From b813a53f72fd1f080adf441cedb3d6e37d29018a Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Tue, 4 Aug 2026 10:28:08 +0100 Subject: [PATCH 01/16] CCM-22090: Recipient response message retrieval API --- proxies/live/apiproxy/targets/target.xml | 1 + proxies/sandbox/apiproxy/targets/sandbox.xml | 1 + ...tial.Flows.GetMessageResponsesEndpoint.xml | 24 +++++ ...gnMessage.MessageResponses.Get.Request.xml | 33 +++++++ ...nMessage.MessageResponses.Get.Response.xml | 10 ++ ...Variables.MessageResponses.Get.Request.xml | 33 +++++++ .../resources/jsc/Routing.CheckValid.js | 4 + sandbox/__test__/message_responses.spec.js | 98 +++++++++++++++++++ sandbox/app.js | 1 + sandbox/handlers/index.js | 1 + sandbox/handlers/message_responses.js | 67 +++++++++++++ tests/api/message_responses/__init__.py | 0 .../api/message_responses/test_200_success.py | 20 ++++ tests/api/message_responses/test_404.py | 22 +++++ .../lib/constants/message_responses_paths.py | 14 +++ tests/sandbox/message_responses/__init__.py | 0 tests/sandbox/message_responses/test_400.py | 18 ++++ tests/sandbox/message_responses/test_404.py | 23 +++++ tests/sandbox/message_responses/test_500.py | 23 +++++ tests/sandbox/message_responses/test_502.py | 23 +++++ .../sandbox/message_responses/test_success.py | 32 ++++++ 21 files changed, 448 insertions(+) create mode 100644 proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml create mode 100644 proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml create mode 100644 proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml create mode 100644 proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml create mode 100644 sandbox/__test__/message_responses.spec.js create mode 100644 sandbox/handlers/message_responses.js create mode 100644 tests/api/message_responses/__init__.py create mode 100644 tests/api/message_responses/test_200_success.py create mode 100644 tests/api/message_responses/test_404.py create mode 100644 tests/lib/constants/message_responses_paths.py create mode 100644 tests/sandbox/message_responses/__init__.py create mode 100644 tests/sandbox/message_responses/test_400.py create mode 100644 tests/sandbox/message_responses/test_404.py create mode 100644 tests/sandbox/message_responses/test_500.py create mode 100644 tests/sandbox/message_responses/test_502.py create mode 100644 tests/sandbox/message_responses/test_success.py diff --git a/proxies/live/apiproxy/targets/target.xml b/proxies/live/apiproxy/targets/target.xml index c9c0bfdd3..ad495a61e 100644 --- a/proxies/live/apiproxy/targets/target.xml +++ b/proxies/live/apiproxy/targets/target.xml @@ -9,6 +9,7 @@ [% include './partials/Partial.Flows.CreateMessageEndpoint.xml' %] [% include './partials/Partial.Flows.GetMessageEndpoint.xml' %] [% include './partials/Partial.Flows.GetNhsAppAccountsEndpoint.xml' %] + [% include './partials/Partial.Flows.GetMessageResponsesEndpoint.xml' %] [% include './partials/Partial.Target.PostFlow.xml' %] diff --git a/proxies/sandbox/apiproxy/targets/sandbox.xml b/proxies/sandbox/apiproxy/targets/sandbox.xml index 4543da831..6a6b53eb9 100644 --- a/proxies/sandbox/apiproxy/targets/sandbox.xml +++ b/proxies/sandbox/apiproxy/targets/sandbox.xml @@ -9,6 +9,7 @@ [% include './partials/Partial.Flows.CreateMessageEndpoint.xml' %] [% include './partials/Partial.Flows.GetMessageEndpoint.xml' %] [% include './partials/Partial.Flows.GetNhsAppAccountsEndpoint.xml' %] + [% include './partials/Partial.Flows.GetMessageResponsesEndpoint.xml' %] [% include './partials/Partial.Target.PostFlow.xml' %] diff --git a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml new file mode 100644 index 000000000..bc1a24275 --- /dev/null +++ b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml @@ -0,0 +1,24 @@ + + Handle get message responses + + + ExtractVariables.MessageResponses.Get.Request + + + AssignMessage.MessageResponses.Get.Request + + {% if ENVIRONMENT_TYPE != 'sandbox' %} + + AssignMessage.AuthenticationDetails + + {% endif %} + + + + AssignMessage.MessageResponses.Get.Response + + + + (proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and (request.verb = "GET") + + diff --git a/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml new file mode 100644 index 000000000..6c3d8e8c9 --- /dev/null +++ b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml @@ -0,0 +1,33 @@ + + + + AssignMessage.MessageResponses.Get.Request + + + true + + target.copy.pathsuffix + false + + + requestpath + + + + +
{backendCorrelationId}
+
+ GET + + {data.responseId} + {data.clientId} + {data.correlationId} + {data.cursor} + {data.pageSize} + {data.authoredAtFrom} + {data.authoredAtTo} + +
+
diff --git a/proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml new file mode 100644 index 000000000..36535d230 --- /dev/null +++ b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml @@ -0,0 +1,10 @@ + + + + AssignMessage.MessageResponses.Get.Response + + + true + diff --git a/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml b/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml new file mode 100644 index 000000000..c0633ea3a --- /dev/null +++ b/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml @@ -0,0 +1,33 @@ + + + + data + request + + /v1/message-responses/{messageId} + + + {responseId} + + + {clientId} + + + {correlationId} + + + {cursor} + + + {pageSize} + + + {authoredAtFrom} + + + {authoredAtTo} + + true + diff --git a/proxies/shared/resources/jsc/Routing.CheckValid.js b/proxies/shared/resources/jsc/Routing.CheckValid.js index 1a7087403..e26fabf28 100644 --- a/proxies/shared/resources/jsc/Routing.CheckValid.js +++ b/proxies/shared/resources/jsc/Routing.CheckValid.js @@ -26,6 +26,10 @@ const validPaths = [ { match: /^\/channels\/nhsapp\/accounts$/, methods: ['get'] + }, + { + match: /^\/v1\/message-responses\/.*$/, + methods: ['get'] } ]; diff --git a/sandbox/__test__/message_responses.spec.js b/sandbox/__test__/message_responses.spec.js new file mode 100644 index 000000000..1dbd2b703 --- /dev/null +++ b/sandbox/__test__/message_responses.spec.js @@ -0,0 +1,98 @@ +import request from "supertest" +import * as uuid from 'uuid'; +import { setup } from './helpers.js' + +const VALID_MESSAGE_ID = '11111111-1111-4111-8111-111111111111'; +const NOT_FOUND_MESSAGE_ID = '00000000-0000-4000-8000-000000000404'; +const BAD_GATEWAY_MESSAGE_ID = '00000000-0000-4000-8000-000000000502'; +const TOO_MANY_RESPONSES_MESSAGE_ID = '00000000-0000-4000-8000-000000000500'; + +describe('/api/v1/message-responses/:messageId', () => { + let env; + let server; + + beforeEach(() => { + env = process.env; + server = setup(); + }); + + afterEach(() => { + process.env = env; + server.close(); + }); + + it('returns a X-Correlation-Id when provided', (done) => { + const correlationId = uuid.v4(); + request(server) + .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) + .set('X-Correlation-Id', correlationId) + .expect(200) + .expect('X-Correlation-Id', correlationId, done); + }); + + it('returns a service ban (403) when the user is banned', (done) => { + request(server) + .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) + .set({ Authorization: 'banned' }) + .expect(403, { + message: 'Request rejected because client service ban is in effect.' + }) + .expect('Content-Type', /json/, done); + }); + + it('returns a 400 when messageId is not a UUID', (done) => { + request(server) + .get('/api/v1/message-responses/not-a-valid-uuid') + .expect(400, { + message: 'Invalid message ID format. messageId must be a UUID.' + }) + .expect('Content-Type', /json/, done); + }); + + it('returns a 404 when no responses are found', (done) => { + request(server) + .get(`/api/v1/message-responses/${NOT_FOUND_MESSAGE_ID}`) + .expect(404, { + message: 'No responses found for the given messageId.' + }) + .expect('Content-Type', /json/, done); + }); + + it('returns a 500 when too many responses are returned', (done) => { + request(server) + .get(`/api/v1/message-responses/${TOO_MANY_RESPONSES_MESSAGE_ID}`) + .expect(500, { + message: 'Too many responses returned for this messageId.' + }) + .expect('Content-Type', /json/, done); + }); + + it('returns a 502 when a bad gateway error occurs', (done) => { + request(server) + .get(`/api/v1/message-responses/${BAD_GATEWAY_MESSAGE_ID}`) + .expect(502, { + message: 'Bad Gateway' + }) + .expect('Content-Type', /json/, done); + }); + + it('returns a 200 with correct response structure for a valid messageId', (done) => { + request(server) + .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) + .expect(200) + .expect('Content-Type', /json/) + .expect((res) => { + const { body } = res; + if (!body.messageId) throw new Error('missing messageId'); + if (body.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId'); + if (!Array.isArray(body.responses)) throw new Error('responses must be an array'); + const first = body.responses[0]; + if (!first.responseId) throw new Error('missing responseId'); + if (!first.code) throw new Error('missing code'); + if (!first.channel) throw new Error('missing channel'); + if (!first.channelStatus) throw new Error('missing channelStatus'); + if (!first.authoredAt) throw new Error('missing authoredAt'); + }) + .end(done); + }); +}); diff --git a/sandbox/app.js b/sandbox/app.js index 1fe8b6f9a..c977d2c63 100644 --- a/sandbox/app.js +++ b/sandbox/app.js @@ -129,6 +129,7 @@ app.post("/api/v1/send", handlers.batchSend); app.post("/api/v1/messages", handlers.messages); app.get("/api/v1/messages/:messageId", handlers.getMessage); app.get("/api/channels/nhsapp/accounts", handlers.nhsappAccounts); +app.get("/api/v1/message-responses/:messageId", handlers.messageResponses); app.get("/_timeout", handlers.triggerTimeout); app.get("/_invalid_certificate", handlers.backend403); app.get("/_timeout_408", handlers.backend408); diff --git a/sandbox/handlers/index.js b/sandbox/handlers/index.js index 15b14371b..559a843b0 100644 --- a/sandbox/handlers/index.js +++ b/sandbox/handlers/index.js @@ -3,6 +3,7 @@ export { batchSend } from "./batch_send.js" export { messages } from "./messages.js" export { getMessage } from "./get_message.js" export { nhsappAccounts } from "./nhsapp_accounts.js" +export { messageResponses } from "./message_responses.js" export { triggerTimeout } from "./trigger_timeout.js" export { backend403 } from "./responses/backend_403.js" export { backend408 } from "./responses/backend_408.js" diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js new file mode 100644 index 000000000..c3e0d740f --- /dev/null +++ b/sandbox/handlers/message_responses.js @@ -0,0 +1,67 @@ +import { sendError } from './utils.js' + +const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const notFoundMessageId = '00000000-0000-4000-8000-000000000404'; +const badGatewayMessageId = '00000000-0000-4000-8000-000000000502'; +const tooManyResponsesMessageId = '00000000-0000-4000-8000-000000000500'; + +export async function messageResponses(req, res, next) { + if (req.headers.authorization === 'banned') { + sendError(res, 403, 'Request rejected because client service ban is in effect.'); + next(); + return; + } + + const { messageId } = req.params; + + if (!uuidRegex.test(messageId)) { + sendError(res, 400, 'Invalid message ID format. messageId must be a UUID.'); + next(); + return; + } + + if (messageId === badGatewayMessageId) { + sendError(res, 502, 'Bad Gateway'); + next(); + return; + } + + if (messageId === notFoundMessageId) { + sendError(res, 404, 'No responses found for the given messageId.'); + next(); + return; + } + + if (messageId === tooManyResponsesMessageId) { + sendError(res, 500, 'Too many responses returned for this messageId.'); + next(); + return; + } + + res.type('json').status(200).json(getDefaultResponse(messageId)); +} + +function getDefaultResponse(messageId) { + return { + messageId, + responses: [ + { + responseId: '22222222-2222-4222-8222-222222222222', + messageReference: 'msg-ref-1', + code: 'YES', + channel: 'nhsapp', + channelStatus: 'delivered', + authoredAt: '2026-01-02T09:00:00.000Z' + }, + { + responseId: '33333333-3333-4333-8333-333333333333', + messageReference: 'msg-ref-1', + code: 'NO', + channel: 'nhsapp', + channelStatus: 'delivered', + authoredAt: '2026-01-02T09:05:00.000Z' + } + ] + }; +} diff --git a/tests/api/message_responses/__init__.py b/tests/api/message_responses/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/api/message_responses/test_200_success.py b/tests/api/message_responses/test_200_success.py new file mode 100644 index 000000000..335311829 --- /dev/null +++ b/tests/api/message_responses/test_200_success.py @@ -0,0 +1,20 @@ +import requests +import pytest +from lib import Assertions, Generators +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, VALID_MESSAGE_ID +from lib.fixtures import * # NOSONAR + + +@pytest.mark.devtest +def test_200_success(url, bearer_token): + headers = Generators.generate_valid_headers(bearer_token.value) + + resp = requests.get( + f"{url}{MESSAGE_RESPONSES_ENDPOINT}/{VALID_MESSAGE_ID}", + headers=headers + ) + + assert resp.status_code == 200, f"Response: {resp.status_code}: {resp.text}" + body = resp.json() + assert "messageId" in body + assert isinstance(body.get("responses"), list) diff --git a/tests/api/message_responses/test_404.py b/tests/api/message_responses/test_404.py new file mode 100644 index 000000000..bf9683640 --- /dev/null +++ b/tests/api/message_responses/test_404.py @@ -0,0 +1,22 @@ +import requests +import pytest +from lib import Assertions, Generators +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, NOT_FOUND_MESSAGE_ID +from lib.fixtures import * # NOSONAR + + +@pytest.mark.devtest +def test_404_not_found(url, bearer_token): + headers = Generators.generate_valid_headers(bearer_token.value) + + resp = requests.get( + f"{url}{MESSAGE_RESPONSES_ENDPOINT}/{NOT_FOUND_MESSAGE_ID}", + headers=headers + ) + + Assertions.assert_error_with_optional_correlation_id( + resp, + 404, + Generators.generate_not_found_error(), + None + ) diff --git a/tests/lib/constants/message_responses_paths.py b/tests/lib/constants/message_responses_paths.py new file mode 100644 index 000000000..ad56d9173 --- /dev/null +++ b/tests/lib/constants/message_responses_paths.py @@ -0,0 +1,14 @@ +MESSAGE_RESPONSES_ENDPOINT = "/v1/message-responses" + +VALID_MESSAGE_ID = "11111111-1111-4111-8111-111111111111" +NOT_FOUND_MESSAGE_ID = "00000000-0000-4000-8000-000000000404" +BAD_GATEWAY_MESSAGE_ID = "00000000-0000-4000-8000-000000000502" +TOO_MANY_RESPONSES_MESSAGE_ID = "00000000-0000-4000-8000-000000000500" + +INVALID_MESSAGE_IDS = [ + "not-a-uuid", + "12345", + "invalid_id" +] + +CORRELATION_IDS = [None, "228aac39-542d-4803-b28e-5de9e100b9f8"] diff --git a/tests/sandbox/message_responses/__init__.py b/tests/sandbox/message_responses/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sandbox/message_responses/test_400.py b/tests/sandbox/message_responses/test_400.py new file mode 100644 index 000000000..422947359 --- /dev/null +++ b/tests/sandbox/message_responses/test_400.py @@ -0,0 +1,18 @@ +import requests +import pytest +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, INVALID_MESSAGE_IDS + + +@pytest.mark.sandboxtest +@pytest.mark.parametrize("correlation_id", CORRELATION_IDS) +@pytest.mark.parametrize("message_id", INVALID_MESSAGE_IDS) +def test_400_invalid_message_id(nhsd_apim_proxy_url, correlation_id, message_id): + resp = requests.get( + f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{message_id}", + headers={ + "X-Correlation-Id": correlation_id, + "Accept": "application/json" + } + ) + + assert resp.status_code == 400, f"Response: {resp.status_code}: {resp.text}" diff --git a/tests/sandbox/message_responses/test_404.py b/tests/sandbox/message_responses/test_404.py new file mode 100644 index 000000000..740ca68f6 --- /dev/null +++ b/tests/sandbox/message_responses/test_404.py @@ -0,0 +1,23 @@ +import requests +import pytest +from lib import Assertions, Generators +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, NOT_FOUND_MESSAGE_ID + + +@pytest.mark.sandboxtest +@pytest.mark.parametrize("correlation_id", CORRELATION_IDS) +def test_404_message_not_found(nhsd_apim_proxy_url, correlation_id): + resp = requests.get( + f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{NOT_FOUND_MESSAGE_ID}", + headers={ + "X-Correlation-Id": correlation_id, + "Accept": "application/json" + } + ) + + Assertions.assert_error_with_optional_correlation_id( + resp, + 404, + Generators.generate_not_found_error(), + correlation_id + ) diff --git a/tests/sandbox/message_responses/test_500.py b/tests/sandbox/message_responses/test_500.py new file mode 100644 index 000000000..336de83b3 --- /dev/null +++ b/tests/sandbox/message_responses/test_500.py @@ -0,0 +1,23 @@ +import requests +import pytest +from lib import Assertions, Generators +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, TOO_MANY_RESPONSES_MESSAGE_ID + + +@pytest.mark.sandboxtest +@pytest.mark.parametrize("correlation_id", CORRELATION_IDS) +def test_500_too_many_responses(nhsd_apim_proxy_url, correlation_id): + resp = requests.get( + f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{TOO_MANY_RESPONSES_MESSAGE_ID}", + headers={ + "X-Correlation-Id": correlation_id, + "Accept": "application/json" + } + ) + + Assertions.assert_error_with_optional_correlation_id( + resp, + 500, + Generators.generate_internal_server_error(), + correlation_id + ) diff --git a/tests/sandbox/message_responses/test_502.py b/tests/sandbox/message_responses/test_502.py new file mode 100644 index 000000000..d32a9e801 --- /dev/null +++ b/tests/sandbox/message_responses/test_502.py @@ -0,0 +1,23 @@ +import requests +import pytest +from lib import Assertions, Generators +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, BAD_GATEWAY_MESSAGE_ID + + +@pytest.mark.sandboxtest +@pytest.mark.parametrize("correlation_id", CORRELATION_IDS) +def test_502_bad_gateway(nhsd_apim_proxy_url, correlation_id): + resp = requests.get( + f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{BAD_GATEWAY_MESSAGE_ID}", + headers={ + "X-Correlation-Id": correlation_id, + "Accept": "application/json" + } + ) + + Assertions.assert_error_with_optional_correlation_id( + resp, + 502, + Generators.generate_bad_gateway_error(), + correlation_id + ) diff --git a/tests/sandbox/message_responses/test_success.py b/tests/sandbox/message_responses/test_success.py new file mode 100644 index 000000000..669a6ccb0 --- /dev/null +++ b/tests/sandbox/message_responses/test_success.py @@ -0,0 +1,32 @@ +import requests +import pytest +from lib import Assertions +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, VALID_MESSAGE_ID + + +@pytest.mark.sandboxtest +@pytest.mark.parametrize("correlation_id", CORRELATION_IDS) +def test_200_success(nhsd_apim_proxy_url, correlation_id): + resp = requests.get( + f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{VALID_MESSAGE_ID}", + headers={ + "X-Correlation-Id": correlation_id, + "Accept": "application/json" + } + ) + + assert resp.status_code == 200, f"Response: {resp.status_code}: {resp.text}" + body = resp.json() + assert body.get("messageId") == VALID_MESSAGE_ID + assert isinstance(body.get("responses"), list) + assert len(body["responses"]) > 0 + + first = body["responses"][0] + assert "responseId" in first + assert "messageReference" in first + assert "code" in first + assert "channel" in first + assert "channelStatus" in first + assert "authoredAt" in first + + Assertions.assert_correlation_id(resp.headers.get("X-Correlation-Id"), correlation_id) From 02e52a3f373660dc663e450a81c34af8214b4393 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Tue, 4 Aug 2026 10:50:57 +0100 Subject: [PATCH 02/16] CCM-22090: Fixed lint --- tests/sandbox/message_responses/test_500.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/sandbox/message_responses/test_500.py b/tests/sandbox/message_responses/test_500.py index 336de83b3..b8fbffda3 100644 --- a/tests/sandbox/message_responses/test_500.py +++ b/tests/sandbox/message_responses/test_500.py @@ -1,7 +1,9 @@ import requests import pytest from lib import Assertions, Generators -from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, TOO_MANY_RESPONSES_MESSAGE_ID +from lib.constants.message_responses_paths import ( + MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, TOO_MANY_RESPONSES_MESSAGE_ID +) @pytest.mark.sandboxtest From 01f518da02803a01ca716cf0d5537e1f83defa19 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 5 Aug 2026 10:23:00 +0100 Subject: [PATCH 03/16] CCM-22090: Added documentation --- specification/communications-manager.yaml | 10 + specification/documentation/APIDescription.md | 4 + specification/documentation/GetResponses.md | 239 ++++++++++++++++++ specification/endpoints/get_responses.yaml | 40 +++ .../responses/2xx/200_Responses.yaml | 10 + .../400_UnableToGetResponses.yaml | 30 +++ .../schemas/components/ResponseItem.yaml | 48 ++++ .../responses/GetResponsesSuccess.yaml | 42 +++ .../UnableToGetResponses.yaml | 43 ++++ 9 files changed, 466 insertions(+) create mode 100644 specification/documentation/GetResponses.md create mode 100644 specification/endpoints/get_responses.yaml create mode 100644 specification/responses/2xx/200_Responses.yaml create mode 100644 specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml create mode 100644 specification/schemas/components/ResponseItem.yaml create mode 100644 specification/schemas/responses/GetResponsesSuccess.yaml create mode 100644 specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml diff --git a/specification/communications-manager.yaml b/specification/communications-manager.yaml index 4ab713263..5606d62cf 100644 --- a/specification/communications-manager.yaml +++ b/specification/communications-manager.yaml @@ -32,6 +32,16 @@ paths: description: The unique identifier for the message. get: $ref: endpoints/get_message.yaml + /v1/messages/{messageId}/responses: + parameters: + - schema: + $ref: schemas/types/KSUID.yaml + name: messageId + in: path + required: true + description: The unique identifier for the message. + get: + $ref: endpoints/get_responses.yaml /channels/nhsapp/accounts: get: $ref: endpoints/get_nhsapp_account_details.yaml diff --git a/specification/documentation/APIDescription.md b/specification/documentation/APIDescription.md index c4a24db72..58f36f0bc 100644 --- a/specification/documentation/APIDescription.md +++ b/specification/documentation/APIDescription.md @@ -255,6 +255,10 @@ In order to present the recipient with answers, include the `answerOptions` fiel If you subscribe to recipient response callbacks, NHS Notify will send you a callback when a recipient responds to a message (currently only NHS App supports this). See [the recipient response callback](#post-/-client-provided-recipient-response-URI-) for more details. +### Retrieving responses via the API + +Use the [get message responses](#get-/v1/messages/-messageId-/responses) endpoint to query historical responses for a given message. Responses are available for up to 9 months after the message was sent. + ## Message character limits Different character limits apply to each of the communication channels as listed below. NHS Notify will validate that any personalisation fields submitted in the send message request do not exceed these limits but it is the client's responsibility to ensure that when personalisation is combined with any templated text, the channel character limit is not exceeded. diff --git a/specification/documentation/GetResponses.md b/specification/documentation/GetResponses.md new file mode 100644 index 000000000..12b71d592 --- /dev/null +++ b/specification/documentation/GetResponses.md @@ -0,0 +1,239 @@ +## Overview + +Use this endpoint to query historical NHS App keyword responses associated with a specific message. + +Responses are available for retrieval for up to 9 months after the message was sent. + +### Authentication + +This endpoint requires APIM application identity credentials. You must include a valid `Authorization` header on every request. See the [security and authorisation](#overview--security-and-authorisation) section for details on obtaining credentials. + +### Preconditions + +* Your application has valid APIM credentials with the appropriate scope. +* The message used the NHS App channel with keyword response options configured. + +If no responses exist yet, the `data` array in the response will be empty. + +### Request + +``` +GET /v1/messages/{messageId}/responses +``` + +#### Path parameters + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | KSUID | Yes | The unique identifier of the message for which you are retrieving responses. | + +#### Query parameters + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cursor` | string | No | An opaque cursor value from `links.next` or `links.prev` in a previous response. Omit to retrieve the first page. | + +#### Headers + +| Header | Required | Description | +| ------ | -------- | ----------- | +| `Authorization` | Yes (except sandbox) | Bearer token from APIM signed JWT authentication. | +| `X-Correlation-ID` | No | An optional ID to track transactions across systems. Returned in the response header. | +| `Accept` | No | `application/vnd.api+json` (default) or `application/json`. | + +#### Example request + +``` +curl -X GET \ + --header "Authorization: Bearer " \ + --header "Accept: application/vnd.api+json" \ + --header "X-Correlation-ID: 11C46F5F-CDEF-4865-94B2-0EE0EDCC26DA" \ + https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses +``` + +### Response + +A successful `200` response returns a JSON:API collection containing all responses for the given message. + +#### Response body + +```json +{ + "data": [ + { + "type": "Response", + "id": "33333333-3333-4333-8333-333333333333", + "attributes": { + "messageId": "2WL3qFTEFM0qMY8xjRbt1LIKCzM", + "code": "YES", + "authoredAt": "2026-01-15T10:30:00Z", + "timestamp": "2026-01-15T10:30:05Z", + "channel": "nhsapp" + } + } + ], + "links": { + "self": "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses", + "next": null, + "prev": null + }, + "meta": { + "totalCount": 1 + } +} +``` + +#### Response fields + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `data` | array | Array of response items. Empty if no responses exist. | +| `data[].type` | string | Always `"Response"`. | +| `data[].id` | string (UUID) | The unique identifier for this response. | +| `data[].attributes.messageId` | string (KSUID) | The identifier of the message this response belongs to. | +| `data[].attributes.code` | string | The keyword code selected by the recipient. | +| `data[].attributes.authoredAt` | string (date-time) | The date-time the recipient submitted their response. | +| `data[].attributes.timestamp` | string (date-time) | The date-time the response was processed by NHS Notify. | +| `data[].attributes.channel` | string | The channel through which the response was received. Currently always `nhsapp`. | +| `links.self` | string (URI) | The URL of the current page. | +| `links.next` | string (URI) or null | The URL of the next page. Absent or null if there are no further pages. | +| `links.prev` | string (URI) or null | The URL of the previous page. Absent or null if there is no previous page. | +| `meta.totalCount` | integer | The total number of responses available for this message. | + +### Pagination + +This endpoint uses cursor-based pagination. The response includes a `links` object containing `next` and `prev` URLs when additional pages are available. + +To retrieve all responses for a message: + +1. Send an initial request without a `cursor` parameter. +2. If `links.next` is present and non-null, send a subsequent request using the value of `links.next` as your URL (or extract the `cursor` query parameter from it). +3. Continue until `links.next` is null or absent. + +The cursor value is opaque — do not attempt to construct or parse it. Always use the full URL from `links.next`. + +#### Pagination example + +**First page:** + +``` +GET /v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses +``` + +Response: + +```json +{ + "data": [...], + "links": { + "self": "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses", + "next": "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses?cursor=eyJsYXN0S2V5IjoidGVzdCJ9", + "prev": null + } +} +``` + +**Second page:** + +``` +GET /v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses?cursor=eyJsYXN0S2V5IjoidGVzdCJ9 +``` + +### Error responses + +#### 400 — Invalid message ID + +Returned when the `messageId` path parameter is not a valid KSUID. + +```json +{ + "errors": [ + { + "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", + "code": "CM_INVALID_VALUE", + "status": "400", + "title": "Invalid value", + "detail": "The message ID provided is not a valid KSUID.", + "source": { + "parameter": "messageId" + } + } + ] +} +``` + +#### 403 — Forbidden + +Returned when the request is not authorised. + +```json +{ + "errors": [ + { + "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", + "code": "CM_FORBIDDEN", + "status": "403", + "title": "Forbidden", + "detail": "Client not recognised or not yet onboarded." + } + ] +} +``` + +#### 503 — Service unavailable + +Returned when the datastore is temporarily unavailable. Retry after the number of seconds indicated in the `Retry-After` response header. + +```json +{ + "errors": [ + { + "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", + "code": "CM_SERVICE_UNAVAILABLE", + "status": "503", + "title": "The service is currently unavailable", + "detail": "The service is currently not able to process this request, try again later." + } + ] +} +``` + +### Security + +* **Transport security**: All requests must be made over TLS 1.2 or higher. +* **Authentication**: Every request must include a valid `Authorization` header. +* **Data classification**: Response payloads may contain information about patient interactions. Handle and store this data in accordance with your data processing agreements and applicable data protection obligations. + +### Postconditions + +* Retrieval is read-only. Calling this endpoint does not modify any message or response state. +* The `X-Correlation-ID` header is returned in every response, for use in end-to-end audit tracing. + +### Non-functional requirements + +* **Availability**: This endpoint is subject to the same platform SLA as other NHS Notify endpoints. See the [service level](#overview--service-level) section. +* **Rate limiting**: APIM rate limits apply. See the [errors](#overview--errors) section and the `429` response definition. +* **Data retention**: Responses are available for retrieval for up to 9 months after the originating message was sent. + +### Sandbox + +The sandbox environment does not persist data, so responses will not be returned for messages created in a previous sandbox session. + +To test a successful retrieval in the sandbox, first create a message using the `POST /v1/messages` endpoint in the sandbox, then call this endpoint with the returned message ID. + +To simulate error responses in the sandbox, use the `Prefer` header: + +| `Prefer` value | Simulated response | +| -------------- | ------------------ | +| `code=400` | 400 Invalid message ID | +| `code=403` | 403 Forbidden | +| `code=503` | 503 Service unavailable | + +Example: + +``` +curl -X GET \ + --header "Accept: application/vnd.api+json" \ + --header "Prefer: code=403" \ + https://sandbox.api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses +``` diff --git a/specification/endpoints/get_responses.yaml b/specification/endpoints/get_responses.yaml new file mode 100644 index 000000000..537f8dadd --- /dev/null +++ b/specification/endpoints/get_responses.yaml @@ -0,0 +1,40 @@ +summary: Get responses for a message +description: + $ref: ../documentation/GetResponses.md +operationId: get-message-responses +parameters: + - $ref: ../snippets/AuthorizationParameter.yaml + - $ref: ../snippets/CorrelationParameter.yaml + - name: cursor + in: query + required: false + description: |- + An opaque cursor value returned in the `links.next` or `links.prev` field of a previous response. + Use this to retrieve the next or previous page of results. + If omitted, the first page of results is returned. + schema: + type: string + example: eyJsYXN0S2V5IjoidGVzdCJ9 +responses: + '200': + $ref: ../responses/2xx/200_Responses.yaml + '400': + $ref: ../responses/4xx/message_responses/400_UnableToGetResponses.yaml + '401': + $ref: ../responses/4xx/401_AccessDenied.yaml + '403': + $ref: ../responses/4xx/403_Forbidden.yaml + '405': + $ref: ../responses/4xx/405_NotAllowed.yaml + '406': + $ref: ../responses/4xx/406_NotAcceptable.yaml + '408': + $ref: ../responses/4xx/408_RequestTimeout.yaml + '429': + $ref: ../responses/4xx/429_TooManyRequests.yaml + '500': + $ref: ../responses/5xx/500_InternalServerError.yaml + '503': + $ref: ../responses/5xx/503_ServiceUnavailable.yaml + '504': + $ref: ../responses/5xx/504_ServiceTimeout.yaml diff --git a/specification/responses/2xx/200_Responses.yaml b/specification/responses/2xx/200_Responses.yaml new file mode 100644 index 000000000..7d88fad97 --- /dev/null +++ b/specification/responses/2xx/200_Responses.yaml @@ -0,0 +1,10 @@ +description: The responses for the given message have been retrieved successfully. +headers: + $ref: ../../snippets/StandardResponseHeaders.yaml +content: + application/vnd.api+json: + schema: + $ref: ../../schemas/responses/GetResponsesSuccess.yaml + application/json: + schema: + $ref: ../../schemas/responses/GetResponsesSuccess.yaml diff --git a/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml b/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml new file mode 100644 index 000000000..8036751e5 --- /dev/null +++ b/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml @@ -0,0 +1,30 @@ +description: |+ + The message ID in the request path is not a valid KSUID. + + The following validation error can occur: + + | Error code | Title | Description | + | ---------- | ----- | ----------- | + | `CM_INVALID_VALUE` | Invalid value | The `messageId` path parameter is not a valid KSUID. | + + ### Sandbox + + It is possible to trigger the `CM_INVALID_VALUE` error in the sandbox by sending the header `Prefer` with a value of `code=400`. + + Here is an example curl request: + + ``` + curl -X GET \ + --header "Accept: application/vnd.api+json" \ + --header "Prefer: code=400" \ + https://sandbox.api.service.nhs.uk/comms/v1/messages/not-a-valid-id/responses + ``` +content: + application/vnd.api+json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/UnableToGetResponses.yaml + application/json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/UnableToGetResponses.yaml +headers: + $ref: ../../../snippets/StandardResponseHeaders.yaml diff --git a/specification/schemas/components/ResponseItem.yaml b/specification/schemas/components/ResponseItem.yaml new file mode 100644 index 000000000..9c68f6beb --- /dev/null +++ b/specification/schemas/components/ResponseItem.yaml @@ -0,0 +1,48 @@ +type: object +title: ResponseItem +additionalProperties: false +required: + - type + - id + - attributes +properties: + type: + type: string + enum: + - Response + example: Response + id: + type: string + description: The unique identifier for the recipient response. + format: uuid + example: "33333333-3333-4333-8333-333333333333" + attributes: + type: object + additionalProperties: false + required: + - messageId + - code + - authoredAt + - channel + properties: + messageId: + $ref: ../types/KSUID.yaml + code: + type: string + description: The keyword code from the recipient's response. + example: "YES" + authoredAt: + type: string + description: The date-time the recipient authored the response. + format: date-time + example: '2026-01-15T10:30:00Z' + timestamp: + type: string + description: Date-time for when the response was processed. + format: date-time + example: '2026-01-15T10:30:05Z' + channel: + type: string + enum: + - nhsapp + example: nhsapp diff --git a/specification/schemas/responses/GetResponsesSuccess.yaml b/specification/schemas/responses/GetResponsesSuccess.yaml new file mode 100644 index 000000000..2e5ade80a --- /dev/null +++ b/specification/schemas/responses/GetResponsesSuccess.yaml @@ -0,0 +1,42 @@ +title: GetResponsesSuccess +type: object +additionalProperties: false +required: + - data + - links +properties: + data: + type: array + items: + $ref: ../components/ResponseItem.yaml + links: + type: object + additionalProperties: false + required: + - self + properties: + self: + type: string + format: uri + description: The URL of the current page of results. + example: "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses" + next: + type: string + format: uri + nullable: true + description: The URL of the next page of results. Absent if there are no further pages. + example: "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses?cursor=eyJsYXN0S2V5IjoidGVzdCJ9" + prev: + type: string + format: uri + nullable: true + description: The URL of the previous page of results. Absent if there is no previous page. + example: null + meta: + type: object + additionalProperties: false + properties: + totalCount: + type: integer + description: The total number of responses available for this message. + example: 3 diff --git a/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml b/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml new file mode 100644 index 000000000..b45186a9b --- /dev/null +++ b/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml @@ -0,0 +1,43 @@ +type: object +title: Unable to get responses +additionalProperties: false +properties: + errors: + type: array + minItems: 1 + maxItems: 1 + uniqueItems: true + items: + type: object + additionalProperties: false + properties: + id: + $ref: ../../../types/ErrorIdentifier.yaml + code: + $ref: ../../../enums/ErrorInvalidValue.yaml + links: + $ref: ../../../types/LinksError.yaml + status: + type: string + enum: + - '400' + example: '400' + title: + type: string + enum: + - Invalid value + example: Invalid value + detail: + type: string + enum: + - The message ID provided is not a valid KSUID. + example: The message ID provided is not a valid KSUID. + source: + type: object + additionalProperties: false + properties: + parameter: + type: string + enum: + - messageId + example: messageId From 985db0bb6a34d0582b2aba44b1b85b47efe7b790 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 10 Aug 2026 11:00:35 +0100 Subject: [PATCH 04/16] CCM-22090: Revised documentation --- specification/documentation/GetResponses.md | 130 ++++++++---------- specification/endpoints/get_responses.yaml | 14 +- .../422_TooManyResponses.yaml | 24 ++++ .../schemas/components/ResponseItem.yaml | 74 +++++----- .../schemas/enums/ErrorTooManyResponses.yaml | 5 + .../responses/GetResponsesSuccess.yaml | 39 +----- .../message_responses/TooManyResponses.yaml | 34 +++++ 7 files changed, 160 insertions(+), 160 deletions(-) create mode 100644 specification/responses/4xx/message_responses/422_TooManyResponses.yaml create mode 100644 specification/schemas/enums/ErrorTooManyResponses.yaml create mode 100644 specification/schemas/responses/errors/message_responses/TooManyResponses.yaml diff --git a/specification/documentation/GetResponses.md b/specification/documentation/GetResponses.md index 12b71d592..f3d8911d7 100644 --- a/specification/documentation/GetResponses.md +++ b/specification/documentation/GetResponses.md @@ -27,12 +27,6 @@ GET /v1/messages/{messageId}/responses | --------- | ---- | -------- | ----------- | | `messageId` | KSUID | Yes | The unique identifier of the message for which you are retrieving responses. | -#### Query parameters - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `cursor` | string | No | An opaque cursor value from `links.next` or `links.prev` in a previous response. Omit to retrieve the first page. | - #### Headers | Header | Required | Description | @@ -59,27 +53,17 @@ A successful `200` response returns a JSON:API collection containing all respons ```json { - "data": [ + "messageId": "2WL3qFTEFM0qMY8xjRbt1LIKCzM", + "responses": [ { - "type": "Response", - "id": "33333333-3333-4333-8333-333333333333", - "attributes": { - "messageId": "2WL3qFTEFM0qMY8xjRbt1LIKCzM", - "code": "YES", - "authoredAt": "2026-01-15T10:30:00Z", - "timestamp": "2026-01-15T10:30:05Z", - "channel": "nhsapp" - } + "responseId": "33333333-3333-4333-8333-333333333333", + "authoredAt": "2026-01-15T10:30:00Z", + "channel": "nhsapp", + "channelStatus": "delivered", + "code": "YES", + "messageReference": "da0b1495-c7cb-468c-9d81-07dee089d728" } - ], - "links": { - "self": "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses", - "next": null, - "prev": null - }, - "meta": { - "totalCount": 1 - } + ] } ``` @@ -87,57 +71,14 @@ A successful `200` response returns a JSON:API collection containing all respons | Field | Type | Description | | ----- | ---- | ----------- | -| `data` | array | Array of response items. Empty if no responses exist. | -| `data[].type` | string | Always `"Response"`. | -| `data[].id` | string (UUID) | The unique identifier for this response. | -| `data[].attributes.messageId` | string (KSUID) | The identifier of the message this response belongs to. | -| `data[].attributes.code` | string | The keyword code selected by the recipient. | -| `data[].attributes.authoredAt` | string (date-time) | The date-time the recipient submitted their response. | -| `data[].attributes.timestamp` | string (date-time) | The date-time the response was processed by NHS Notify. | -| `data[].attributes.channel` | string | The channel through which the response was received. Currently always `nhsapp`. | -| `links.self` | string (URI) | The URL of the current page. | -| `links.next` | string (URI) or null | The URL of the next page. Absent or null if there are no further pages. | -| `links.prev` | string (URI) or null | The URL of the previous page. Absent or null if there is no previous page. | -| `meta.totalCount` | integer | The total number of responses available for this message. | - -### Pagination - -This endpoint uses cursor-based pagination. The response includes a `links` object containing `next` and `prev` URLs when additional pages are available. - -To retrieve all responses for a message: - -1. Send an initial request without a `cursor` parameter. -2. If `links.next` is present and non-null, send a subsequent request using the value of `links.next` as your URL (or extract the `cursor` query parameter from it). -3. Continue until `links.next` is null or absent. - -The cursor value is opaque — do not attempt to construct or parse it. Always use the full URL from `links.next`. - -#### Pagination example - -**First page:** - -``` -GET /v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses -``` - -Response: - -```json -{ - "data": [...], - "links": { - "self": "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses", - "next": "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses?cursor=eyJsYXN0S2V5IjoidGVzdCJ9", - "prev": null - } -} -``` - -**Second page:** - -``` -GET /v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses?cursor=eyJsYXN0S2V5IjoidGVzdCJ9 -``` +| `messageId` | string (KSUID) | The identifier of the message. | +| `responses` | array | Array of response items. | +| `responses[].responseId` | string (UUID) | The unique identifier for this response. | +| `responses[].authoredAt` | string (date-time) | The date-time the recipient submitted their response. | +| `responses[].channel` | string | The channel through which the response was received. Currently always `nhsapp`. | +| `responses[].channelStatus` | string | The status of the channel at the time the response was received. One of `sending`, `delivered`, or `failed`. | +| `responses[].code` | string | The keyword code selected by the recipient. | +| `responses[].messageReference` | string | The reference for the message, as provided when the message was created. | ### Error responses @@ -180,6 +121,42 @@ Returned when the request is not authorised. } ``` +#### 404 — Not found + +Returned when no responses exist for the given message ID. + +```json +{ + "errors": [ + { + "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", + "code": "CM_NOT_FOUND", + "status": "404", + "title": "Not Found", + "detail": "No responses found for the specified message ID." + } + ] +} +``` + +#### 422 — Too many responses + +Returned when more than 1000 responses exist for the given message ID. + +```json +{ + "errors": [ + { + "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", + "code": "CM_TOO_MANY_RESPONSES", + "status": "422", + "title": "Too many responses", + "detail": "There are too many responses to return." + } + ] +} +``` + #### 503 — Service unavailable Returned when the datastore is temporarily unavailable. Retry after the number of seconds indicated in the `Retry-After` response header. @@ -227,6 +204,7 @@ To simulate error responses in the sandbox, use the `Prefer` header: | -------------- | ------------------ | | `code=400` | 400 Invalid message ID | | `code=403` | 403 Forbidden | +| `code=422` | 422 Too many responses | | `code=503` | 503 Service unavailable | Example: diff --git a/specification/endpoints/get_responses.yaml b/specification/endpoints/get_responses.yaml index 537f8dadd..7da8e9562 100644 --- a/specification/endpoints/get_responses.yaml +++ b/specification/endpoints/get_responses.yaml @@ -5,16 +5,6 @@ operationId: get-message-responses parameters: - $ref: ../snippets/AuthorizationParameter.yaml - $ref: ../snippets/CorrelationParameter.yaml - - name: cursor - in: query - required: false - description: |- - An opaque cursor value returned in the `links.next` or `links.prev` field of a previous response. - Use this to retrieve the next or previous page of results. - If omitted, the first page of results is returned. - schema: - type: string - example: eyJsYXN0S2V5IjoidGVzdCJ9 responses: '200': $ref: ../responses/2xx/200_Responses.yaml @@ -24,12 +14,16 @@ responses: $ref: ../responses/4xx/401_AccessDenied.yaml '403': $ref: ../responses/4xx/403_Forbidden.yaml + '404': + $ref: ../responses/4xx/404_NotFound.yaml '405': $ref: ../responses/4xx/405_NotAllowed.yaml '406': $ref: ../responses/4xx/406_NotAcceptable.yaml '408': $ref: ../responses/4xx/408_RequestTimeout.yaml + '422': + $ref: ../responses/4xx/message_responses/422_TooManyResponses.yaml '429': $ref: ../responses/4xx/429_TooManyRequests.yaml '500': diff --git a/specification/responses/4xx/message_responses/422_TooManyResponses.yaml b/specification/responses/4xx/message_responses/422_TooManyResponses.yaml new file mode 100644 index 000000000..03cf526f1 --- /dev/null +++ b/specification/responses/4xx/message_responses/422_TooManyResponses.yaml @@ -0,0 +1,24 @@ +description: |+ + There are too many responses associated with this message to return in a single response. This error occurs when more than 1000 responses exist for the given message ID. + + ### Sandbox + + It is possible to trigger this error in the sandbox by sending the header `Prefer` with a value of `code=422`. + + Here is an example curl request: + + ``` + curl -X GET \ + --header "Accept: application/vnd.api+json" \ + --header "Prefer: code=422" \ + https://sandbox.api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses + ``` +content: + application/vnd.api+json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/TooManyResponses.yaml + application/json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/TooManyResponses.yaml +headers: + $ref: ../../../snippets/StandardResponseHeaders.yaml diff --git a/specification/schemas/components/ResponseItem.yaml b/specification/schemas/components/ResponseItem.yaml index 9c68f6beb..2a37a46a7 100644 --- a/specification/schemas/components/ResponseItem.yaml +++ b/specification/schemas/components/ResponseItem.yaml @@ -2,47 +2,41 @@ type: object title: ResponseItem additionalProperties: false required: - - type - - id - - attributes + - responseId + - authoredAt + - channel + - channelStatus + - code + - messageReference properties: - type: + responseId: type: string - enum: - - Response - example: Response - id: - type: string - description: The unique identifier for the recipient response. format: uuid + description: The unique identifier for this response. example: "33333333-3333-4333-8333-333333333333" - attributes: - type: object - additionalProperties: false - required: - - messageId - - code - - authoredAt - - channel - properties: - messageId: - $ref: ../types/KSUID.yaml - code: - type: string - description: The keyword code from the recipient's response. - example: "YES" - authoredAt: - type: string - description: The date-time the recipient authored the response. - format: date-time - example: '2026-01-15T10:30:00Z' - timestamp: - type: string - description: Date-time for when the response was processed. - format: date-time - example: '2026-01-15T10:30:05Z' - channel: - type: string - enum: - - nhsapp - example: nhsapp + authoredAt: + type: string + description: The date-time the recipient submitted their response. + format: date-time + example: '2026-01-15T10:30:00Z' + channel: + type: string + enum: + - nhsapp + example: nhsapp + channelStatus: + type: string + description: The status of the channel at the time the response was received. + enum: + - sending + - delivered + - failed + example: delivered + code: + type: string + description: The keyword code from the recipient's response. + example: "YES" + messageReference: + type: string + description: The reference for the message, as provided when the message was created. + example: "da0b1495-c7cb-468c-9d81-07dee089d728" diff --git a/specification/schemas/enums/ErrorTooManyResponses.yaml b/specification/schemas/enums/ErrorTooManyResponses.yaml new file mode 100644 index 000000000..bc2afc083 --- /dev/null +++ b/specification/schemas/enums/ErrorTooManyResponses.yaml @@ -0,0 +1,5 @@ +title: Enum_Error_TooManyResponses +type: string +enum: + - CM_TOO_MANY_RESPONSES +example: CM_TOO_MANY_RESPONSES diff --git a/specification/schemas/responses/GetResponsesSuccess.yaml b/specification/schemas/responses/GetResponsesSuccess.yaml index 2e5ade80a..10540f5c4 100644 --- a/specification/schemas/responses/GetResponsesSuccess.yaml +++ b/specification/schemas/responses/GetResponsesSuccess.yaml @@ -2,41 +2,12 @@ title: GetResponsesSuccess type: object additionalProperties: false required: - - data - - links + - messageId + - responses properties: - data: + messageId: + $ref: ../types/KSUID.yaml + responses: type: array items: $ref: ../components/ResponseItem.yaml - links: - type: object - additionalProperties: false - required: - - self - properties: - self: - type: string - format: uri - description: The URL of the current page of results. - example: "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses" - next: - type: string - format: uri - nullable: true - description: The URL of the next page of results. Absent if there are no further pages. - example: "https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses?cursor=eyJsYXN0S2V5IjoidGVzdCJ9" - prev: - type: string - format: uri - nullable: true - description: The URL of the previous page of results. Absent if there is no previous page. - example: null - meta: - type: object - additionalProperties: false - properties: - totalCount: - type: integer - description: The total number of responses available for this message. - example: 3 diff --git a/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml b/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml new file mode 100644 index 000000000..7a3786118 --- /dev/null +++ b/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml @@ -0,0 +1,34 @@ +type: object +title: Too many responses +additionalProperties: false +properties: + errors: + type: array + minItems: 1 + maxItems: 1 + uniqueItems: true + items: + type: object + additionalProperties: false + properties: + id: + $ref: ../../../types/ErrorIdentifier.yaml + code: + $ref: ../../../enums/ErrorTooManyResponses.yaml + links: + $ref: ../../../types/LinksError.yaml + status: + type: string + enum: + - '422' + example: '422' + title: + type: string + enum: + - Too many responses + example: Too many responses + detail: + type: string + enum: + - There are too many responses to return. + example: There are too many responses to return. From 3e55a1b99886a49ffcc76f10c5f8bba6c03096b0 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 12 Aug 2026 11:26:11 +0100 Subject: [PATCH 05/16] CCM-22090: PR feedback --- ...gnMessage.MessageResponses.Get.Request.xml | 11 +- ...Variables.MessageResponses.Get.Request.xml | 23 +- sandbox/handlers/message_responses.js | 49 ++-- specification/communications-manager.yaml | 2 +- specification/documentation/APIDescription.md | 2 +- .../documentation/GetMessageResponses.md | 41 ++++ specification/documentation/GetResponses.md | 217 ------------------ specification/endpoints/get_responses.yaml | 2 +- .../responses/2xx/200_Responses.yaml | 4 +- .../schemas/components/ResponseItem.yaml | 37 ++- .../responses/GetResponsesSuccess.yaml | 13 -- .../schemas/responses/MessageResponses.yaml | 4 + 12 files changed, 108 insertions(+), 297 deletions(-) create mode 100644 specification/documentation/GetMessageResponses.md delete mode 100644 specification/documentation/GetResponses.md delete mode 100644 specification/schemas/responses/GetResponsesSuccess.yaml create mode 100644 specification/schemas/responses/MessageResponses.yaml diff --git a/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml index 6c3d8e8c9..3fbc72790 100644 --- a/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml +++ b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml @@ -1,6 +1,6 @@ AssignMessage.MessageResponses.Get.Request @@ -20,14 +20,5 @@
{backendCorrelationId}
GET - - {data.responseId} - {data.clientId} - {data.correlationId} - {data.cursor} - {data.pageSize} - {data.authoredAtFrom} - {data.authoredAtTo} -
diff --git a/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml b/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml index c0633ea3a..787aceead 100644 --- a/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml +++ b/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml @@ -1,6 +1,6 @@ data @@ -8,26 +8,5 @@ /v1/message-responses/{messageId} - - {responseId} - - - {clientId} - - - {correlationId} - - - {cursor} - - - {pageSize} - - - {authoredAtFrom} - - - {authoredAtTo} - true diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js index c3e0d740f..f6e4d468c 100644 --- a/sandbox/handlers/message_responses.js +++ b/sandbox/handlers/message_responses.js @@ -39,29 +39,32 @@ export async function messageResponses(req, res, next) { return; } - res.type('json').status(200).json(getDefaultResponse(messageId)); + res.type('json').status(200).json(getDefaultResponse()); } -function getDefaultResponse(messageId) { - return { - messageId, - responses: [ - { - responseId: '22222222-2222-4222-8222-222222222222', - messageReference: 'msg-ref-1', - code: 'YES', - channel: 'nhsapp', - channelStatus: 'delivered', - authoredAt: '2026-01-02T09:00:00.000Z' - }, - { - responseId: '33333333-3333-4333-8333-333333333333', - messageReference: 'msg-ref-1', - code: 'NO', - channel: 'nhsapp', - channelStatus: 'delivered', - authoredAt: '2026-01-02T09:05:00.000Z' - } - ] - }; +function getDefaultResponse() { + return [ + { + responseId: '22222222-2222-4222-8222-222222222222', + messageId: '2WL3qFTEFM0qMY8xjRbt1LIKCzM', + messageReference: 'msg-ref-1', + channel: 'nhsapp', + channelStatus: 'delivered', + cascadeType: 'primary', + code: 'YES', + authoredAt: '2026-01-02T09:00:00.000Z', + timestamp: '2026-01-02T09:00:02.345Z' + }, + { + responseId: '33333333-3333-4333-8333-333333333333', + messageId: '2WL3qFTEFM0qMY8xjRbt1LIKCzM', + messageReference: 'msg-ref-1', + channel: 'nhsapp', + channelStatus: 'delivered', + cascadeType: 'secondary', + code: 'NO', + authoredAt: '2026-01-02T09:05:00.000Z', + timestamp: '2026-01-02T09:05:01.678Z' + } + ]; } diff --git a/specification/communications-manager.yaml b/specification/communications-manager.yaml index 5606d62cf..b31a4415c 100644 --- a/specification/communications-manager.yaml +++ b/specification/communications-manager.yaml @@ -32,7 +32,7 @@ paths: description: The unique identifier for the message. get: $ref: endpoints/get_message.yaml - /v1/messages/{messageId}/responses: + /v1/message-responses/{messageId}: parameters: - schema: $ref: schemas/types/KSUID.yaml diff --git a/specification/documentation/APIDescription.md b/specification/documentation/APIDescription.md index 58f36f0bc..26e1aea0e 100644 --- a/specification/documentation/APIDescription.md +++ b/specification/documentation/APIDescription.md @@ -257,7 +257,7 @@ If you subscribe to recipient response callbacks, NHS Notify will send you a cal ### Retrieving responses via the API -Use the [get message responses](#get-/v1/messages/-messageId-/responses) endpoint to query historical responses for a given message. Responses are available for up to 9 months after the message was sent. +Use the [get message responses](#get-/v1/message-responses/-messageId-) endpoint to query historical responses for a given message. Responses are available for up to 9 months after the message was sent. ## Message character limits Different character limits apply to each of the communication channels as listed below. NHS Notify will validate that any personalisation fields submitted in the send message request do not exceed these limits but it is the client's responsibility to ensure that when personalisation is combined with any templated text, the channel character limit is not exceeded. diff --git a/specification/documentation/GetMessageResponses.md b/specification/documentation/GetMessageResponses.md new file mode 100644 index 000000000..f8dd0a17d --- /dev/null +++ b/specification/documentation/GetMessageResponses.md @@ -0,0 +1,41 @@ +## Overview + +Use this endpoint to retrieve recipient responses associated with a specific message. + +Responses are available for retrieval for up to 9 months after the message was sent. + +### Response structure + +A successful response returns a flat JSON array of response items. + +Each item includes: + +* `responseId` - the unique identifier for this response +* `messageId` - the identifier of the message this response relates to +* `messageReference` - the reference you provided when the message was created +* `channel` - the channel through which the response was received +* `channelStatus` - the status of the channel at the time the response was received +* `channelFailureReasonCode` - the reason code for the channel failure (only present when `channelStatus` is `failed`) +* `cascadeType` - whether this is a `primary` or `secondary` cascade response +* `code` - the keyword code selected by the recipient +* `authoredAt` - the date-time the recipient submitted their response +* `timestamp` - the date-time the response was recorded by NHS Notify + +If no responses exist for the given message, a `404` response is returned. + +### Sandbox + +When sending this request on sandbox you can use any valid message ID format. + +To simulate error responses in the sandbox, use the following message IDs: + +* not found - `00000000-0000-4000-8000-000000000404` +* bad gateway - `00000000-0000-4000-8000-000000000502` +* too many responses - `00000000-0000-4000-8000-000000000500` + +Here's an example curl command: + +``` +curl -X GET 'https://sandbox.api.service.nhs.uk/comms/v1/message-responses/2WL3qFTEFM0qMY8xjRbt1LIKCzM' \ + --header 'Accept: application/vnd.api+json' +``` diff --git a/specification/documentation/GetResponses.md b/specification/documentation/GetResponses.md deleted file mode 100644 index f3d8911d7..000000000 --- a/specification/documentation/GetResponses.md +++ /dev/null @@ -1,217 +0,0 @@ -## Overview - -Use this endpoint to query historical NHS App keyword responses associated with a specific message. - -Responses are available for retrieval for up to 9 months after the message was sent. - -### Authentication - -This endpoint requires APIM application identity credentials. You must include a valid `Authorization` header on every request. See the [security and authorisation](#overview--security-and-authorisation) section for details on obtaining credentials. - -### Preconditions - -* Your application has valid APIM credentials with the appropriate scope. -* The message used the NHS App channel with keyword response options configured. - -If no responses exist yet, the `data` array in the response will be empty. - -### Request - -``` -GET /v1/messages/{messageId}/responses -``` - -#### Path parameters - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `messageId` | KSUID | Yes | The unique identifier of the message for which you are retrieving responses. | - -#### Headers - -| Header | Required | Description | -| ------ | -------- | ----------- | -| `Authorization` | Yes (except sandbox) | Bearer token from APIM signed JWT authentication. | -| `X-Correlation-ID` | No | An optional ID to track transactions across systems. Returned in the response header. | -| `Accept` | No | `application/vnd.api+json` (default) or `application/json`. | - -#### Example request - -``` -curl -X GET \ - --header "Authorization: Bearer " \ - --header "Accept: application/vnd.api+json" \ - --header "X-Correlation-ID: 11C46F5F-CDEF-4865-94B2-0EE0EDCC26DA" \ - https://api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses -``` - -### Response - -A successful `200` response returns a JSON:API collection containing all responses for the given message. - -#### Response body - -```json -{ - "messageId": "2WL3qFTEFM0qMY8xjRbt1LIKCzM", - "responses": [ - { - "responseId": "33333333-3333-4333-8333-333333333333", - "authoredAt": "2026-01-15T10:30:00Z", - "channel": "nhsapp", - "channelStatus": "delivered", - "code": "YES", - "messageReference": "da0b1495-c7cb-468c-9d81-07dee089d728" - } - ] -} -``` - -#### Response fields - -| Field | Type | Description | -| ----- | ---- | ----------- | -| `messageId` | string (KSUID) | The identifier of the message. | -| `responses` | array | Array of response items. | -| `responses[].responseId` | string (UUID) | The unique identifier for this response. | -| `responses[].authoredAt` | string (date-time) | The date-time the recipient submitted their response. | -| `responses[].channel` | string | The channel through which the response was received. Currently always `nhsapp`. | -| `responses[].channelStatus` | string | The status of the channel at the time the response was received. One of `sending`, `delivered`, or `failed`. | -| `responses[].code` | string | The keyword code selected by the recipient. | -| `responses[].messageReference` | string | The reference for the message, as provided when the message was created. | - -### Error responses - -#### 400 — Invalid message ID - -Returned when the `messageId` path parameter is not a valid KSUID. - -```json -{ - "errors": [ - { - "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", - "code": "CM_INVALID_VALUE", - "status": "400", - "title": "Invalid value", - "detail": "The message ID provided is not a valid KSUID.", - "source": { - "parameter": "messageId" - } - } - ] -} -``` - -#### 403 — Forbidden - -Returned when the request is not authorised. - -```json -{ - "errors": [ - { - "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", - "code": "CM_FORBIDDEN", - "status": "403", - "title": "Forbidden", - "detail": "Client not recognised or not yet onboarded." - } - ] -} -``` - -#### 404 — Not found - -Returned when no responses exist for the given message ID. - -```json -{ - "errors": [ - { - "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", - "code": "CM_NOT_FOUND", - "status": "404", - "title": "Not Found", - "detail": "No responses found for the specified message ID." - } - ] -} -``` - -#### 422 — Too many responses - -Returned when more than 1000 responses exist for the given message ID. - -```json -{ - "errors": [ - { - "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", - "code": "CM_TOO_MANY_RESPONSES", - "status": "422", - "title": "Too many responses", - "detail": "There are too many responses to return." - } - ] -} -``` - -#### 503 — Service unavailable - -Returned when the datastore is temporarily unavailable. Retry after the number of seconds indicated in the `Retry-After` response header. - -```json -{ - "errors": [ - { - "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", - "code": "CM_SERVICE_UNAVAILABLE", - "status": "503", - "title": "The service is currently unavailable", - "detail": "The service is currently not able to process this request, try again later." - } - ] -} -``` - -### Security - -* **Transport security**: All requests must be made over TLS 1.2 or higher. -* **Authentication**: Every request must include a valid `Authorization` header. -* **Data classification**: Response payloads may contain information about patient interactions. Handle and store this data in accordance with your data processing agreements and applicable data protection obligations. - -### Postconditions - -* Retrieval is read-only. Calling this endpoint does not modify any message or response state. -* The `X-Correlation-ID` header is returned in every response, for use in end-to-end audit tracing. - -### Non-functional requirements - -* **Availability**: This endpoint is subject to the same platform SLA as other NHS Notify endpoints. See the [service level](#overview--service-level) section. -* **Rate limiting**: APIM rate limits apply. See the [errors](#overview--errors) section and the `429` response definition. -* **Data retention**: Responses are available for retrieval for up to 9 months after the originating message was sent. - -### Sandbox - -The sandbox environment does not persist data, so responses will not be returned for messages created in a previous sandbox session. - -To test a successful retrieval in the sandbox, first create a message using the `POST /v1/messages` endpoint in the sandbox, then call this endpoint with the returned message ID. - -To simulate error responses in the sandbox, use the `Prefer` header: - -| `Prefer` value | Simulated response | -| -------------- | ------------------ | -| `code=400` | 400 Invalid message ID | -| `code=403` | 403 Forbidden | -| `code=422` | 422 Too many responses | -| `code=503` | 503 Service unavailable | - -Example: - -``` -curl -X GET \ - --header "Accept: application/vnd.api+json" \ - --header "Prefer: code=403" \ - https://sandbox.api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses -``` diff --git a/specification/endpoints/get_responses.yaml b/specification/endpoints/get_responses.yaml index 7da8e9562..3167326d1 100644 --- a/specification/endpoints/get_responses.yaml +++ b/specification/endpoints/get_responses.yaml @@ -1,6 +1,6 @@ summary: Get responses for a message description: - $ref: ../documentation/GetResponses.md + $ref: ../documentation/GetMessageResponses.md operationId: get-message-responses parameters: - $ref: ../snippets/AuthorizationParameter.yaml diff --git a/specification/responses/2xx/200_Responses.yaml b/specification/responses/2xx/200_Responses.yaml index 7d88fad97..18c82b100 100644 --- a/specification/responses/2xx/200_Responses.yaml +++ b/specification/responses/2xx/200_Responses.yaml @@ -4,7 +4,7 @@ headers: content: application/vnd.api+json: schema: - $ref: ../../schemas/responses/GetResponsesSuccess.yaml + $ref: ../../schemas/responses/MessageResponses.yaml application/json: schema: - $ref: ../../schemas/responses/GetResponsesSuccess.yaml + $ref: ../../schemas/responses/MessageResponses.yaml diff --git a/specification/schemas/components/ResponseItem.yaml b/specification/schemas/components/ResponseItem.yaml index 2a37a46a7..3c3fbf462 100644 --- a/specification/schemas/components/ResponseItem.yaml +++ b/specification/schemas/components/ResponseItem.yaml @@ -4,21 +4,27 @@ additionalProperties: false required: - responseId - authoredAt + - cascadeType - channel - channelStatus - code + - messageId - messageReference + - timestamp properties: responseId: type: string format: uuid description: The unique identifier for this response. example: "33333333-3333-4333-8333-333333333333" - authoredAt: + messageId: type: string - description: The date-time the recipient submitted their response. - format: date-time - example: '2026-01-15T10:30:00Z' + description: The unique identifier of the message this response relates to. + example: "2WL3qFTEFM0qMY8xjRbt1LIKCzM" + messageReference: + type: string + description: The reference for the message, as provided when the message was created. + example: "da0b1495-c7cb-468c-9d81-07dee089d728" channel: type: string enum: @@ -32,11 +38,28 @@ properties: - delivered - failed example: delivered + channelFailureReasonCode: + type: string + description: The reason code for the channel failure. Only present when channelStatus is failed. + example: "CFR_SUPE_0001" + cascadeType: + type: string + description: Whether this is a primary or secondary cascade response. + enum: + - primary + - secondary + example: primary code: type: string description: The keyword code from the recipient's response. example: "YES" - messageReference: + authoredAt: type: string - description: The reference for the message, as provided when the message was created. - example: "da0b1495-c7cb-468c-9d81-07dee089d728" + description: The date-time the recipient submitted their response. + format: date-time + example: "2026-06-15T14:30:00.000Z" + timestamp: + type: string + description: The date-time the response was recorded by NHS Notify. + format: date-time + example: "2026-06-15T14:30:05.123Z" diff --git a/specification/schemas/responses/GetResponsesSuccess.yaml b/specification/schemas/responses/GetResponsesSuccess.yaml deleted file mode 100644 index 10540f5c4..000000000 --- a/specification/schemas/responses/GetResponsesSuccess.yaml +++ /dev/null @@ -1,13 +0,0 @@ -title: GetResponsesSuccess -type: object -additionalProperties: false -required: - - messageId - - responses -properties: - messageId: - $ref: ../types/KSUID.yaml - responses: - type: array - items: - $ref: ../components/ResponseItem.yaml diff --git a/specification/schemas/responses/MessageResponses.yaml b/specification/schemas/responses/MessageResponses.yaml new file mode 100644 index 000000000..a2756f09b --- /dev/null +++ b/specification/schemas/responses/MessageResponses.yaml @@ -0,0 +1,4 @@ +title: MessageResponses +type: array +items: + $ref: ../components/ResponseItem.yaml From a68c89246678eb58e5aba8bf2f175e46a4c554fe Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Fri, 14 Aug 2026 14:20:16 +0100 Subject: [PATCH 06/16] CCM-22090: PR feedback --- sandbox/__test__/message_responses.spec.js | 43 +++++++++++------ sandbox/handlers/message_responses.js | 38 +++++++++------ specification/communications-manager.yaml | 3 +- specification/documentation/APIDescription.md | 2 +- .../documentation/GetMessageResponses.md | 13 +++--- specification/endpoints/get_responses.yaml | 4 +- .../responses/2xx/200_Responses.yaml | 3 -- .../responses/4xx/406_NotAcceptable.yaml | 2 +- .../responses/4xx/415_UnsupportedMedia.yaml | 12 +---- .../400_UnableToGetResponses.yaml | 25 +--------- .../422_TooManyResponses.yaml | 19 +------- .../schemas/components/ResponseItem.yaml | 3 +- .../schemas/enums/ErrorTooManyResponses.yaml | 5 -- .../message_responses/TooManyResponses.yaml | 43 ++++++----------- .../UnableToGetResponses.yaml | 46 +++---------------- .../sandbox/message_responses/test_success.py | 10 ++-- 16 files changed, 96 insertions(+), 175 deletions(-) delete mode 100644 specification/schemas/enums/ErrorTooManyResponses.yaml diff --git a/sandbox/__test__/message_responses.spec.js b/sandbox/__test__/message_responses.spec.js index 1dbd2b703..718ed8d4f 100644 --- a/sandbox/__test__/message_responses.spec.js +++ b/sandbox/__test__/message_responses.spec.js @@ -4,8 +4,7 @@ import { setup } from './helpers.js' const VALID_MESSAGE_ID = '11111111-1111-4111-8111-111111111111'; const NOT_FOUND_MESSAGE_ID = '00000000-0000-4000-8000-000000000404'; -const BAD_GATEWAY_MESSAGE_ID = '00000000-0000-4000-8000-000000000502'; -const TOO_MANY_RESPONSES_MESSAGE_ID = '00000000-0000-4000-8000-000000000500'; +const TOO_MANY_RESPONSES_MESSAGE_ID = '00000000-0000-4000-8000-000000000422'; describe('/api/v1/message-responses/:messageId', () => { let env; @@ -35,7 +34,7 @@ describe('/api/v1/message-responses/:messageId', () => { .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) .set({ Authorization: 'banned' }) .expect(403, { - message: 'Request rejected because client service ban is in effect.' + error: 'Forbidden' }) .expect('Content-Type', /json/, done); }); @@ -44,7 +43,7 @@ describe('/api/v1/message-responses/:messageId', () => { request(server) .get('/api/v1/message-responses/not-a-valid-uuid') .expect(400, { - message: 'Invalid message ID format. messageId must be a UUID.' + error: 'Invalid messageId format' }) .expect('Content-Type', /json/, done); }); @@ -53,7 +52,7 @@ describe('/api/v1/message-responses/:messageId', () => { request(server) .get(`/api/v1/message-responses/${NOT_FOUND_MESSAGE_ID}`) .expect(404, { - message: 'No responses found for the given messageId.' + error: 'No responses found for the specified messageId' }) .expect('Content-Type', /json/, done); }); @@ -61,17 +60,29 @@ describe('/api/v1/message-responses/:messageId', () => { it('returns a 500 when too many responses are returned', (done) => { request(server) .get(`/api/v1/message-responses/${TOO_MANY_RESPONSES_MESSAGE_ID}`) - .expect(500, { - message: 'Too many responses returned for this messageId.' + .expect(422, { + error: 'response_too_large', + message: 'There are too many responses to return.' }) .expect('Content-Type', /json/, done); }); - it('returns a 502 when a bad gateway error occurs', (done) => { + it('returns a 415 when the content type is not supported', (done) => { request(server) - .get(`/api/v1/message-responses/${BAD_GATEWAY_MESSAGE_ID}`) - .expect(502, { - message: 'Bad Gateway' + .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) + .set('Content-Type', 'text/plain') + .expect(415, { + message: 'Unsupported media type.' + }) + .expect('Content-Type', /json/, done); + }); + + it('returns a 429 when the request is rate limited', (done) => { + request(server) + .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) + .set('Prefer', 'code=429') + .expect(429, { + message: 'Too many requests.' }) .expect('Content-Type', /json/, done); }); @@ -83,15 +94,17 @@ describe('/api/v1/message-responses/:messageId', () => { .expect('Content-Type', /json/) .expect((res) => { const { body } = res; - if (!body.messageId) throw new Error('missing messageId'); - if (body.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId'); - if (!Array.isArray(body.responses)) throw new Error('responses must be an array'); - const first = body.responses[0]; + if (!Array.isArray(body)) throw new Error('response must be an array'); + const first = body[0]; if (!first.responseId) throw new Error('missing responseId'); + if (first.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId'); + if (!first.messageReference) throw new Error('missing messageReference'); if (!first.code) throw new Error('missing code'); if (!first.channel) throw new Error('missing channel'); if (!first.channelStatus) throw new Error('missing channelStatus'); + if (!first.cascadeType) throw new Error('missing cascadeType'); if (!first.authoredAt) throw new Error('missing authoredAt'); + if (!first.timestamp) throw new Error('missing timestamp'); }) .end(done); }); diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js index f6e4d468c..7872ca272 100644 --- a/sandbox/handlers/message_responses.js +++ b/sandbox/handlers/message_responses.js @@ -1,52 +1,60 @@ import { sendError } from './utils.js' -const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const notFoundMessageId = '00000000-0000-4000-8000-000000000404'; -const badGatewayMessageId = '00000000-0000-4000-8000-000000000502'; -const tooManyResponsesMessageId = '00000000-0000-4000-8000-000000000500'; +const tooManyResponsesMessageId = '00000000-0000-4000-8000-000000000422'; export async function messageResponses(req, res, next) { if (req.headers.authorization === 'banned') { - sendError(res, 403, 'Request rejected because client service ban is in effect.'); + res.status(403).json({ error: 'Forbidden' }); next(); return; } - const { messageId } = req.params; + if (req.headers['content-type'] && req.headers['content-type'] !== 'application/json') { + sendError(res, 415, 'Unsupported media type.'); + next(); + return; + } - if (!uuidRegex.test(messageId)) { - sendError(res, 400, 'Invalid message ID format. messageId must be a UUID.'); + if (req.headers.prefer === 'code=429') { + sendError(res, 429, 'Too many requests.'); next(); return; } - if (messageId === badGatewayMessageId) { - sendError(res, 502, 'Bad Gateway'); + const { messageId } = req.params; + + if (!uuidRegex.test(messageId)) { + res.status(400).json({ error: 'Invalid messageId format' }); next(); return; } if (messageId === notFoundMessageId) { - sendError(res, 404, 'No responses found for the given messageId.'); + res.status(404).json({ error: 'No responses found for the specified messageId' }); next(); return; } if (messageId === tooManyResponsesMessageId) { - sendError(res, 500, 'Too many responses returned for this messageId.'); + res.status(422).json({ + error: 'response_too_large', + message: 'There are too many responses to return.' + }); next(); return; } - res.type('json').status(200).json(getDefaultResponse()); + res.type('json').status(200).json(getDefaultResponse(messageId)); } -function getDefaultResponse() { +function getDefaultResponse(messageId) { return [ { responseId: '22222222-2222-4222-8222-222222222222', - messageId: '2WL3qFTEFM0qMY8xjRbt1LIKCzM', + messageId, messageReference: 'msg-ref-1', channel: 'nhsapp', channelStatus: 'delivered', @@ -57,7 +65,7 @@ function getDefaultResponse() { }, { responseId: '33333333-3333-4333-8333-333333333333', - messageId: '2WL3qFTEFM0qMY8xjRbt1LIKCzM', + messageId, messageReference: 'msg-ref-1', channel: 'nhsapp', channelStatus: 'delivered', diff --git a/specification/communications-manager.yaml b/specification/communications-manager.yaml index b31a4415c..aa492d0ad 100644 --- a/specification/communications-manager.yaml +++ b/specification/communications-manager.yaml @@ -35,7 +35,8 @@ paths: /v1/message-responses/{messageId}: parameters: - schema: - $ref: schemas/types/KSUID.yaml + type: string + format: uuid name: messageId in: path required: true diff --git a/specification/documentation/APIDescription.md b/specification/documentation/APIDescription.md index 26e1aea0e..d36639bf4 100644 --- a/specification/documentation/APIDescription.md +++ b/specification/documentation/APIDescription.md @@ -257,7 +257,7 @@ If you subscribe to recipient response callbacks, NHS Notify will send you a cal ### Retrieving responses via the API -Use the [get message responses](#get-/v1/message-responses/-messageId-) endpoint to query historical responses for a given message. Responses are available for up to 9 months after the message was sent. +Use the [get message responses](#get-/v1/message-responses/-messageId-) endpoint to query responses for a given message. Responses are available for up to 9 months after the message was sent. ## Message character limits Different character limits apply to each of the communication channels as listed below. NHS Notify will validate that any personalisation fields submitted in the send message request do not exceed these limits but it is the client's responsibility to ensure that when personalisation is combined with any templated text, the channel character limit is not exceeded. diff --git a/specification/documentation/GetMessageResponses.md b/specification/documentation/GetMessageResponses.md index f8dd0a17d..0776de10f 100644 --- a/specification/documentation/GetMessageResponses.md +++ b/specification/documentation/GetMessageResponses.md @@ -2,11 +2,13 @@ Use this endpoint to retrieve recipient responses associated with a specific message. +Recipient responses are the keyword answers selected by a recipient for a message sent through the NHS App. For more information, see the [recipient response callback](#post-/-client-provided-recipient-response-URI-). + Responses are available for retrieval for up to 9 months after the message was sent. ### Response structure -A successful response returns a flat JSON array of response items. +A successful response returns an array of response items. Each item includes: @@ -25,17 +27,16 @@ If no responses exist for the given message, a `404` response is returned. ### Sandbox -When sending this request on sandbox you can use any valid message ID format. +When sending this request on sandbox you can use any valid UUID v4 message ID. To simulate error responses in the sandbox, use the following message IDs: * not found - `00000000-0000-4000-8000-000000000404` -* bad gateway - `00000000-0000-4000-8000-000000000502` -* too many responses - `00000000-0000-4000-8000-000000000500` +* too many responses - `00000000-0000-4000-8000-000000000422` Here's an example curl command: ``` -curl -X GET 'https://sandbox.api.service.nhs.uk/comms/v1/message-responses/2WL3qFTEFM0qMY8xjRbt1LIKCzM' \ - --header 'Accept: application/vnd.api+json' +curl -X GET 'https://sandbox.api.service.nhs.uk/comms/v1/message-responses/11111111-1111-4111-8111-111111111111' \ + --header 'Accept: application/json' ``` diff --git a/specification/endpoints/get_responses.yaml b/specification/endpoints/get_responses.yaml index 3167326d1..d6216371d 100644 --- a/specification/endpoints/get_responses.yaml +++ b/specification/endpoints/get_responses.yaml @@ -18,10 +18,10 @@ responses: $ref: ../responses/4xx/404_NotFound.yaml '405': $ref: ../responses/4xx/405_NotAllowed.yaml - '406': - $ref: ../responses/4xx/406_NotAcceptable.yaml '408': $ref: ../responses/4xx/408_RequestTimeout.yaml + '415': + $ref: ../responses/4xx/415_UnsupportedMedia.yaml '422': $ref: ../responses/4xx/message_responses/422_TooManyResponses.yaml '429': diff --git a/specification/responses/2xx/200_Responses.yaml b/specification/responses/2xx/200_Responses.yaml index 18c82b100..d69028d05 100644 --- a/specification/responses/2xx/200_Responses.yaml +++ b/specification/responses/2xx/200_Responses.yaml @@ -2,9 +2,6 @@ description: The responses for the given message have been retrieved successfull headers: $ref: ../../snippets/StandardResponseHeaders.yaml content: - application/vnd.api+json: - schema: - $ref: ../../schemas/responses/MessageResponses.yaml application/json: schema: $ref: ../../schemas/responses/MessageResponses.yaml diff --git a/specification/responses/4xx/406_NotAcceptable.yaml b/specification/responses/4xx/406_NotAcceptable.yaml index d8ad56b3b..34de3ef24 100644 --- a/specification/responses/4xx/406_NotAcceptable.yaml +++ b/specification/responses/4xx/406_NotAcceptable.yaml @@ -15,4 +15,4 @@ content: schema: $ref: ../../schemas/responses/errors/NotAcceptable.yaml headers: - $ref: ../../snippets/StandardResponseHeaders.yaml \ No newline at end of file + $ref: ../../snippets/StandardResponseHeaders.yaml diff --git a/specification/responses/4xx/415_UnsupportedMedia.yaml b/specification/responses/4xx/415_UnsupportedMedia.yaml index dc686705d..e47139f8f 100644 --- a/specification/responses/4xx/415_UnsupportedMedia.yaml +++ b/specification/responses/4xx/415_UnsupportedMedia.yaml @@ -1,16 +1,8 @@ description: |+ - The `Content-Type` of the request is not supported. This endpoint supports: - - * `application/json` - * `application/vnd.api+json` - * `application/json; charset=utf-8` - * `application/vnd.api+json; charset=utf-8` + The `Content-Type` of the request is not supported. This endpoint supports `application/json`. content: - application/vnd.api+json: - schema: - $ref: ../../schemas/responses/errors/UnsupportedMedia.yaml application/json: schema: $ref: ../../schemas/responses/errors/UnsupportedMedia.yaml headers: - $ref: ../../snippets/StandardResponseHeaders.yaml \ No newline at end of file + $ref: ../../snippets/StandardResponseHeaders.yaml diff --git a/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml b/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml index 8036751e5..6c5fdca57 100644 --- a/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml +++ b/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml @@ -1,28 +1,5 @@ -description: |+ - The message ID in the request path is not a valid KSUID. - - The following validation error can occur: - - | Error code | Title | Description | - | ---------- | ----- | ----------- | - | `CM_INVALID_VALUE` | Invalid value | The `messageId` path parameter is not a valid KSUID. | - - ### Sandbox - - It is possible to trigger the `CM_INVALID_VALUE` error in the sandbox by sending the header `Prefer` with a value of `code=400`. - - Here is an example curl request: - - ``` - curl -X GET \ - --header "Accept: application/vnd.api+json" \ - --header "Prefer: code=400" \ - https://sandbox.api.service.nhs.uk/comms/v1/messages/not-a-valid-id/responses - ``` +description: The `messageId` path parameter is not a valid UUID. content: - application/vnd.api+json: - schema: - $ref: ../../../schemas/responses/errors/message_responses/UnableToGetResponses.yaml application/json: schema: $ref: ../../../schemas/responses/errors/message_responses/UnableToGetResponses.yaml diff --git a/specification/responses/4xx/message_responses/422_TooManyResponses.yaml b/specification/responses/4xx/message_responses/422_TooManyResponses.yaml index 03cf526f1..168764700 100644 --- a/specification/responses/4xx/message_responses/422_TooManyResponses.yaml +++ b/specification/responses/4xx/message_responses/422_TooManyResponses.yaml @@ -1,22 +1,5 @@ -description: |+ - There are too many responses associated with this message to return in a single response. This error occurs when more than 1000 responses exist for the given message ID. - - ### Sandbox - - It is possible to trigger this error in the sandbox by sending the header `Prefer` with a value of `code=422`. - - Here is an example curl request: - - ``` - curl -X GET \ - --header "Accept: application/vnd.api+json" \ - --header "Prefer: code=422" \ - https://sandbox.api.service.nhs.uk/comms/v1/messages/2WL3qFTEFM0qMY8xjRbt1LIKCzM/responses - ``` +description: There are too many responses associated with this message to return in a single response. This error occurs when more than 1000 responses exist for the given message ID. content: - application/vnd.api+json: - schema: - $ref: ../../../schemas/responses/errors/message_responses/TooManyResponses.yaml application/json: schema: $ref: ../../../schemas/responses/errors/message_responses/TooManyResponses.yaml diff --git a/specification/schemas/components/ResponseItem.yaml b/specification/schemas/components/ResponseItem.yaml index 3c3fbf462..1065d51bf 100644 --- a/specification/schemas/components/ResponseItem.yaml +++ b/specification/schemas/components/ResponseItem.yaml @@ -20,7 +20,8 @@ properties: messageId: type: string description: The unique identifier of the message this response relates to. - example: "2WL3qFTEFM0qMY8xjRbt1LIKCzM" + format: uuid + example: "11111111-1111-4111-8111-111111111111" messageReference: type: string description: The reference for the message, as provided when the message was created. diff --git a/specification/schemas/enums/ErrorTooManyResponses.yaml b/specification/schemas/enums/ErrorTooManyResponses.yaml deleted file mode 100644 index bc2afc083..000000000 --- a/specification/schemas/enums/ErrorTooManyResponses.yaml +++ /dev/null @@ -1,5 +0,0 @@ -title: Enum_Error_TooManyResponses -type: string -enum: - - CM_TOO_MANY_RESPONSES -example: CM_TOO_MANY_RESPONSES diff --git a/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml b/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml index 7a3786118..0aee3a0aa 100644 --- a/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml +++ b/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml @@ -1,34 +1,17 @@ type: object title: Too many responses additionalProperties: false +required: + - error + - message properties: - errors: - type: array - minItems: 1 - maxItems: 1 - uniqueItems: true - items: - type: object - additionalProperties: false - properties: - id: - $ref: ../../../types/ErrorIdentifier.yaml - code: - $ref: ../../../enums/ErrorTooManyResponses.yaml - links: - $ref: ../../../types/LinksError.yaml - status: - type: string - enum: - - '422' - example: '422' - title: - type: string - enum: - - Too many responses - example: Too many responses - detail: - type: string - enum: - - There are too many responses to return. - example: There are too many responses to return. + error: + type: string + enum: + - response_too_large + example: response_too_large + message: + type: string + enum: + - There are too many responses to return. + example: There are too many responses to return. diff --git a/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml b/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml index b45186a9b..a4d689291 100644 --- a/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml +++ b/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml @@ -1,43 +1,11 @@ type: object title: Unable to get responses additionalProperties: false +required: + - error properties: - errors: - type: array - minItems: 1 - maxItems: 1 - uniqueItems: true - items: - type: object - additionalProperties: false - properties: - id: - $ref: ../../../types/ErrorIdentifier.yaml - code: - $ref: ../../../enums/ErrorInvalidValue.yaml - links: - $ref: ../../../types/LinksError.yaml - status: - type: string - enum: - - '400' - example: '400' - title: - type: string - enum: - - Invalid value - example: Invalid value - detail: - type: string - enum: - - The message ID provided is not a valid KSUID. - example: The message ID provided is not a valid KSUID. - source: - type: object - additionalProperties: false - properties: - parameter: - type: string - enum: - - messageId - example: messageId + error: + type: string + enum: + - Invalid messageId format + example: Invalid messageId format diff --git a/tests/sandbox/message_responses/test_success.py b/tests/sandbox/message_responses/test_success.py index 669a6ccb0..01adef8a2 100644 --- a/tests/sandbox/message_responses/test_success.py +++ b/tests/sandbox/message_responses/test_success.py @@ -17,16 +17,18 @@ def test_200_success(nhsd_apim_proxy_url, correlation_id): assert resp.status_code == 200, f"Response: {resp.status_code}: {resp.text}" body = resp.json() - assert body.get("messageId") == VALID_MESSAGE_ID - assert isinstance(body.get("responses"), list) - assert len(body["responses"]) > 0 + assert isinstance(body, list) + assert len(body) > 0 - first = body["responses"][0] + first = body[0] assert "responseId" in first + assert first["messageId"] == VALID_MESSAGE_ID assert "messageReference" in first assert "code" in first assert "channel" in first assert "channelStatus" in first + assert "cascadeType" in first assert "authoredAt" in first + assert "timestamp" in first Assertions.assert_correlation_id(resp.headers.get("X-Correlation-Id"), correlation_id) From 746ebe48635878bec575bfc8720853d60b9f35d6 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Thu, 20 Aug 2026 13:55:57 +0100 Subject: [PATCH 07/16] CCM-22090: Migrate app response API to JSON:API format --- .../documentation/GetMessageResponses.md | 35 +++++++- specification/endpoints/get_responses.yaml | 6 +- .../responses/2xx/200_Responses.yaml | 5 +- .../400_MessageResponsesInvalidRequest.yaml | 19 +++++ .../400_UnableToGetResponses.yaml | 7 -- .../422_TooManyResponses.yaml | 7 -- ...0_MessageResponsesInternalServerError.yaml | 20 +++++ .../schemas/components/ResponseItem.yaml | 66 --------------- .../enums/EnumRecipientResponseSnapshot.yaml | 5 ++ ...orMessageResponsesInternalServerError.yaml | 6 ++ .../schemas/responses/MessageResponses.yaml | 4 - .../responses/RecipientResponseSnapshot.yaml | 81 +++++++++++++++++++ .../MessageResponsesInternalServerError.yaml | 35 ++++++++ .../MessageResponsesInvalidRequest.yaml | 46 +++++++++++ .../message_responses/TooManyResponses.yaml | 17 ---- .../UnableToGetResponses.yaml | 11 --- 16 files changed, 249 insertions(+), 121 deletions(-) create mode 100644 specification/responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml delete mode 100644 specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml delete mode 100644 specification/responses/4xx/message_responses/422_TooManyResponses.yaml create mode 100644 specification/responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml delete mode 100644 specification/schemas/components/ResponseItem.yaml create mode 100644 specification/schemas/enums/EnumRecipientResponseSnapshot.yaml create mode 100644 specification/schemas/enums/ErrorMessageResponsesInternalServerError.yaml delete mode 100644 specification/schemas/responses/MessageResponses.yaml create mode 100644 specification/schemas/responses/RecipientResponseSnapshot.yaml create mode 100644 specification/schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml create mode 100644 specification/schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml delete mode 100644 specification/schemas/responses/errors/message_responses/TooManyResponses.yaml delete mode 100644 specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml diff --git a/specification/documentation/GetMessageResponses.md b/specification/documentation/GetMessageResponses.md index 0776de10f..d523d6c06 100644 --- a/specification/documentation/GetMessageResponses.md +++ b/specification/documentation/GetMessageResponses.md @@ -8,11 +8,36 @@ Responses are available for retrieval for up to 9 months after the message was s ### Response structure -A successful response returns an array of response items. +A successful response returns a JSON:API document containing an array of recipient response resources, for example: -Each item includes: +```json +{ + "data": [ + { + "type": "RecipientResponseSnapshot", + "id": "33333333-3333-4333-8333-333333333333", + "attributes": { + "messageId": "11111111-1111-4111-8111-111111111111", + "messageReference": "da0b1495-c7cb-468c-9d81-07dee089d728", + "channel": "nhsapp", + "channelStatus": "delivered", + "cascadeType": "primary", + "code": "YES", + "authoredAt": "2026-06-15T14:30:00.000Z", + "timestamp": "2026-06-15T14:30:05.123Z" + } + } + ] +} +``` + +Each resource has: + +* `type` - the resource type, always `RecipientResponseSnapshot` +* `id` - the unique identifier for this response + +Each resource's `attributes` include: -* `responseId` - the unique identifier for this response * `messageId` - the identifier of the message this response relates to * `messageReference` - the reference you provided when the message was created * `channel` - the channel through which the response was received @@ -25,6 +50,8 @@ Each item includes: If no responses exist for the given message, a `404` response is returned. +If there are more than 1000 responses for the given message, a `500` response with the `CM_TOO_MANY_RESPONSES` error code is returned instead. + ### Sandbox When sending this request on sandbox you can use any valid UUID v4 message ID. @@ -32,7 +59,7 @@ When sending this request on sandbox you can use any valid UUID v4 message ID. To simulate error responses in the sandbox, use the following message IDs: * not found - `00000000-0000-4000-8000-000000000404` -* too many responses - `00000000-0000-4000-8000-000000000422` +* too many responses - `00000000-0000-4000-8000-000000000500` Here's an example curl command: diff --git a/specification/endpoints/get_responses.yaml b/specification/endpoints/get_responses.yaml index d6216371d..1af3e20c1 100644 --- a/specification/endpoints/get_responses.yaml +++ b/specification/endpoints/get_responses.yaml @@ -9,7 +9,7 @@ responses: '200': $ref: ../responses/2xx/200_Responses.yaml '400': - $ref: ../responses/4xx/message_responses/400_UnableToGetResponses.yaml + $ref: ../responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml '401': $ref: ../responses/4xx/401_AccessDenied.yaml '403': @@ -22,12 +22,10 @@ responses: $ref: ../responses/4xx/408_RequestTimeout.yaml '415': $ref: ../responses/4xx/415_UnsupportedMedia.yaml - '422': - $ref: ../responses/4xx/message_responses/422_TooManyResponses.yaml '429': $ref: ../responses/4xx/429_TooManyRequests.yaml '500': - $ref: ../responses/5xx/500_InternalServerError.yaml + $ref: ../responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml '503': $ref: ../responses/5xx/503_ServiceUnavailable.yaml '504': diff --git a/specification/responses/2xx/200_Responses.yaml b/specification/responses/2xx/200_Responses.yaml index d69028d05..663267a23 100644 --- a/specification/responses/2xx/200_Responses.yaml +++ b/specification/responses/2xx/200_Responses.yaml @@ -2,6 +2,9 @@ description: The responses for the given message have been retrieved successfull headers: $ref: ../../snippets/StandardResponseHeaders.yaml content: + application/vnd.api+json: + schema: + $ref: ../../schemas/responses/RecipientResponseSnapshot.yaml application/json: schema: - $ref: ../../schemas/responses/MessageResponses.yaml + $ref: ../../schemas/responses/RecipientResponseSnapshot.yaml diff --git a/specification/responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml b/specification/responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml new file mode 100644 index 000000000..860563b22 --- /dev/null +++ b/specification/responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml @@ -0,0 +1,19 @@ +description: |+ + A validation error has occurred with the request sent. + + The following validation errors can occur: + + | Error code | Title | Description | + | -------------------- | --------------- | --------------------------------------------------- | + | `CM_INVALID_REQUEST` | Invalid Request | The `messageId` path parameter is not a valid UUID. | + + Within each error is a source object which identifies the request parameter that caused the error. +content: + application/vnd.api+json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml + application/json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml +headers: + $ref: ../../../snippets/StandardResponseHeaders.yaml diff --git a/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml b/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml deleted file mode 100644 index 6c5fdca57..000000000 --- a/specification/responses/4xx/message_responses/400_UnableToGetResponses.yaml +++ /dev/null @@ -1,7 +0,0 @@ -description: The `messageId` path parameter is not a valid UUID. -content: - application/json: - schema: - $ref: ../../../schemas/responses/errors/message_responses/UnableToGetResponses.yaml -headers: - $ref: ../../../snippets/StandardResponseHeaders.yaml diff --git a/specification/responses/4xx/message_responses/422_TooManyResponses.yaml b/specification/responses/4xx/message_responses/422_TooManyResponses.yaml deleted file mode 100644 index 168764700..000000000 --- a/specification/responses/4xx/message_responses/422_TooManyResponses.yaml +++ /dev/null @@ -1,7 +0,0 @@ -description: There are too many responses associated with this message to return in a single response. This error occurs when more than 1000 responses exist for the given message ID. -content: - application/json: - schema: - $ref: ../../../schemas/responses/errors/message_responses/TooManyResponses.yaml -headers: - $ref: ../../../snippets/StandardResponseHeaders.yaml diff --git a/specification/responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml b/specification/responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml new file mode 100644 index 000000000..0999b8efc --- /dev/null +++ b/specification/responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml @@ -0,0 +1,20 @@ +description: |+ + An error has occurred that is stopping your request from being processed. + + The following errors can occur: + + | Error code | Title | Description | + | -------------------------- | ------------------------ | ----------------------------------------------------------- | + | `CM_TOO_MANY_RESPONSES` | Too many responses | There are too many responses to return. | + | `CM_INTERNAL_SERVER_ERROR` | Error processing request | There was an internal error whilst processing this request. | + + The `CM_TOO_MANY_RESPONSES` error occurs when more than 1000 responses exist for the given message ID. +content: + application/vnd.api+json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml + application/json: + schema: + $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml +headers: + $ref: ../../../snippets/StandardResponseHeaders.yaml diff --git a/specification/schemas/components/ResponseItem.yaml b/specification/schemas/components/ResponseItem.yaml deleted file mode 100644 index 1065d51bf..000000000 --- a/specification/schemas/components/ResponseItem.yaml +++ /dev/null @@ -1,66 +0,0 @@ -type: object -title: ResponseItem -additionalProperties: false -required: - - responseId - - authoredAt - - cascadeType - - channel - - channelStatus - - code - - messageId - - messageReference - - timestamp -properties: - responseId: - type: string - format: uuid - description: The unique identifier for this response. - example: "33333333-3333-4333-8333-333333333333" - messageId: - type: string - description: The unique identifier of the message this response relates to. - format: uuid - example: "11111111-1111-4111-8111-111111111111" - messageReference: - type: string - description: The reference for the message, as provided when the message was created. - example: "da0b1495-c7cb-468c-9d81-07dee089d728" - channel: - type: string - enum: - - nhsapp - example: nhsapp - channelStatus: - type: string - description: The status of the channel at the time the response was received. - enum: - - sending - - delivered - - failed - example: delivered - channelFailureReasonCode: - type: string - description: The reason code for the channel failure. Only present when channelStatus is failed. - example: "CFR_SUPE_0001" - cascadeType: - type: string - description: Whether this is a primary or secondary cascade response. - enum: - - primary - - secondary - example: primary - code: - type: string - description: The keyword code from the recipient's response. - example: "YES" - authoredAt: - type: string - description: The date-time the recipient submitted their response. - format: date-time - example: "2026-06-15T14:30:00.000Z" - timestamp: - type: string - description: The date-time the response was recorded by NHS Notify. - format: date-time - example: "2026-06-15T14:30:05.123Z" diff --git a/specification/schemas/enums/EnumRecipientResponseSnapshot.yaml b/specification/schemas/enums/EnumRecipientResponseSnapshot.yaml new file mode 100644 index 000000000..93138dfb9 --- /dev/null +++ b/specification/schemas/enums/EnumRecipientResponseSnapshot.yaml @@ -0,0 +1,5 @@ +title: Enum_RecipientResponseSnapshot +type: string +enum: + - RecipientResponseSnapshot +example: RecipientResponseSnapshot diff --git a/specification/schemas/enums/ErrorMessageResponsesInternalServerError.yaml b/specification/schemas/enums/ErrorMessageResponsesInternalServerError.yaml new file mode 100644 index 000000000..4de745cb2 --- /dev/null +++ b/specification/schemas/enums/ErrorMessageResponsesInternalServerError.yaml @@ -0,0 +1,6 @@ +title: Enum_Error_MessageResponsesInternalServerError +type: string +enum: + - CM_TOO_MANY_RESPONSES + - CM_INTERNAL_SERVER_ERROR +example: CM_TOO_MANY_RESPONSES diff --git a/specification/schemas/responses/MessageResponses.yaml b/specification/schemas/responses/MessageResponses.yaml deleted file mode 100644 index a2756f09b..000000000 --- a/specification/schemas/responses/MessageResponses.yaml +++ /dev/null @@ -1,4 +0,0 @@ -title: MessageResponses -type: array -items: - $ref: ../components/ResponseItem.yaml diff --git a/specification/schemas/responses/RecipientResponseSnapshot.yaml b/specification/schemas/responses/RecipientResponseSnapshot.yaml new file mode 100644 index 000000000..684bdf2d5 --- /dev/null +++ b/specification/schemas/responses/RecipientResponseSnapshot.yaml @@ -0,0 +1,81 @@ +title: RecipientResponseSnapshot +type: object +additionalProperties: false +properties: + data: + type: array + items: + type: object + additionalProperties: false + required: + - type + - id + - attributes + properties: + type: + $ref: ../enums/EnumRecipientResponseSnapshot.yaml + id: + type: string + format: uuid + description: The unique identifier for this response. + example: "33333333-3333-4333-8333-333333333333" + attributes: + type: object + additionalProperties: false + required: + - authoredAt + - cascadeType + - channel + - channelStatus + - code + - messageId + - messageReference + - timestamp + properties: + messageId: + type: string + description: The unique identifier of the message this response relates to. + format: uuid + example: "11111111-1111-4111-8111-111111111111" + messageReference: + type: string + description: The reference for the message, as provided when the message was created. + example: "da0b1495-c7cb-468c-9d81-07dee089d728" + channel: + type: string + enum: + - nhsapp + example: nhsapp + channelStatus: + type: string + description: The status of the channel at the time the response was received. + enum: + - sending + - delivered + - failed + example: delivered + channelFailureReasonCode: + type: string + description: The reason code for the channel failure. Only present when channelStatus is failed. + example: "CFR_SUPE_0001" + cascadeType: + type: string + description: Whether this is a primary or secondary cascade response. + enum: + - primary + - secondary + example: primary + code: + type: string + description: The keyword code from the recipient's response. + example: "YES" + authoredAt: + type: string + description: The date-time the recipient submitted their response. + format: date-time + example: "2026-06-15T14:30:00.000Z" + timestamp: + type: string + description: The date-time the response was recorded by NHS Notify. + format: date-time + example: "2026-06-15T14:30:05.123Z" diff --git a/specification/schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml b/specification/schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml new file mode 100644 index 000000000..2dfec4065 --- /dev/null +++ b/specification/schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml @@ -0,0 +1,35 @@ +type: object +title: Internal server error +additionalProperties: false +properties: + errors: + type: array + minItems: 1 + uniqueItems: true + items: + type: object + additionalProperties: false + properties: + id: + $ref: ../../../types/ErrorIdentifier.yaml + code: + $ref: ../../../enums/ErrorMessageResponsesInternalServerError.yaml + links: + $ref: ../../../types/LinksError.yaml + status: + type: string + enum: + - '500' + example: '500' + title: + type: string + enum: + - Too many responses + - Error processing request + example: Too many responses + detail: + type: string + enum: + - 'There are too many responses to return.' + - 'There was an internal error whilst processing this request.' + example: 'There are too many responses to return.' diff --git a/specification/schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml b/specification/schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml new file mode 100644 index 000000000..bf973d548 --- /dev/null +++ b/specification/schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml @@ -0,0 +1,46 @@ +type: object +title: Invalid request +additionalProperties: false +properties: + errors: + type: array + minItems: 1 + uniqueItems: true + items: + type: object + additionalProperties: false + properties: + id: + $ref: ../../../types/ErrorIdentifier.yaml + code: + type: string + enum: + - CM_INVALID_REQUEST + example: CM_INVALID_REQUEST + title: Enum_Error_InvalidGetMessageResponses_Request + links: + $ref: ../../../types/LinksError.yaml + status: + type: string + enum: + - '400' + example: '400' + title: + type: string + enum: + - Invalid Request + example: Invalid Request + detail: + type: string + enum: + - The messageId path parameter is not a valid UUID. + example: 'The messageId path parameter is not a valid UUID.' + source: + type: object + additionalProperties: false + properties: + parameter: + type: string + enum: + - messageId + example: messageId diff --git a/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml b/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml deleted file mode 100644 index 0aee3a0aa..000000000 --- a/specification/schemas/responses/errors/message_responses/TooManyResponses.yaml +++ /dev/null @@ -1,17 +0,0 @@ -type: object -title: Too many responses -additionalProperties: false -required: - - error - - message -properties: - error: - type: string - enum: - - response_too_large - example: response_too_large - message: - type: string - enum: - - There are too many responses to return. - example: There are too many responses to return. diff --git a/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml b/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml deleted file mode 100644 index a4d689291..000000000 --- a/specification/schemas/responses/errors/message_responses/UnableToGetResponses.yaml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -title: Unable to get responses -additionalProperties: false -required: - - error -properties: - error: - type: string - enum: - - Invalid messageId format - example: Invalid messageId format From 52e06fd7fa01019a2a0a5f69ca09e0e87dcf42e4 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 24 Aug 2026 08:23:03 +0100 Subject: [PATCH 08/16] CCM-22090: PR feedback --- sandbox/__test__/message_responses.spec.js | 85 ++++++++--- sandbox/handlers/message_responses.js | 132 +++++++++++++----- .../documentation/GetMessageResponses.md | 2 +- .../responses/4xx/415_UnsupportedMedia.yaml | 5 +- 4 files changed, 166 insertions(+), 58 deletions(-) diff --git a/sandbox/__test__/message_responses.spec.js b/sandbox/__test__/message_responses.spec.js index 718ed8d4f..56da0b0a5 100644 --- a/sandbox/__test__/message_responses.spec.js +++ b/sandbox/__test__/message_responses.spec.js @@ -4,7 +4,7 @@ import { setup } from './helpers.js' const VALID_MESSAGE_ID = '11111111-1111-4111-8111-111111111111'; const NOT_FOUND_MESSAGE_ID = '00000000-0000-4000-8000-000000000404'; -const TOO_MANY_RESPONSES_MESSAGE_ID = '00000000-0000-4000-8000-000000000422'; +const TOO_MANY_RESPONSES_MESSAGE_ID = '00000000-0000-4000-8000-000000000500'; describe('/api/v1/message-responses/:messageId', () => { let env; @@ -34,7 +34,14 @@ describe('/api/v1/message-responses/:messageId', () => { .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) .set({ Authorization: 'banned' }) .expect(403, { - error: 'Forbidden' + errors: [ + { + code: 'CM_FORBIDDEN', + status: '403', + title: 'Forbidden', + detail: 'Client not recognised or not yet onboarded.' + } + ] }) .expect('Content-Type', /json/, done); }); @@ -43,7 +50,15 @@ describe('/api/v1/message-responses/:messageId', () => { request(server) .get('/api/v1/message-responses/not-a-valid-uuid') .expect(400, { - error: 'Invalid messageId format' + errors: [ + { + code: 'CM_INVALID_REQUEST', + status: '400', + title: 'Invalid Request', + detail: 'The messageId path parameter is not a valid UUID.', + source: { parameter: 'messageId' } + } + ] }) .expect('Content-Type', /json/, done); }); @@ -52,7 +67,14 @@ describe('/api/v1/message-responses/:messageId', () => { request(server) .get(`/api/v1/message-responses/${NOT_FOUND_MESSAGE_ID}`) .expect(404, { - error: 'No responses found for the specified messageId' + errors: [ + { + code: 'CM_NOT_FOUND', + status: '404', + title: 'Resource not found', + detail: 'The resource at the requested URI was not found.' + } + ] }) .expect('Content-Type', /json/, done); }); @@ -60,9 +82,15 @@ describe('/api/v1/message-responses/:messageId', () => { it('returns a 500 when too many responses are returned', (done) => { request(server) .get(`/api/v1/message-responses/${TOO_MANY_RESPONSES_MESSAGE_ID}`) - .expect(422, { - error: 'response_too_large', - message: 'There are too many responses to return.' + .expect(500, { + errors: [ + { + code: 'CM_TOO_MANY_RESPONSES', + status: '500', + title: 'Too many responses', + detail: 'There are too many responses to return.' + } + ] }) .expect('Content-Type', /json/, done); }); @@ -72,7 +100,15 @@ describe('/api/v1/message-responses/:messageId', () => { .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) .set('Content-Type', 'text/plain') .expect(415, { - message: 'Unsupported media type.' + errors: [ + { + code: 'CM_UNSUPPORTED_MEDIA', + status: '415', + title: 'Unsupported media', + detail: 'Invalid content-type, this API only supports application/vnd.api+json or application/json.', + source: { header: 'Content-Type' } + } + ] }) .expect('Content-Type', /json/, done); }); @@ -82,7 +118,14 @@ describe('/api/v1/message-responses/:messageId', () => { .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) .set('Prefer', 'code=429') .expect(429, { - message: 'Too many requests.' + errors: [ + { + code: 'CM_QUOTA', + status: '429', + title: 'Too many requests', + detail: 'You have made too many requests. Re-send the request after the time (in seconds) specified `Retry-After` header.' + } + ] }) .expect('Content-Type', /json/, done); }); @@ -94,17 +137,19 @@ describe('/api/v1/message-responses/:messageId', () => { .expect('Content-Type', /json/) .expect((res) => { const { body } = res; - if (!Array.isArray(body)) throw new Error('response must be an array'); - const first = body[0]; - if (!first.responseId) throw new Error('missing responseId'); - if (first.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId'); - if (!first.messageReference) throw new Error('missing messageReference'); - if (!first.code) throw new Error('missing code'); - if (!first.channel) throw new Error('missing channel'); - if (!first.channelStatus) throw new Error('missing channelStatus'); - if (!first.cascadeType) throw new Error('missing cascadeType'); - if (!first.authoredAt) throw new Error('missing authoredAt'); - if (!first.timestamp) throw new Error('missing timestamp'); + if (!Array.isArray(body.data)) throw new Error('response must contain a data array'); + const first = body.data[0]; + if (!first.id) throw new Error('missing id'); + if (first.type !== 'RecipientResponseSnapshot') throw new Error('incorrect type'); + const { attributes } = first; + if (attributes.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId'); + if (!attributes.messageReference) throw new Error('missing messageReference'); + if (!attributes.code) throw new Error('missing code'); + if (!attributes.channel) throw new Error('missing channel'); + if (!attributes.channelStatus) throw new Error('missing channelStatus'); + if (!attributes.cascadeType) throw new Error('missing cascadeType'); + if (!attributes.authoredAt) throw new Error('missing authoredAt'); + if (!attributes.timestamp) throw new Error('missing timestamp'); }) .end(done); }); diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js index 7872ca272..1a651637c 100644 --- a/sandbox/handlers/message_responses.js +++ b/sandbox/handlers/message_responses.js @@ -1,25 +1,52 @@ -import { sendError } from './utils.js' - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const notFoundMessageId = '00000000-0000-4000-8000-000000000404'; -const tooManyResponsesMessageId = '00000000-0000-4000-8000-000000000422'; +const tooManyResponsesMessageId = '00000000-0000-4000-8000-000000000500'; +const supportedContentTypes = ['application/json', 'application/vnd.api+json']; export async function messageResponses(req, res, next) { if (req.headers.authorization === 'banned') { - res.status(403).json({ error: 'Forbidden' }); + res.status(403).json({ + errors: [ + { + code: 'CM_FORBIDDEN', + status: '403', + title: 'Forbidden', + detail: 'Client not recognised or not yet onboarded.' + } + ] + }); next(); return; } - if (req.headers['content-type'] && req.headers['content-type'] !== 'application/json') { - sendError(res, 415, 'Unsupported media type.'); + if (req.headers['content-type'] && !supportedContentTypes.includes(req.headers['content-type'])) { + res.status(415).json({ + errors: [ + { + code: 'CM_UNSUPPORTED_MEDIA', + status: '415', + title: 'Unsupported media', + detail: 'Invalid content-type, this API only supports application/vnd.api+json or application/json.', + source: { header: 'Content-Type' } + } + ] + }); next(); return; } if (req.headers.prefer === 'code=429') { - sendError(res, 429, 'Too many requests.'); + res.status(429).json({ + errors: [ + { + code: 'CM_QUOTA', + status: '429', + title: 'Too many requests', + detail: 'You have made too many requests. Re-send the request after the time (in seconds) specified `Retry-After` header.' + } + ] + }); next(); return; } @@ -27,21 +54,46 @@ export async function messageResponses(req, res, next) { const { messageId } = req.params; if (!uuidRegex.test(messageId)) { - res.status(400).json({ error: 'Invalid messageId format' }); + res.status(400).json({ + errors: [ + { + code: 'CM_INVALID_REQUEST', + status: '400', + title: 'Invalid Request', + detail: 'The messageId path parameter is not a valid UUID.', + source: { parameter: 'messageId' } + } + ] + }); next(); return; } if (messageId === notFoundMessageId) { - res.status(404).json({ error: 'No responses found for the specified messageId' }); + res.status(404).json({ + errors: [ + { + code: 'CM_NOT_FOUND', + status: '404', + title: 'Resource not found', + detail: 'The resource at the requested URI was not found.' + } + ] + }); next(); return; } if (messageId === tooManyResponsesMessageId) { - res.status(422).json({ - error: 'response_too_large', - message: 'There are too many responses to return.' + res.status(500).json({ + errors: [ + { + code: 'CM_TOO_MANY_RESPONSES', + status: '500', + title: 'Too many responses', + detail: 'There are too many responses to return.' + } + ] }); next(); return; @@ -51,28 +103,36 @@ export async function messageResponses(req, res, next) { } function getDefaultResponse(messageId) { - return [ - { - responseId: '22222222-2222-4222-8222-222222222222', - messageId, - messageReference: 'msg-ref-1', - channel: 'nhsapp', - channelStatus: 'delivered', - cascadeType: 'primary', - code: 'YES', - authoredAt: '2026-01-02T09:00:00.000Z', - timestamp: '2026-01-02T09:00:02.345Z' - }, - { - responseId: '33333333-3333-4333-8333-333333333333', - messageId, - messageReference: 'msg-ref-1', - channel: 'nhsapp', - channelStatus: 'delivered', - cascadeType: 'secondary', - code: 'NO', - authoredAt: '2026-01-02T09:05:00.000Z', - timestamp: '2026-01-02T09:05:01.678Z' - } - ]; + return { + data: [ + { + type: 'RecipientResponseSnapshot', + id: '22222222-2222-4222-8222-222222222222', + attributes: { + messageId, + messageReference: 'msg-ref-1', + channel: 'nhsapp', + channelStatus: 'delivered', + cascadeType: 'primary', + code: 'YES', + authoredAt: '2026-01-02T09:00:00.000Z', + timestamp: '2026-01-02T09:00:02.345Z' + } + }, + { + type: 'RecipientResponseSnapshot', + id: '33333333-3333-4333-8333-333333333333', + attributes: { + messageId, + messageReference: 'msg-ref-1', + channel: 'nhsapp', + channelStatus: 'delivered', + cascadeType: 'secondary', + code: 'NO', + authoredAt: '2026-01-02T09:05:00.000Z', + timestamp: '2026-01-02T09:05:01.678Z' + } + } + ] + }; } diff --git a/specification/documentation/GetMessageResponses.md b/specification/documentation/GetMessageResponses.md index d523d6c06..faabd83cf 100644 --- a/specification/documentation/GetMessageResponses.md +++ b/specification/documentation/GetMessageResponses.md @@ -65,5 +65,5 @@ Here's an example curl command: ``` curl -X GET 'https://sandbox.api.service.nhs.uk/comms/v1/message-responses/11111111-1111-4111-8111-111111111111' \ - --header 'Accept: application/json' + --header 'Accept: application/vnd.api+json' ``` diff --git a/specification/responses/4xx/415_UnsupportedMedia.yaml b/specification/responses/4xx/415_UnsupportedMedia.yaml index e47139f8f..ad5815d7a 100644 --- a/specification/responses/4xx/415_UnsupportedMedia.yaml +++ b/specification/responses/4xx/415_UnsupportedMedia.yaml @@ -1,6 +1,9 @@ description: |+ - The `Content-Type` of the request is not supported. This endpoint supports `application/json`. + The `Content-Type` of the request is not supported. This endpoint supports `application/vnd.api+json` and `application/json`. content: + application/vnd.api+json: + schema: + $ref: ../../schemas/responses/errors/UnsupportedMedia.yaml application/json: schema: $ref: ../../schemas/responses/errors/UnsupportedMedia.yaml From 8daff3dd38df674c82ebb47377147b7e3b1af6b5 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 24 Aug 2026 13:58:46 +0100 Subject: [PATCH 09/16] CCM-22090: Reverted shared file --- specification/responses/4xx/415_UnsupportedMedia.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/specification/responses/4xx/415_UnsupportedMedia.yaml b/specification/responses/4xx/415_UnsupportedMedia.yaml index ad5815d7a..dc686705d 100644 --- a/specification/responses/4xx/415_UnsupportedMedia.yaml +++ b/specification/responses/4xx/415_UnsupportedMedia.yaml @@ -1,5 +1,10 @@ description: |+ - The `Content-Type` of the request is not supported. This endpoint supports `application/vnd.api+json` and `application/json`. + The `Content-Type` of the request is not supported. This endpoint supports: + + * `application/json` + * `application/vnd.api+json` + * `application/json; charset=utf-8` + * `application/vnd.api+json; charset=utf-8` content: application/vnd.api+json: schema: @@ -8,4 +13,4 @@ content: schema: $ref: ../../schemas/responses/errors/UnsupportedMedia.yaml headers: - $ref: ../../snippets/StandardResponseHeaders.yaml + $ref: ../../snippets/StandardResponseHeaders.yaml \ No newline at end of file From 87b35ac05d5d02dd4d8c83cbedd233589e969334 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Mon, 24 Aug 2026 14:08:09 +0100 Subject: [PATCH 10/16] CCM-22090: PR feedback --- sandbox/__test__/message_responses.spec.js | 19 ++-------------- sandbox/handlers/message_responses.js | 25 ++++------------------ 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/sandbox/__test__/message_responses.spec.js b/sandbox/__test__/message_responses.spec.js index 56da0b0a5..38e49477d 100644 --- a/sandbox/__test__/message_responses.spec.js +++ b/sandbox/__test__/message_responses.spec.js @@ -34,14 +34,7 @@ describe('/api/v1/message-responses/:messageId', () => { .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) .set({ Authorization: 'banned' }) .expect(403, { - errors: [ - { - code: 'CM_FORBIDDEN', - status: '403', - title: 'Forbidden', - detail: 'Client not recognised or not yet onboarded.' - } - ] + error: 'Forbidden' }) .expect('Content-Type', /json/, done); }); @@ -100,15 +93,7 @@ describe('/api/v1/message-responses/:messageId', () => { .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) .set('Content-Type', 'text/plain') .expect(415, { - errors: [ - { - code: 'CM_UNSUPPORTED_MEDIA', - status: '415', - title: 'Unsupported media', - detail: 'Invalid content-type, this API only supports application/vnd.api+json or application/json.', - source: { header: 'Content-Type' } - } - ] + message: 'Unsupported media type.' }) .expect('Content-Type', /json/, done); }); diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js index 1a651637c..e32184af3 100644 --- a/sandbox/handlers/message_responses.js +++ b/sandbox/handlers/message_responses.js @@ -1,3 +1,5 @@ +import { sendError } from './utils.js' + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const notFoundMessageId = '00000000-0000-4000-8000-000000000404'; @@ -6,32 +8,13 @@ const supportedContentTypes = ['application/json', 'application/vnd.api+json']; export async function messageResponses(req, res, next) { if (req.headers.authorization === 'banned') { - res.status(403).json({ - errors: [ - { - code: 'CM_FORBIDDEN', - status: '403', - title: 'Forbidden', - detail: 'Client not recognised or not yet onboarded.' - } - ] - }); + res.status(403).json({ error: 'Forbidden' }); next(); return; } if (req.headers['content-type'] && !supportedContentTypes.includes(req.headers['content-type'])) { - res.status(415).json({ - errors: [ - { - code: 'CM_UNSUPPORTED_MEDIA', - status: '415', - title: 'Unsupported media', - detail: 'Invalid content-type, this API only supports application/vnd.api+json or application/json.', - source: { header: 'Content-Type' } - } - ] - }); + sendError(res, 415, 'Unsupported media type.'); next(); return; } From 2c4a7d50fec96c883bea2f96d6706bd428403510 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Tue, 25 Aug 2026 08:10:12 +0100 Subject: [PATCH 11/16] CCM-22090: PR feedback --- proxies/live/apiproxy/proxies/default.xml | 4 +++ .../apiproxy/targets/app-response-target.xml | 26 ++++++++++++++++++ proxies/live/apiproxy/targets/target.xml | 1 - sandbox/__test__/message_responses.spec.js | 27 +++++++------------ sandbox/handlers/message_responses.js | 23 +++------------- .../documentation/GetMessageResponses.md | 7 ++--- specification/endpoints/get_responses.yaml | 2 +- .../responses/2xx/200_Responses.yaml | 4 +-- .../schemas/enums/EnumRecipientResponse.yaml | 5 ++++ .../enums/EnumRecipientResponseSnapshot.yaml | 5 ---- ...seSnapshot.yaml => RecipientResponse.yaml} | 4 +-- .../lib/constants/message_responses_paths.py | 1 - tests/sandbox/message_responses/test_500.py | 25 ----------------- 13 files changed, 55 insertions(+), 79 deletions(-) create mode 100644 proxies/live/apiproxy/targets/app-response-target.xml create mode 100644 specification/schemas/enums/EnumRecipientResponse.yaml delete mode 100644 specification/schemas/enums/EnumRecipientResponseSnapshot.yaml rename specification/schemas/responses/{RecipientResponseSnapshot.yaml => RecipientResponse.yaml} (96%) delete mode 100644 tests/sandbox/message_responses/test_500.py diff --git a/proxies/live/apiproxy/proxies/default.xml b/proxies/live/apiproxy/proxies/default.xml index 5f4c2dc74..4529c9e8a 100644 --- a/proxies/live/apiproxy/proxies/default.xml +++ b/proxies/live/apiproxy/proxies/default.xml @@ -15,6 +15,10 @@ secure [% include './partials/Partial.Proxy.Routes.xml' %] + + (proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and (request.verb = "GET") + app-response-target + communications-manager-target diff --git a/proxies/live/apiproxy/targets/app-response-target.xml b/proxies/live/apiproxy/targets/app-response-target.xml new file mode 100644 index 000000000..22d756377 --- /dev/null +++ b/proxies/live/apiproxy/targets/app-response-target.xml @@ -0,0 +1,26 @@ + + + + [% include './partials/Partial.Target.PreFlowRequest.xml' %] + + + + [% include './partials/Partial.Flows.GetMessageResponsesEndpoint.xml' %] + + [% include './partials/Partial.Target.PostFlow.xml' %] + + [% include './partials/Partial.Target.FaultRules.xml' %] + + + + true + + + + + {requestpath} + + 29000 + + + diff --git a/proxies/live/apiproxy/targets/target.xml b/proxies/live/apiproxy/targets/target.xml index ad495a61e..c9c0bfdd3 100644 --- a/proxies/live/apiproxy/targets/target.xml +++ b/proxies/live/apiproxy/targets/target.xml @@ -9,7 +9,6 @@ [% include './partials/Partial.Flows.CreateMessageEndpoint.xml' %] [% include './partials/Partial.Flows.GetMessageEndpoint.xml' %] [% include './partials/Partial.Flows.GetNhsAppAccountsEndpoint.xml' %] - [% include './partials/Partial.Flows.GetMessageResponsesEndpoint.xml' %] [% include './partials/Partial.Target.PostFlow.xml' %] diff --git a/sandbox/__test__/message_responses.spec.js b/sandbox/__test__/message_responses.spec.js index 38e49477d..fa7f9bff6 100644 --- a/sandbox/__test__/message_responses.spec.js +++ b/sandbox/__test__/message_responses.spec.js @@ -4,7 +4,7 @@ import { setup } from './helpers.js' const VALID_MESSAGE_ID = '11111111-1111-4111-8111-111111111111'; const NOT_FOUND_MESSAGE_ID = '00000000-0000-4000-8000-000000000404'; -const TOO_MANY_RESPONSES_MESSAGE_ID = '00000000-0000-4000-8000-000000000500'; +const NON_V4_MESSAGE_ID = '11111111-1111-1111-8111-111111111111'; describe('/api/v1/message-responses/:messageId', () => { let env; @@ -56,6 +56,13 @@ describe('/api/v1/message-responses/:messageId', () => { .expect('Content-Type', /json/, done); }); + it('returns a 200 for a non-v4 UUID messageId', (done) => { + request(server) + .get(`/api/v1/message-responses/${NON_V4_MESSAGE_ID}`) + .expect(200) + .expect('Content-Type', /json/, done); + }); + it('returns a 404 when no responses are found', (done) => { request(server) .get(`/api/v1/message-responses/${NOT_FOUND_MESSAGE_ID}`) @@ -72,22 +79,6 @@ describe('/api/v1/message-responses/:messageId', () => { .expect('Content-Type', /json/, done); }); - it('returns a 500 when too many responses are returned', (done) => { - request(server) - .get(`/api/v1/message-responses/${TOO_MANY_RESPONSES_MESSAGE_ID}`) - .expect(500, { - errors: [ - { - code: 'CM_TOO_MANY_RESPONSES', - status: '500', - title: 'Too many responses', - detail: 'There are too many responses to return.' - } - ] - }) - .expect('Content-Type', /json/, done); - }); - it('returns a 415 when the content type is not supported', (done) => { request(server) .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`) @@ -125,7 +116,7 @@ describe('/api/v1/message-responses/:messageId', () => { if (!Array.isArray(body.data)) throw new Error('response must contain a data array'); const first = body.data[0]; if (!first.id) throw new Error('missing id'); - if (first.type !== 'RecipientResponseSnapshot') throw new Error('incorrect type'); + if (first.type !== 'RecipientResponse') throw new Error('incorrect type'); const { attributes } = first; if (attributes.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId'); if (!attributes.messageReference) throw new Error('missing messageReference'); diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js index e32184af3..4fe8aefdc 100644 --- a/sandbox/handlers/message_responses.js +++ b/sandbox/handlers/message_responses.js @@ -1,9 +1,9 @@ import { sendError } from './utils.js' -const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// Mirrors the RFC 9562 UUID validation used by the app-response bounded context (versions 1-8, plus the nil/max UUIDs). +const uuidRegex = /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; const notFoundMessageId = '00000000-0000-4000-8000-000000000404'; -const tooManyResponsesMessageId = '00000000-0000-4000-8000-000000000500'; const supportedContentTypes = ['application/json', 'application/vnd.api+json']; export async function messageResponses(req, res, next) { @@ -67,21 +67,6 @@ export async function messageResponses(req, res, next) { return; } - if (messageId === tooManyResponsesMessageId) { - res.status(500).json({ - errors: [ - { - code: 'CM_TOO_MANY_RESPONSES', - status: '500', - title: 'Too many responses', - detail: 'There are too many responses to return.' - } - ] - }); - next(); - return; - } - res.type('json').status(200).json(getDefaultResponse(messageId)); } @@ -89,7 +74,7 @@ function getDefaultResponse(messageId) { return { data: [ { - type: 'RecipientResponseSnapshot', + type: 'RecipientResponse', id: '22222222-2222-4222-8222-222222222222', attributes: { messageId, @@ -103,7 +88,7 @@ function getDefaultResponse(messageId) { } }, { - type: 'RecipientResponseSnapshot', + type: 'RecipientResponse', id: '33333333-3333-4333-8333-333333333333', attributes: { messageId, diff --git a/specification/documentation/GetMessageResponses.md b/specification/documentation/GetMessageResponses.md index faabd83cf..ed42cf6f9 100644 --- a/specification/documentation/GetMessageResponses.md +++ b/specification/documentation/GetMessageResponses.md @@ -14,7 +14,7 @@ A successful response returns a JSON:API document containing an array of recipie { "data": [ { - "type": "RecipientResponseSnapshot", + "type": "RecipientResponse", "id": "33333333-3333-4333-8333-333333333333", "attributes": { "messageId": "11111111-1111-4111-8111-111111111111", @@ -33,7 +33,7 @@ A successful response returns a JSON:API document containing an array of recipie Each resource has: -* `type` - the resource type, always `RecipientResponseSnapshot` +* `type` - the resource type, always `RecipientResponse` * `id` - the unique identifier for this response Each resource's `attributes` include: @@ -50,8 +50,6 @@ Each resource's `attributes` include: If no responses exist for the given message, a `404` response is returned. -If there are more than 1000 responses for the given message, a `500` response with the `CM_TOO_MANY_RESPONSES` error code is returned instead. - ### Sandbox When sending this request on sandbox you can use any valid UUID v4 message ID. @@ -59,7 +57,6 @@ When sending this request on sandbox you can use any valid UUID v4 message ID. To simulate error responses in the sandbox, use the following message IDs: * not found - `00000000-0000-4000-8000-000000000404` -* too many responses - `00000000-0000-4000-8000-000000000500` Here's an example curl command: diff --git a/specification/endpoints/get_responses.yaml b/specification/endpoints/get_responses.yaml index 1af3e20c1..89e757b86 100644 --- a/specification/endpoints/get_responses.yaml +++ b/specification/endpoints/get_responses.yaml @@ -25,7 +25,7 @@ responses: '429': $ref: ../responses/4xx/429_TooManyRequests.yaml '500': - $ref: ../responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml + $ref: ../responses/5xx/500_InternalServerError.yaml '503': $ref: ../responses/5xx/503_ServiceUnavailable.yaml '504': diff --git a/specification/responses/2xx/200_Responses.yaml b/specification/responses/2xx/200_Responses.yaml index 663267a23..2afbc0f6d 100644 --- a/specification/responses/2xx/200_Responses.yaml +++ b/specification/responses/2xx/200_Responses.yaml @@ -4,7 +4,7 @@ headers: content: application/vnd.api+json: schema: - $ref: ../../schemas/responses/RecipientResponseSnapshot.yaml + $ref: ../../schemas/responses/RecipientResponse.yaml application/json: schema: - $ref: ../../schemas/responses/RecipientResponseSnapshot.yaml + $ref: ../../schemas/responses/RecipientResponse.yaml diff --git a/specification/schemas/enums/EnumRecipientResponse.yaml b/specification/schemas/enums/EnumRecipientResponse.yaml new file mode 100644 index 000000000..1b103f91e --- /dev/null +++ b/specification/schemas/enums/EnumRecipientResponse.yaml @@ -0,0 +1,5 @@ +title: Enum_RecipientResponse +type: string +enum: + - RecipientResponse +example: RecipientResponse diff --git a/specification/schemas/enums/EnumRecipientResponseSnapshot.yaml b/specification/schemas/enums/EnumRecipientResponseSnapshot.yaml deleted file mode 100644 index 93138dfb9..000000000 --- a/specification/schemas/enums/EnumRecipientResponseSnapshot.yaml +++ /dev/null @@ -1,5 +0,0 @@ -title: Enum_RecipientResponseSnapshot -type: string -enum: - - RecipientResponseSnapshot -example: RecipientResponseSnapshot diff --git a/specification/schemas/responses/RecipientResponseSnapshot.yaml b/specification/schemas/responses/RecipientResponse.yaml similarity index 96% rename from specification/schemas/responses/RecipientResponseSnapshot.yaml rename to specification/schemas/responses/RecipientResponse.yaml index 684bdf2d5..39a5a746d 100644 --- a/specification/schemas/responses/RecipientResponseSnapshot.yaml +++ b/specification/schemas/responses/RecipientResponse.yaml @@ -1,4 +1,4 @@ -title: RecipientResponseSnapshot +title: RecipientResponse type: object additionalProperties: false properties: @@ -13,7 +13,7 @@ properties: - attributes properties: type: - $ref: ../enums/EnumRecipientResponseSnapshot.yaml + $ref: ../enums/EnumRecipientResponse.yaml id: type: string format: uuid diff --git a/tests/lib/constants/message_responses_paths.py b/tests/lib/constants/message_responses_paths.py index ad56d9173..f51af526d 100644 --- a/tests/lib/constants/message_responses_paths.py +++ b/tests/lib/constants/message_responses_paths.py @@ -3,7 +3,6 @@ VALID_MESSAGE_ID = "11111111-1111-4111-8111-111111111111" NOT_FOUND_MESSAGE_ID = "00000000-0000-4000-8000-000000000404" BAD_GATEWAY_MESSAGE_ID = "00000000-0000-4000-8000-000000000502" -TOO_MANY_RESPONSES_MESSAGE_ID = "00000000-0000-4000-8000-000000000500" INVALID_MESSAGE_IDS = [ "not-a-uuid", diff --git a/tests/sandbox/message_responses/test_500.py b/tests/sandbox/message_responses/test_500.py deleted file mode 100644 index b8fbffda3..000000000 --- a/tests/sandbox/message_responses/test_500.py +++ /dev/null @@ -1,25 +0,0 @@ -import requests -import pytest -from lib import Assertions, Generators -from lib.constants.message_responses_paths import ( - MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, TOO_MANY_RESPONSES_MESSAGE_ID -) - - -@pytest.mark.sandboxtest -@pytest.mark.parametrize("correlation_id", CORRELATION_IDS) -def test_500_too_many_responses(nhsd_apim_proxy_url, correlation_id): - resp = requests.get( - f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{TOO_MANY_RESPONSES_MESSAGE_ID}", - headers={ - "X-Correlation-Id": correlation_id, - "Accept": "application/json" - } - ) - - Assertions.assert_error_with_optional_correlation_id( - resp, - 500, - Generators.generate_internal_server_error(), - correlation_id - ) From 38b36ca21fdd8b02e1f7f154d47cbc7db901b208 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 26 Aug 2026 08:41:37 +0100 Subject: [PATCH 12/16] CCM-22090: PR feedback --- docs/proxies.md | 31 +++++++++++++++++++ ...tial.Flows.GetMessageResponsesEndpoint.xml | 9 ++++++ ...vaScript.MessageResponses.Get.Validate.xml | 11 +++++++ .../jsc/MessageResponses.Get.Validate.js | 13 ++++++++ sandbox/__test__/message_responses.spec.js | 31 ++++++------------- sandbox/handlers/message_responses.js | 19 ------------ .../callbacks/recipient_response.yaml | 2 +- ...se.yaml => RecipientResponseCallback.yaml} | 0 8 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml create mode 100644 proxies/shared/resources/jsc/MessageResponses.Get.Validate.js rename specification/schemas/components/{RecipientResponse.yaml => RecipientResponseCallback.yaml} (100%) diff --git a/docs/proxies.md b/docs/proxies.md index 205b41ff7..85e18c918 100644 --- a/docs/proxies.md +++ b/docs/proxies.md @@ -421,6 +421,37 @@ flowchart MRESP --> E ``` +### Get Message Responses + +This flow maps get message responses requests to the app-response service and maps the response to the public API format. + +Source: [proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml](https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml) + +```mermaid +flowchart + S[Start] --> Q1{Matches get message responses endpoint?} + Q1 --> |No| E[End] + Q1 --> |Yes| EV[Extract messageId from request + + ExtractVariables.MessageResponses.Get.Request] + EV --> V[Validate messageId + + JavaScript.MessageResponses.Get.Validate] + V --> Q2{Validation errors found?} + Q2 --> |Yes| 400[Raise 400 error + + RaiseFault.4xxGeneric] + 400 --> E + Q2 --> |No| MR[Create app-response request + + AssignMessage.MessageResponses.Get.Request] + MR --> SEND[Send request to app-response service] + SEND --> RESP[Convert response + + AssignMessage.MessageResponses.Get.Response] + RESP --> E +``` + ### Target Post Flow This flow runs on all outgoing responses from the target. diff --git a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml index bc1a24275..1bf2f3073 100644 --- a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml +++ b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml @@ -4,6 +4,15 @@ ExtractVariables.MessageResponses.Get.Request + {% if ENVIRONMENT_TYPE != 'sandbox' %} + + JavaScript.MessageResponses.Get.Validate + + + RaiseFault.4xxGeneric + errors != null + + {% endif %} AssignMessage.MessageResponses.Get.Request diff --git a/proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml b/proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml new file mode 100644 index 000000000..878bd2fa5 --- /dev/null +++ b/proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml @@ -0,0 +1,11 @@ + + + + JavaScript.MessageResponses.Get.Validate + + jsc://helpers/validationErrors.js + jsc://helpers/validationChecks.js + jsc://MessageResponses.Get.Validate.js + diff --git a/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js b/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js new file mode 100644 index 000000000..3bd86f46b --- /dev/null +++ b/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js @@ -0,0 +1,13 @@ +/* global context, validateUuid */ + +const errors = [] + +validateUuid(errors, context.getVariable("data.messageId"), "/messageId"); + +if (errors.length > 0) { + context.setVariable("generic_status_code", errors[0].status); + context.setVariable("errors", JSON.stringify(errors)); +} else { + context.setVariable("generic_status_code", null); + context.setVariable("errors", null); +} diff --git a/sandbox/__test__/message_responses.spec.js b/sandbox/__test__/message_responses.spec.js index fa7f9bff6..680696ebb 100644 --- a/sandbox/__test__/message_responses.spec.js +++ b/sandbox/__test__/message_responses.spec.js @@ -4,7 +4,7 @@ import { setup } from './helpers.js' const VALID_MESSAGE_ID = '11111111-1111-4111-8111-111111111111'; const NOT_FOUND_MESSAGE_ID = '00000000-0000-4000-8000-000000000404'; -const NON_V4_MESSAGE_ID = '11111111-1111-1111-8111-111111111111'; +const UUID_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8]; describe('/api/v1/message-responses/:messageId', () => { let env; @@ -39,28 +39,15 @@ describe('/api/v1/message-responses/:messageId', () => { .expect('Content-Type', /json/, done); }); - it('returns a 400 when messageId is not a UUID', (done) => { - request(server) - .get('/api/v1/message-responses/not-a-valid-uuid') - .expect(400, { - errors: [ - { - code: 'CM_INVALID_REQUEST', - status: '400', - title: 'Invalid Request', - detail: 'The messageId path parameter is not a valid UUID.', - source: { parameter: 'messageId' } - } - ] - }) - .expect('Content-Type', /json/, done); - }); + UUID_VERSIONS.forEach((version) => { + it(`returns a 200 for a valid UUID v${version} messageId`, (done) => { + const messageId = `11111111-1111-${version}111-8111-111111111111`; - it('returns a 200 for a non-v4 UUID messageId', (done) => { - request(server) - .get(`/api/v1/message-responses/${NON_V4_MESSAGE_ID}`) - .expect(200) - .expect('Content-Type', /json/, done); + request(server) + .get(`/api/v1/message-responses/${messageId}`) + .expect(200) + .expect('Content-Type', /json/, done); + }); }); it('returns a 404 when no responses are found', (done) => { diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js index 4fe8aefdc..006b88aaf 100644 --- a/sandbox/handlers/message_responses.js +++ b/sandbox/handlers/message_responses.js @@ -1,8 +1,5 @@ import { sendError } from './utils.js' -// Mirrors the RFC 9562 UUID validation used by the app-response bounded context (versions 1-8, plus the nil/max UUIDs). -const uuidRegex = /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; - const notFoundMessageId = '00000000-0000-4000-8000-000000000404'; const supportedContentTypes = ['application/json', 'application/vnd.api+json']; @@ -36,22 +33,6 @@ export async function messageResponses(req, res, next) { const { messageId } = req.params; - if (!uuidRegex.test(messageId)) { - res.status(400).json({ - errors: [ - { - code: 'CM_INVALID_REQUEST', - status: '400', - title: 'Invalid Request', - detail: 'The messageId path parameter is not a valid UUID.', - source: { parameter: 'messageId' } - } - ] - }); - next(); - return; - } - if (messageId === notFoundMessageId) { res.status(404).json({ errors: [ diff --git a/specification/callbacks/recipient_response.yaml b/specification/callbacks/recipient_response.yaml index da8517845..673626d92 100644 --- a/specification/callbacks/recipient_response.yaml +++ b/specification/callbacks/recipient_response.yaml @@ -29,7 +29,7 @@ requestBody: type: array minItems: 1 items: - $ref: ../schemas/components/RecipientResponse.yaml + $ref: ../schemas/components/RecipientResponseCallback.yaml responses: '202': description: Accepted diff --git a/specification/schemas/components/RecipientResponse.yaml b/specification/schemas/components/RecipientResponseCallback.yaml similarity index 100% rename from specification/schemas/components/RecipientResponse.yaml rename to specification/schemas/components/RecipientResponseCallback.yaml From eb7e0bcfef5c26717b130b04e15088c0c1a1045f Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 26 Aug 2026 08:53:38 +0100 Subject: [PATCH 13/16] CCM-22090: Added message responses to security tests --- tests/lib/constants/constants.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/lib/constants/constants.py b/tests/lib/constants/constants.py index a40b01518..f1e24eb35 100644 --- a/tests/lib/constants/constants.py +++ b/tests/lib/constants/constants.py @@ -1,6 +1,12 @@ INT_URL = "https://int.api.service.nhs.uk/comms" PROD_URL = "https://api.service.nhs.uk/comms" -VALID_ENDPOINTS = ["/v1/message-batches", "/v1/messages", "/v1/api/send", "/v1/messages/1234"] +VALID_ENDPOINTS = [ + "/v1/message-batches", + "/v1/messages", + "/v1/api/send", + "/v1/messages/1234", + "/v1/message-responses/11111111-1111-4111-8111-111111111111", +] CORS_METHODS = "GET, PUT, POST, PATCH, DELETE" CORS_MAX_AGE = "3628800" CORS_ALLOW_HEADERS = "origin, x-requested-with, accept, " \ From 7547d562f0b1c87288bee27f2a1f1312bb8d2984 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 26 Aug 2026 10:36:47 +0100 Subject: [PATCH 14/16] CCM-22090: Reverted change --- ...Partial.Flows.GetMessageResponsesEndpoint.xml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml index 1bf2f3073..f770aad5b 100644 --- a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml +++ b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml @@ -4,15 +4,13 @@ ExtractVariables.MessageResponses.Get.Request - {% if ENVIRONMENT_TYPE != 'sandbox' %} - - JavaScript.MessageResponses.Get.Validate - - - RaiseFault.4xxGeneric - errors != null - - {% endif %} + + JavaScript.MessageResponses.Get.Validate + + + RaiseFault.4xxGeneric + errors != null + AssignMessage.MessageResponses.Get.Request From d6a9b7edb4233a0c874840c176dd340583b7da04 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 26 Aug 2026 11:44:57 +0100 Subject: [PATCH 15/16] CCM-22090: PR feedback --- .../jsc/MessageResponses.Get.Validate.js | 4 +-- .../resources/jsc/helpers/validationChecks.js | 11 ++++++++ .../resources/jsc/helpers/validationErrors.js | 16 +++++++++++ tests/api/message_responses/test_400.py | 27 +++++++++++++++++++ tests/lib/constants/constants.py | 7 +++++ 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/api/message_responses/test_400.py diff --git a/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js b/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js index 3bd86f46b..1271966a2 100644 --- a/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js +++ b/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js @@ -1,8 +1,8 @@ -/* global context, validateUuid */ +/* global context, validateUuidParameter */ const errors = [] -validateUuid(errors, context.getVariable("data.messageId"), "/messageId"); +validateUuidParameter(errors, context.getVariable("data.messageId"), "messageId"); if (errors.length > 0) { context.setVariable("generic_status_code", errors[0].status); diff --git a/proxies/shared/resources/jsc/helpers/validationChecks.js b/proxies/shared/resources/jsc/helpers/validationChecks.js index 303085aee..45bbaca75 100644 --- a/proxies/shared/resources/jsc/helpers/validationChecks.js +++ b/proxies/shared/resources/jsc/helpers/validationChecks.js @@ -57,6 +57,17 @@ const validateUuid = (errors, fieldValue, fieldPointer) => { return true } +const validateUuidParameter = (errors, fieldValue, parameter) => { + if (typeof fieldValue !== "string" || !uuidRegex.test(fieldValue)) { + errors.push(invalidParameterError( + parameter, + "The messageId path parameter is not a valid UUID." + )); + return false + } + return true +} + const validateConstantString = (errors, fieldValue, fieldPointer, requiredValue) => { if (isUndefined(fieldValue)) { errors.push(missingError(fieldPointer)); diff --git a/proxies/shared/resources/jsc/helpers/validationErrors.js b/proxies/shared/resources/jsc/helpers/validationErrors.js index 8f5944600..f8610bae2 100644 --- a/proxies/shared/resources/jsc/helpers/validationErrors.js +++ b/proxies/shared/resources/jsc/helpers/validationErrors.js @@ -45,6 +45,22 @@ function invalidError(pointer) { ); } +function invalidParameterError(parameter, detail) { + return { + "id": messageId + "." + errors.length, + "code": "CM_INVALID_REQUEST", + "links": { + "about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify" + }, + "status": "400", + "title": "Invalid Request", + "detail": detail, + "source": { + "parameter": parameter + } + }; +} + function duplicateError(pointer) { return createErrorObject( "CM_DUPLICATE_VALUE", diff --git a/tests/api/message_responses/test_400.py b/tests/api/message_responses/test_400.py new file mode 100644 index 000000000..288038557 --- /dev/null +++ b/tests/api/message_responses/test_400.py @@ -0,0 +1,27 @@ +import requests +import pytest +from lib import Assertions, Generators +import lib.constants.constants as constants +from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, INVALID_MESSAGE_IDS +from lib.fixtures import * # NOSONAR + + +@pytest.mark.devtest +@pytest.mark.parametrize("message_id", INVALID_MESSAGE_IDS) +def test_400_invalid_message_id(url, bearer_token, message_id): + headers = Generators.generate_valid_headers(bearer_token.value) + + resp = requests.get( + f"{url}{MESSAGE_RESPONSES_ENDPOINT}/{message_id}", + headers=headers + ) + + Assertions.assert_error_with_optional_correlation_id( + resp, + 400, + Generators.generate_error( + constants.ERROR_MESSAGE_RESPONSES_INVALID_MESSAGE_ID, + source={"parameter": "messageId"} + ), + None + ) diff --git a/tests/lib/constants/constants.py b/tests/lib/constants/constants.py index f1e24eb35..bef22258e 100644 --- a/tests/lib/constants/constants.py +++ b/tests/lib/constants/constants.py @@ -319,6 +319,13 @@ def __init__(self, code, status, title, detail, links={}): "" ) +ERROR_MESSAGE_RESPONSES_INVALID_MESSAGE_ID = Error( + "CM_INVALID_REQUEST", + "400", + "Invalid Request", + "The messageId path parameter is not a valid UUID." +) + INVALID_REQUEST_TITLE = "Invalid Request" # NHS App Accounts errors From c71aca7dd0fdfc3e3e77d321c5f79a171f39f5d9 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Thu, 3 Sep 2026 11:06:41 +0100 Subject: [PATCH 16/16] CCM-22090: PR feedback --- ...tial.Flows.GetMessageResponsesEndpoint.xml | 5 ++++ .../api/message_responses/test_200_success.py | 16 +++++++++-- tests/api/message_responses/test_400.py | 7 +++++ tests/api/message_responses/test_404.py | 7 +++++ .../sandbox/message_responses/test_success.py | 27 ++++++++++--------- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml index f770aad5b..42b9ef738 100644 --- a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml +++ b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml @@ -1,6 +1,11 @@ Handle get message responses + + + RaiseFault.404NotFound + environment.name = "ref" + ExtractVariables.MessageResponses.Get.Request diff --git a/tests/api/message_responses/test_200_success.py b/tests/api/message_responses/test_200_success.py index 335311829..f27e1b4e9 100644 --- a/tests/api/message_responses/test_200_success.py +++ b/tests/api/message_responses/test_200_success.py @@ -1,9 +1,16 @@ +import os import requests import pytest from lib import Assertions, Generators from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, VALID_MESSAGE_ID from lib.fixtures import * # NOSONAR +# ref has no app-response backend; the endpoint is deliberately disabled there +pytestmark = pytest.mark.skipif( + os.environ.get("API_ENVIRONMENT") == "ref", + reason="message-responses endpoint is not available in ref" +) + @pytest.mark.devtest def test_200_success(url, bearer_token): @@ -16,5 +23,10 @@ def test_200_success(url, bearer_token): assert resp.status_code == 200, f"Response: {resp.status_code}: {resp.text}" body = resp.json() - assert "messageId" in body - assert isinstance(body.get("responses"), list) + assert isinstance(body.get("data"), list) + assert len(body["data"]) > 0 + + first = body["data"][0] + assert first["type"] == "RecipientResponse" + assert "id" in first + assert first["attributes"]["messageId"] == VALID_MESSAGE_ID diff --git a/tests/api/message_responses/test_400.py b/tests/api/message_responses/test_400.py index 288038557..e997f6643 100644 --- a/tests/api/message_responses/test_400.py +++ b/tests/api/message_responses/test_400.py @@ -1,3 +1,4 @@ +import os import requests import pytest from lib import Assertions, Generators @@ -5,6 +6,12 @@ from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, INVALID_MESSAGE_IDS from lib.fixtures import * # NOSONAR +# ref has no app-response backend; the endpoint is deliberately disabled there +pytestmark = pytest.mark.skipif( + os.environ.get("API_ENVIRONMENT") == "ref", + reason="message-responses endpoint is not available in ref" +) + @pytest.mark.devtest @pytest.mark.parametrize("message_id", INVALID_MESSAGE_IDS) diff --git a/tests/api/message_responses/test_404.py b/tests/api/message_responses/test_404.py index bf9683640..d11919e7f 100644 --- a/tests/api/message_responses/test_404.py +++ b/tests/api/message_responses/test_404.py @@ -1,9 +1,16 @@ +import os import requests import pytest from lib import Assertions, Generators from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, NOT_FOUND_MESSAGE_ID from lib.fixtures import * # NOSONAR +# ref has no app-response backend; the endpoint is deliberately disabled there +pytestmark = pytest.mark.skipif( + os.environ.get("API_ENVIRONMENT") == "ref", + reason="message-responses endpoint is not available in ref" +) + @pytest.mark.devtest def test_404_not_found(url, bearer_token): diff --git a/tests/sandbox/message_responses/test_success.py b/tests/sandbox/message_responses/test_success.py index 01adef8a2..fa37e1c25 100644 --- a/tests/sandbox/message_responses/test_success.py +++ b/tests/sandbox/message_responses/test_success.py @@ -17,18 +17,21 @@ def test_200_success(nhsd_apim_proxy_url, correlation_id): assert resp.status_code == 200, f"Response: {resp.status_code}: {resp.text}" body = resp.json() - assert isinstance(body, list) - assert len(body) > 0 + assert isinstance(body.get("data"), list) + assert len(body["data"]) > 0 - first = body[0] - assert "responseId" in first - assert first["messageId"] == VALID_MESSAGE_ID - assert "messageReference" in first - assert "code" in first - assert "channel" in first - assert "channelStatus" in first - assert "cascadeType" in first - assert "authoredAt" in first - assert "timestamp" in first + first = body["data"][0] + assert first["type"] == "RecipientResponse" + assert "id" in first + + attributes = first["attributes"] + assert attributes["messageId"] == VALID_MESSAGE_ID + assert "messageReference" in attributes + assert "code" in attributes + assert "channel" in attributes + assert "channelStatus" in attributes + assert "cascadeType" in attributes + assert "authoredAt" in attributes + assert "timestamp" in attributes Assertions.assert_correlation_id(resp.headers.get("X-Correlation-Id"), correlation_id)