comq 0.5.3-dev.1 → 0.6.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # Message Properties
2
+
3
+ Events may be emitted and consumed with custom properties using an optional `properties` argument.
4
+
5
+ ## Emission
6
+
7
+ `async IO.emit(exchange: string, payload: any, [properties: comq.amqp.Properties]): void`
8
+
9
+ `properties` are passed
10
+ to [amqplib.publish](https://amqp-node.github.io/amqplib/channel_api.html#channel_publish).
11
+
12
+ ## Consumption
13
+
14
+ `async IO.consume(exchange: string, [group: string], consumer): void`
15
+
16
+ `consumer` function's signature
17
+ is `async? (payload: any, [properties: comq.amqp.Properties]): void`
@@ -0,0 +1,29 @@
1
+ Feature: Message properties
2
+
3
+ Background:
4
+ Given an active connection to the broker
5
+
6
+ Scenario: Sending and receiving an event with headers
7
+ Given that `checker` is consuming events from the `numbers_added` exchange
8
+ When an event is emitted to the `numbers_added` exchange with properties:
9
+ """yaml
10
+ headers:
11
+ foo: bar
12
+ """
13
+ Then `checker` receives the event with properties:
14
+ """yaml
15
+ contentType: application/octet-stream # standard properties are available
16
+ headers:
17
+ foo: bar
18
+ """
19
+
20
+ Scenario: Sending and receiving an event with AMQP properties
21
+ Given that `checker` is consuming events from the `numbers_added` exchange
22
+ When an event is emitted to the `numbers_added` exchange with properties:
23
+ """yaml
24
+ appId: test
25
+ """
26
+ Then `checker` receives the event with properties:
27
+ """yaml
28
+ appId: test
29
+ """
@@ -1,9 +1,11 @@
1
1
  'use strict'
2
2
 
3
3
  const { AssertionError } = require('node:assert')
4
+ const { randomBytes } = require('node:crypto')
4
5
  const { generate } = require('randomstring')
5
6
  const { promex, timeout, random } = require('@toa.io/generic')
6
7
  const tomato = require('@toa.io/tomato')
8
+ const { dump } = require('@toa.io/yaml')
7
9
  const { io } = require('./io.mock')
8
10
  const mock = { tomato }
9
11
 
@@ -47,7 +49,16 @@ describe('Given (that ){token} is consuming events from the {token} exchange', (
47
49
 
48
50
  await consumer(payload)
49
51
 
50
- expect(context.consumed[group]).toStrictEqual(payload)
52
+ expect(context.consumed[group]).toStrictEqual(expect.objectContaining({ payload }))
53
+ })
54
+
55
+ it('should store event properties', async () => {
56
+ const payload = generate()
57
+ const properties = generate()
58
+
59
+ await consumer(payload, properties)
60
+
61
+ expect(context.consumed[group]).toStrictEqual({ payload, properties })
51
62
  })
52
63
  })
53
64
 
@@ -79,13 +90,13 @@ describe('When an event is emitted to the {token} exchange', () => {
79
90
  })
80
91
 
81
92
  it('should emit event', async () => {
82
- expect(io.emit).toHaveBeenCalledWith(exchange, expect.anything())
93
+ expect(io.emit.mock.calls[0][0]).toStrictEqual(exchange)
83
94
  })
84
95
 
85
96
  it('should wait for context.expected', async () => {
86
97
  jest.clearAllMocks()
87
98
 
88
- context.expected = promex()
99
+ context.consumptionPromise = promex()
89
100
 
90
101
  let resolved = false
91
102
 
@@ -93,7 +104,7 @@ describe('When an event is emitted to the {token} exchange', () => {
93
104
  expect(io.emit).not.toHaveBeenCalled()
94
105
 
95
106
  resolved = true
96
- context.expected.resolve()
107
+ context.consumptionPromise.resolve()
97
108
  })
98
109
 
99
110
  await step.call(context, exchange)
@@ -103,20 +114,42 @@ describe('When an event is emitted to the {token} exchange', () => {
103
114
  })
104
115
  })
105
116
 
117
+ describe('When an event is emitted to the {token} exchange with properties:', () => {
118
+ const step = tomato.steps.Wh('an event is emitted to the {token} exchange with properties:')
119
+
120
+ it('should be', async () => undefined)
121
+
122
+ const exchange = generate()
123
+ const properties = { [generate()]: generate() }
124
+ const yaml = dump(properties)
125
+
126
+ beforeEach(async () => {
127
+ await step.call(context, exchange, yaml)
128
+ })
129
+
130
+ it('should store event', async () => {
131
+ expect(context.published).toBeDefined()
132
+ })
133
+
134
+ it('should emit event', async () => {
135
+ expect(io.emit).toHaveBeenCalledWith(exchange, expect.anything(), properties)
136
+ })
137
+ })
138
+
106
139
  describe('Then {token} receives the event', () => {
107
140
  const step = tomato.steps.Th('{token} receives the event')
108
141
 
109
- const payload = generate()
142
+ const payload = randomBytes(8)
110
143
  const group = generate()
111
144
 
112
145
  beforeEach(() => {
113
- context.consumed = { [group]: payload }
146
+ context.consumed = { [group]: { payload } }
114
147
  })
115
148
 
116
149
  it('should be', async () => undefined)
117
150
 
118
151
  it('should throw if not consumed', async () => {
119
- context.published = generate()
152
+ context.published = randomBytes(8)
120
153
 
121
154
  await expect(step.call(context, group)).rejects.toThrow(AssertionError)
122
155
  })
@@ -128,20 +161,47 @@ describe('Then {token} receives the event', () => {
128
161
  })
129
162
  })
130
163
 
164
+ describe('Then {token} receives the event with properties:', () => {
165
+ const step = tomato.steps.Th('{token} receives the event with properties:')
166
+
167
+ it('should be', async () => undefined)
168
+
169
+ const payload = randomBytes(8)
170
+ const properties = { headers: { [generate()]: generate() } }
171
+ const yaml = dump(properties)
172
+ const group = generate()
173
+
174
+ beforeEach(() => {
175
+ context.consumed = { [group]: { payload, properties } }
176
+ context.published = payload
177
+ })
178
+
179
+ it('should throw if does not match', async () => {
180
+ const properties = { headers: { [generate()]: generate() } }
181
+ const yaml = dump(properties)
182
+
183
+ await expect(step.call(context, group, yaml)).rejects.toThrow(AssertionError)
184
+ })
185
+
186
+ it('should pass if matches', async () => {
187
+ await expect(step.call(context, group, yaml)).resolves.not.toThrow()
188
+ })
189
+ })
190
+
131
191
  describe('Then the event is received', () => {
132
192
  const step = tomato.steps.Th('the event is received')
133
193
 
134
- const payload = generate()
194
+ const payload = randomBytes(8)
135
195
  const group = undefined
136
196
 
137
197
  beforeEach(() => {
138
- context.consumed = { [group]: payload }
198
+ context.consumed = { [group]: { payload } }
139
199
  })
140
200
 
141
201
  it('should be', async () => undefined)
142
202
 
143
203
  it('should throw if not consumed', async () => {
144
- context.published = generate()
204
+ context.published = randomBytes(8)
145
205
 
146
206
  await expect(step.call(context, group)).rejects.toThrow(AssertionError)
147
207
  })
@@ -1,5 +1,6 @@
1
1
  import * as _diagnostics from '../../../types/diagnostic'
2
2
  import * as _io from '../../../types/io'
3
+ import * as _amqp from '../../../types/amqp'
3
4
 
4
5
  declare namespace comq.features {
5
6
 
@@ -9,14 +10,14 @@ declare namespace comq.features {
9
10
  connecting: Promise<any>
10
11
  requestsSent: Promise<any>[]
11
12
  reply?: Promise<any>
12
- published?: any
13
+ published?: Buffer
13
14
  eventsPublishedCount: number
14
15
  eventsConsumedCount: number
15
- consumed?: Record<string, any>
16
+ consumed?: Record<string, { payload: any, properties?: _amqp.Properties }>
16
17
  consumedCount: number
17
18
  events?: { [K in _diagnostics.event]?: boolean }
18
19
  exception?: Error
19
- expected?: Promise<any>
20
+ consumptionPromise?: Promise<any>
20
21
  sharded: boolean
21
22
  shard: number
22
23
  sealing: Promise<any>
@@ -1,10 +1,10 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const { generate } = require('randomstring')
5
- const { timeout, quantity } = require('@toa.io/generic')
6
- const { Given, When, Then } = require('@cucumber/cucumber')
7
4
  const { randomBytes } = require('node:crypto')
5
+ const { timeout, quantity, match } = require('@toa.io/generic')
6
+ const { parse } = require('@toa.io/yaml')
7
+ const { Given, When, Then } = require('@cucumber/cucumber')
8
8
 
9
9
  Given('(that ){token} is consuming events from the {token} exchange',
10
10
  /**
@@ -25,7 +25,7 @@ Given('{token} consuming events from the {token} exchange is expected',
25
25
  async function (group, exchange) {
26
26
  await timeout(500) // let it crash
27
27
 
28
- this.expected = consume.call(this, group, exchange)
28
+ this.consumptionPromise = consume.call(this, group, exchange)
29
29
  })
30
30
 
31
31
  Given('(that )events are exclusively consumed from the {token} exchange',
@@ -34,7 +34,7 @@ Given('(that )events are exclusively consumed from the {token} exchange',
34
34
  * @this {comq.features.Context}
35
35
  */
36
36
  async function (exchange) {
37
- this.expected = consume.call(this, undefined, exchange)
37
+ this.consumptionPromise = consume.call(this, undefined, exchange)
38
38
  })
39
39
 
40
40
  When('an event is emitted to the {token} exchange',
@@ -43,13 +43,22 @@ When('an event is emitted to the {token} exchange',
43
43
  * @this {comq.features.Context}
44
44
  */
45
45
  async function (exchange) {
46
- if (this.expected) await this.expected
46
+ const message = randomBytes(8)
47
47
 
48
- const message = generate()
48
+ await emit.call(this, exchange, message)
49
+ })
49
50
 
50
- await this.io.emit(exchange, message)
51
+ When('an event is emitted to the {token} exchange with properties:',
52
+ /**
53
+ * @param {string} exchange
54
+ * @param {string} yaml
55
+ * @this {comq.features.Context}
56
+ */
57
+ async function (exchange, yaml) {
58
+ const message = randomBytes(8)
59
+ const properties = parse(yaml)
51
60
 
52
- this.published = message
61
+ await emit.call(this, exchange, message, properties)
53
62
  })
54
63
 
55
64
  Then('{token} receives the event',
@@ -61,6 +70,21 @@ Then('{token} receives the event',
61
70
  await consumed.call(this, group)
62
71
  })
63
72
 
73
+ Then('{token} receives the event with properties:',
74
+ /**
75
+ * @param {string} group
76
+ * @param {string} yaml
77
+ * @this {comq.features.Context}
78
+ */
79
+ async function (group, yaml) {
80
+ await consumed.call(this, group)
81
+
82
+ const properties = parse(yaml)
83
+ const matches = match(this.consumed[group].properties, properties)
84
+
85
+ assert.equal(matches, true, 'Consumed event properties doesn\'t match')
86
+ })
87
+
64
88
  Then('the event is received',
65
89
  /**
66
90
  * @this {comq.features.Context}
@@ -115,8 +139,8 @@ Then('all events have been received',
115
139
  async function consume (group, exchange) {
116
140
  this.consumed ??= {}
117
141
 
118
- const consumer = async (payload) => {
119
- this.consumed[group] = payload
142
+ const consumer = async (payload, properties) => {
143
+ this.consumed[group] = { payload, properties }
120
144
  this.eventsConsumedCount++
121
145
  }
122
146
 
@@ -133,5 +157,24 @@ async function consumed (group) {
133
157
  await timeout(100) // let it consume
134
158
 
135
159
  assert.notEqual(this.published, undefined, 'No event has been published')
136
- assert.equal(this.published, this.consumed[group], 'Event hasn\'t been exclusively consumed')
160
+
161
+ const consumed = this.consumed[group]
162
+ const equals = this.published.equals(consumed.payload)
163
+
164
+ assert.equal(equals, true, 'Consumed event doesn\'t match')
165
+ }
166
+
167
+ /**
168
+ * @param {string} exchange
169
+ * @param {any} message
170
+ * @param {import('amqplib').Options.Publish} [properties]
171
+ * @return {Promise<void>}
172
+ * @this {comq.features.Context}
173
+ */
174
+ async function emit (exchange, message, properties) {
175
+ if (this.consumptionPromise) await this.consumptionPromise
176
+
177
+ await this.io.emit(exchange, message, properties)
178
+
179
+ this.published = message
137
180
  }
package/lerna.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "useWorkspaces": true,
3
- "version": "0.5.3-dev.1",
3
+ "version": "0.6.0-dev.1",
4
4
  "conventionalCommits": true
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.5.3-dev.1",
3
+ "version": "0.6.0-dev.1",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -28,10 +28,10 @@
28
28
  "types": "types/index.d.ts",
29
29
  "devDependencies": {
30
30
  "@cucumber/cucumber": "9.1.0",
31
- "@toa.io/command": "0.3.0",
31
+ "@toa.io/command": "0.4.0",
32
32
  "@toa.io/mock": "0.3.0",
33
33
  "@toa.io/tomato": "0.4.0-dev.2",
34
- "@toa.io/yaml": "0.4.0-dev.2",
34
+ "@toa.io/yaml": "0.4.0",
35
35
  "@types/amqplib": "0.10.1",
36
36
  "@types/jest": "29.5.0",
37
37
  "husky": "8.0.3",
@@ -41,7 +41,7 @@
41
41
  "standard": "17.0.0"
42
42
  },
43
43
  "dependencies": {
44
- "@toa.io/generic": "0.4.0-dev.5",
44
+ "@toa.io/generic": "0.4.0",
45
45
  "amqplib": "0.10.3",
46
46
  "msgpackr": "1.8.5"
47
47
  },
package/source/io.js CHANGED
@@ -67,13 +67,13 @@ class IO {
67
67
  * @returns {Promise<void>}
68
68
  */
69
69
  async (queue, payload, encoding, replyToFormatter) => {
70
- const [buffer, properties] = this.#encode(payload, encoding)
70
+ const [buffer, contentType] = this.#encode(payload, encoding)
71
71
  const emitter = this.#emitters.get(queue)
72
72
  const reply = this.#createReply()
73
73
  const correlationId = randomBytes(8).toString('hex')
74
+ const properties = { contentType, correlationId }
74
75
 
75
76
  properties.replyTo = replyToFormatter?.(emitter.queue) ?? emitter.queue
76
- properties.correlationId = correlationId
77
77
 
78
78
  emitter.once(correlationId, reply.resolve)
79
79
 
@@ -100,11 +100,22 @@ class IO {
100
100
  /**
101
101
  * @param {string} exchange
102
102
  * @param {any} payload
103
- * @param {comq.encoding} [encoding]
103
+ * @param {comq.encoding | comq.amqp.options.Publish} [encoding]
104
104
  * @returns {Promise<void>}
105
105
  */
106
106
  async (exchange, payload, encoding) => {
107
- const [buffer, properties] = this.#encode(payload, encoding)
107
+ /** @type {comq.amqp.options.Publish} */
108
+ const properties = {}
109
+
110
+ if (typeof encoding === 'object') { // properties passed
111
+ Object.assign(properties, encoding)
112
+
113
+ encoding = /** @type {comq.encoding} */ properties.contentType
114
+ }
115
+
116
+ const [buffer, contentType] = this.#encode(payload, encoding)
117
+
118
+ properties.contentType = contentType
108
119
 
109
120
  await this.#events.publish(exchange, buffer, properties)
110
121
  })
@@ -127,14 +138,10 @@ class IO {
127
138
  // region initializers
128
139
 
129
140
  async #createRequestReplyChannels () {
130
- await this.#createRequestChannel()
131
-
141
+ this.#requests = await this.#createChannel('request')
132
142
  this.#replies = await this.#createChannel('reply')
133
- this.#setupRetransmission()
134
- }
135
143
 
136
- async #createRequestChannel () {
137
- this.#requests = await this.#createChannel('request')
144
+ this.#setupRetransmission()
138
145
  }
139
146
 
140
147
  async #createEventChannel () {
@@ -228,7 +235,7 @@ class IO {
228
235
  track(this, async (message) => {
229
236
  const payload = decode(message)
230
237
 
231
- await callback(payload)
238
+ await callback(payload, message.properties)
232
239
  })
233
240
 
234
241
  /**
@@ -260,7 +267,7 @@ class IO {
260
267
  /**
261
268
  * @param {any} payload
262
269
  * @param {comq.encoding} [contentType]
263
- * @returns [Buffer, comq.amqp.options.Publish]
270
+ * @returns [Buffer, comq.encoding]
264
271
  */
265
272
  #encode (payload, contentType) {
266
273
  const raw = Buffer.isBuffer(payload)
@@ -268,9 +275,8 @@ class IO {
268
275
  contentType ??= raw ? OCTETS : DEFAULT
269
276
 
270
277
  const buffer = raw ? payload : encode(payload, contentType)
271
- const properties = { contentType }
272
278
 
273
- return [buffer, properties]
279
+ return [buffer, contentType]
274
280
  }
275
281
  }
276
282
 
@@ -68,7 +68,20 @@ describe('group consumption', () => {
68
68
 
69
69
  await callback(message)
70
70
 
71
- expect(consumer).toHaveBeenCalledWith(payload)
71
+ expect(consumer.mock.calls[0][0]).toStrictEqual(payload)
72
+ })
73
+
74
+ it('should pass properties', async () => {
75
+ const payload = generate()
76
+ const contentType = 'text/plain'
77
+ const content = encode(payload, contentType)
78
+ const properties = { contentType }
79
+ const message = /** @type {comq.amqp.Message} */ { content, properties }
80
+ const callback = events.subscribe.mock.calls[0][2]
81
+
82
+ await callback(message)
83
+
84
+ expect(consumer).toHaveBeenCalledWith(payload, properties)
72
85
  })
73
86
  })
74
87
 
@@ -94,7 +107,7 @@ describe.each(['omitted', 'undefined'])('exclusive consumption (group is %s)', (
94
107
 
95
108
  await emit(message)
96
109
 
97
- expect(consumer).toHaveBeenCalledWith(message.content)
110
+ expect(consumer.mock.calls[0][0]).toStrictEqual(message.content)
98
111
  })
99
112
  })
100
113
 
@@ -83,12 +83,38 @@ it('should publish Buffer with specified encoding format', async () => {
83
83
  const encoding = 'application/json'
84
84
 
85
85
  await io.emit(exchange, payload, encoding)
86
+
86
87
  const [, buffer, properties] = events.publish.mock.calls[1]
87
88
 
88
89
  expect(buffer).toStrictEqual(payload)
89
90
  expect(properties.contentType).toStrictEqual(encoding)
90
91
  })
91
92
 
93
+ it('should publish a message with specified properties', async () => {
94
+ const payload = randomBytes(8)
95
+
96
+ /** @type {comq.amqp.Properties} */
97
+ const properties = { headers: { [generate()]: generate() } }
98
+
99
+ await io.emit(exchange, payload, properties)
100
+
101
+ const [, , options] = events.publish.mock.calls[1]
102
+
103
+ expect(options).toStrictEqual(expect.objectContaining(properties))
104
+ })
105
+
106
+ it('should publish a message with headers', async () => {
107
+ const message = generate()
108
+ const headers = { [generate()]: generate() }
109
+ const properties = { headers }
110
+
111
+ await io.emit(exchange, message, properties)
112
+
113
+ const [, , options] = events.publish.mock.calls[1]
114
+
115
+ expect(options.headers).toStrictEqual(headers)
116
+ })
117
+
92
118
  /**
93
119
  * @param {comq.topology.type} type
94
120
  * @returns {jest.MockedObject<comq.Channel>}
package/types/amqp.d.ts CHANGED
@@ -12,7 +12,9 @@ declare namespace comq.amqp {
12
12
  type Connection = amqplib.Connection
13
13
  type Channel = amqplib.Channel | amqplib.ConfirmChannel
14
14
  type Message = amqplib.Message
15
+ type Properties = Partial<amqplib.MessageProperties>
15
16
  }
16
17
 
17
18
  export type Connection = comq.amqp.Connection
18
19
  export type Channel = comq.amqp.Channel
20
+ export type Properties = comq.amqp.Properties
@@ -2,6 +2,11 @@ declare namespace comq.diagnostics {
2
2
 
3
3
  type event = 'open' | 'close' | 'flow' | 'drain' | 'remove' | 'recover' | 'discard'
4
4
 
5
+ interface Diagnosable {
6
+ diagnose(event: event, listener: Function): void
7
+ }
8
+
5
9
  }
6
10
 
7
11
  export type event = comq.diagnostics.event
12
+ export type Diagnosable = comq.diagnostics.Diagnosable
package/types/io.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import * as _diagnostics from './diagnostic'
2
2
  import * as _encoding from './encoding'
3
+ import * as _topology from './topology'
4
+ import * as _amqp from './amqp'
3
5
 
4
6
  declare namespace comq {
5
7
 
6
- type producer = (message: any) => Promise<any>
7
- type consumer = (message: any) => Promise<void>
8
+ type producer = (message: any) => any | Promise<any>
9
+ type consumer = (message: any, headers?: _amqp.Properties) => void | Promise<void>
8
10
 
9
11
  interface ReplyEmitter {
10
12
  queue: string
@@ -18,7 +20,7 @@ declare namespace comq {
18
20
 
19
21
  type ReplyToPropertyFormatter = (queue: string) => string
20
22
 
21
- interface IO {
23
+ interface IO extends _diagnostics.Diagnosable {
22
24
  reply(queue: string, produce: producer): Promise<void>
23
25
 
24
26
  request(
@@ -34,13 +36,26 @@ declare namespace comq {
34
36
 
35
37
  emit(exchange: string, payload: any, encoding?: _encoding.encoding): Promise<void>
36
38
 
39
+ emit(exchange: string, payload: any, properties?: _amqp.Properties): Promise<void>
40
+
37
41
  seal(): Promise<void>
38
42
 
39
43
  close(): Promise<void>
40
44
 
41
- diagnose(event: _diagnostics.event, listener: Function): void
42
- }
45
+ diagnose(event: 'open', listener: (index?: number) => void)
46
+
47
+ diagnose(event: 'close', listener: (index?: number) => void)
48
+
49
+ diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void)
43
50
 
51
+ diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void)
52
+
53
+ diagnose(event: 'remove', listener: (index?: number) => void)
54
+
55
+ diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void)
56
+
57
+ diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void)
58
+ }
44
59
  }
45
60
 
46
61
  export type producer = comq.producer