From b813a53f72fd1f080adf441cedb3d6e37d29018a Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Tue, 4 Aug 2026 10:28:08 +0100 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)