Skip to content

Commit 4b372ff

Browse files
committed
add option to send to another sns topic on a per-form basis
1 parent 7bc077e commit 4b372ff

5 files changed

Lines changed: 168 additions & 24 deletions

File tree

docs/sns-per-form-topic-routing.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SNS per-form topic routing
2+
3+
By default, all form submissions are published to a single global SNS topic configured via `SNS_ADAPTER_TOPIC_ARN`, however we can additionally route specific forms to their own SNS topics.
4+
5+
When a form is submitted, the runner always publishes to the global topic. If the submitted form's ID is present in the per-form topic map, the exact same payload is also published to the configured topic for that form.
6+
7+
## Configuration
8+
9+
Set the `SNS_FORM_TOPIC_ARN_MAP` environment variable to a JSON object that maps form IDs to SNS topic ARNs. Environment variables are managed in the [cdp-app-config](https://github.com/defra/cdp-app-config) repository.
10+
11+
```
12+
SNS_FORM_TOPIC_ARN_MAP='{"<formId>":"<topicArn>"}'
13+
```
14+
15+
### Example
16+
17+
```
18+
SNS_FORM_TOPIC_ARN_MAP='{"abc123":"arn:aws:sns:eu-west-2:123456789012:my-form-topic","def456":"arn:aws:sns:eu-west-2:123456789012:another-form-topic"}'
19+
```
20+
21+
In this example:
22+
23+
- Form `abc123` publishes to the global topic and to `my-form-topic`
24+
- Form `def456` publishes to the global topic and to `another-form-topic`
25+
- All other forms publish to the global topic only
26+
27+
## Future direction
28+
29+
The longer-term intention is to move this configuration into Designer, allowing form authors to add additional outputs directly against a form without requiring an environment variable change. This approach was not implemented due to time constraints and a deadline. The environment variable solution described here is the interim approach until that work is prioritised.
30+
31+
## Notes
32+
33+
- The variable must be valid JSON. An invalid value will cause the application to fail on startup.
34+
- The payload published to the form-specific topic is identical to the one sent to the global topic.
35+
- If `SNS_FORM_TOPIC_ARN_MAP` is not set, all forms publish to the global topic only — there is no change in behaviour.

jest.setup.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ process.env.SNS_SAVE_TOPIC_ARN =
1919
process.env.SNS_ADAPTER_TOPIC_ARN =
2020
'arn:aws:sns:eu-west-2:123456789012:test-adapter-topic'
2121
process.env.SNS_ENDPOINT = 'http://localhost:4566'
22+
process.env.SNS_FORM_TOPIC_ARN_MAP =
23+
'{"507f1f77bcf86cd799439099":"arn:aws:sns:eu-west-2:123456789012:form-specific-topic"}'

src/config/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,12 @@ export const config = convict({
184184
default: null,
185185
env: 'SNS_ENDPOINT'
186186
} as SchemaObj<string>,
187+
snsFormTopicArnMap: {
188+
doc: 'JSON object mapping formId to SNS topic ARN for per-form additional topic routing',
189+
format: String,
190+
default: '',
191+
env: 'SNS_FORM_TOPIC_ARN_MAP'
192+
} as SchemaObj<string>,
187193

188194
/**
189195
* API integrations

src/server/messaging/formAdapterEventPublisher.test.js

Lines changed: 99 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,32 @@ async function expectValidationError(invalidPayload) {
2424
jest.mock('@aws-sdk/client-sns')
2525
jest.mock('~/src/server/messaging/sns.ts')
2626

27+
/** @type {FormAdapterSubmissionMessagePayload} */
28+
const basePayload = {
29+
meta: {
30+
schemaVersion: 1,
31+
timestamp: new Date(),
32+
formId: '507f1f77bcf86cd799439011',
33+
formSlug: 'test-form',
34+
formName: 'Test Form',
35+
referenceNumber: 'REF-123456',
36+
status: 'live',
37+
isPreview: false,
38+
notificationEmail: 'test@example.com'
39+
},
40+
data: {
41+
main: { field1: 'value1' },
42+
repeaters: {},
43+
files: {}
44+
},
45+
result: {
46+
files: {
47+
main: 'main-file-path',
48+
repeaters: {}
49+
}
50+
}
51+
}
52+
2753
describe('formAdapterEventPublisher', () => {
2854
/** @type {FormAdapterSubmissionMessagePayload} */
2955
let mockPayload
@@ -35,28 +61,8 @@ describe('formAdapterEventPublisher', () => {
3561
jest.clearAllMocks()
3662

3763
mockPayload = /** @type {FormAdapterSubmissionMessagePayload} */ ({
38-
meta: {
39-
schemaVersion: 1,
40-
timestamp: new Date(),
41-
formId: '507f1f77bcf86cd799439011',
42-
formSlug: 'test-form',
43-
formName: 'Test Form',
44-
referenceNumber: 'REF-123456',
45-
status: 'live',
46-
isPreview: false,
47-
notificationEmail: 'test@example.com'
48-
},
49-
data: {
50-
main: { field1: 'value1' },
51-
repeaters: {},
52-
files: {}
53-
},
54-
result: {
55-
files: {
56-
main: 'main-file-path',
57-
repeaters: {}
58-
}
59-
}
64+
...basePayload,
65+
meta: { ...basePayload.meta }
6066
})
6167

6268
mockSnsClient = {
@@ -105,7 +111,7 @@ describe('formAdapterEventPublisher', () => {
105111
expect(mockSnsClient.send).toHaveBeenCalled()
106112
})
107113

108-
it('serializes entire payload as JSON message', async () => {
114+
it('serialises entire payload as JSON message', async () => {
109115
mockSnsClient.send.mockResolvedValue({ MessageId: 'msg-789' })
110116

111117
const complexPayload =
@@ -245,6 +251,76 @@ describe('formAdapterEventPublisher', () => {
245251
})
246252
})
247253

254+
describe('per-form topic routing (SNS_FORM_TOPIC_ARN_MAP)', () => {
255+
// formId must match the entry in jest.setup.cjs SNS_FORM_TOPIC_ARN_MAP
256+
const formSpecificArn =
257+
'arn:aws:sns:eu-west-2:123456789012:form-specific-topic'
258+
const mappedFormId = '507f1f77bcf86cd799439099'
259+
260+
/** @type {FormAdapterSubmissionMessagePayload} */
261+
const mappedPayload = {
262+
...basePayload,
263+
meta: { ...basePayload.meta, formId: mappedFormId }
264+
}
265+
266+
/** @type {any} */
267+
let mockSnsClient
268+
269+
beforeEach(() => {
270+
mockSnsClient = { send: jest.fn() }
271+
jest.mocked(getSNSClient).mockReturnValue(mockSnsClient)
272+
})
273+
274+
it('publishes to form-specific topic in addition to global topic when formId is mapped', async () => {
275+
mockSnsClient.send
276+
.mockResolvedValueOnce({ MessageId: 'global-msg-id' })
277+
.mockResolvedValueOnce({ MessageId: 'form-specific-msg-id' })
278+
279+
const result = await publishFormAdapterEvent(mappedPayload)
280+
281+
expect(result).toBe('global-msg-id')
282+
expect(mockSnsClient.send).toHaveBeenCalledTimes(2)
283+
expect(PublishCommand).toHaveBeenNthCalledWith(1, {
284+
TopicArn: 'arn:aws:sns:eu-west-2:123456789012:test-adapter-topic',
285+
Message: JSON.stringify(mappedPayload)
286+
})
287+
expect(PublishCommand).toHaveBeenNthCalledWith(2, {
288+
TopicArn: formSpecificArn,
289+
Message: JSON.stringify(mappedPayload)
290+
})
291+
})
292+
293+
it('does not publish to form-specific topic when formId is not in the map', async () => {
294+
mockSnsClient.send.mockResolvedValueOnce({ MessageId: 'global-msg-id' })
295+
296+
// basePayload uses formId 507f1f77bcf86cd799439011 which is not in the map
297+
await publishFormAdapterEvent(basePayload)
298+
299+
expect(mockSnsClient.send).toHaveBeenCalledTimes(1)
300+
})
301+
302+
it('throws when form-specific publish returns no MessageId', async () => {
303+
mockSnsClient.send
304+
.mockResolvedValueOnce({ MessageId: 'global-msg-id' })
305+
.mockResolvedValueOnce({})
306+
307+
await expect(publishFormAdapterEvent(mappedPayload)).rejects.toThrow(
308+
'Failed to publish form adapter event to form-specific topic - no message ID returned'
309+
)
310+
})
311+
312+
it('sends the exact same message string to both topics', async () => {
313+
mockSnsClient.send
314+
.mockResolvedValueOnce({ MessageId: 'global-msg-id' })
315+
.mockResolvedValueOnce({ MessageId: 'form-specific-msg-id' })
316+
317+
await publishFormAdapterEvent(mappedPayload)
318+
319+
const calls = /** @type {any[]} */ (PublishCommand).mock.calls
320+
expect(calls[0][0].Message).toBe(calls[1][0].Message)
321+
})
322+
})
323+
248324
/**
249325
* @import { FormAdapterSubmissionMessagePayload } from '@defra/forms-engine-plugin/engine/types.js'
250326
*/

src/server/messaging/formAdapterEventPublisher.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { getSNSClient } from '~/src/server/messaging/sns.js'
88

99
const logger = createLogger()
1010
const snsAdapterTopicArn = config.get('snsAdapterTopicArn')
11+
const snsFormTopicArnMapRaw = config.get('snsFormTopicArnMap')
12+
const snsFormTopicArnMap: Record<string, string> = snsFormTopicArnMapRaw
13+
? (JSON.parse(snsFormTopicArnMapRaw) as Record<string, string>)
14+
: {}
1115

1216
/**
1317
* Validate form adapter submission payload against schema
@@ -55,12 +59,13 @@ export async function publishFormAdapterEvent(
5559
}
5660

5761
const validatedPayload = validateFormAdapterPayload(submissionPayload)
62+
const message = JSON.stringify(validatedPayload)
5863

5964
const snsClient = getSNSClient()
6065
const result = await snsClient.send(
6166
new PublishCommand({
6267
TopicArn: snsAdapterTopicArn,
63-
Message: JSON.stringify(validatedPayload)
68+
Message: message
6469
})
6570
)
6671

@@ -74,5 +79,25 @@ export async function publishFormAdapterEvent(
7479
`Published form adapter event for submission ${validatedPayload.meta.referenceNumber}. MessageId: ${result.MessageId}`
7580
)
7681

82+
const formSpecificTopicArn = snsFormTopicArnMap[validatedPayload.meta.formId]
83+
if (formSpecificTopicArn) {
84+
const formSpecificResult = await snsClient.send(
85+
new PublishCommand({
86+
TopicArn: formSpecificTopicArn,
87+
Message: message
88+
})
89+
)
90+
91+
if (!formSpecificResult.MessageId) {
92+
throw new Error(
93+
'Failed to publish form adapter event to form-specific topic - no message ID returned'
94+
)
95+
}
96+
97+
logger.info(
98+
`Published form adapter event to form-specific topic for submission ${validatedPayload.meta.referenceNumber}. MessageId: ${formSpecificResult.MessageId}`
99+
)
100+
}
101+
77102
return result.MessageId
78103
}

0 commit comments

Comments
 (0)