Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b813a53
CCM-22090: Recipient response message retrieval API
rhyscoxnhs Aug 4, 2026
02e52a3
CCM-22090: Fixed lint
rhyscoxnhs Aug 4, 2026
01f518d
CCM-22090: Added documentation
rhyscoxnhs Aug 5, 2026
985db0b
CCM-22090: Revised documentation
rhyscoxnhs Aug 10, 2026
3e55a1b
CCM-22090: PR feedback
rhyscoxnhs Aug 12, 2026
a68c892
CCM-22090: PR feedback
rhyscoxnhs Aug 14, 2026
746ebe4
CCM-22090: Migrate app response API to JSON:API format
rhyscoxnhs Aug 20, 2026
52e06fd
CCM-22090: PR feedback
rhyscoxnhs Aug 24, 2026
8daff3d
CCM-22090: Reverted shared file
rhyscoxnhs Aug 24, 2026
87b35ac
CCM-22090: PR feedback
rhyscoxnhs Aug 24, 2026
2c4a7d5
CCM-22090: PR feedback
rhyscoxnhs Aug 25, 2026
38b36ca
CCM-22090: PR feedback
rhyscoxnhs Aug 26, 2026
eb7e0bc
CCM-22090: Added message responses to security tests
rhyscoxnhs Aug 26, 2026
7547d56
CCM-22090: Reverted change
rhyscoxnhs Aug 26, 2026
d6a9b7e
CCM-22090: PR feedback
rhyscoxnhs Aug 26, 2026
718b5eb
CCM-22090: PR feedback.
rhyscoxnhs Sep 3, 2026
f8067c1
CCM-22090: Test fixes
rhyscoxnhs Sep 15, 2026
778b206
CCM-22090: Bug fix for 404 response
rhyscoxnhs Sep 15, 2026
6363af8
CCM-22090: Test fix
rhyscoxnhs Sep 15, 2026
f34f812
CCM-22090: Ensure app response tests only run in supported environments
rhyscoxnhs Sep 15, 2026
0857509
CCM-22090: PR feedback
rhyscoxnhs Sep 16, 2026
48c9471
CCM-22090: PR feedback
rhyscoxnhs Sep 18, 2026
0fd0ef7
CCM-22090: Fixed test
rhyscoxnhs Sep 18, 2026
a166f17
CCM-22090: PR feedback
rhyscoxnhs Sep 21, 2026
c61c33a
Merge pull request #1090 from NHSDigital/feature/CCM-22090
rhyscoxnhs Sep 21, 2026
3cb5780
+minor
nhsd-angel-pastor Sep 24, 2026
ad8f9a1
Merge pull request #1098 from NHSDigital/v4.76.0
nhsd-angel-pastor Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/proxies.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,37 @@ flowchart
MRESP --> E
```

### Get Message Responses

This flow maps get message responses requests to the app-response service and maps the response to the public API format.

Source: [proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml](https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml)

```mermaid
flowchart
S[Start] --> Q1{Matches get message responses endpoint?}
Q1 --> |No| E[End]
Q1 --> |Yes| EV[Extract messageId from request

<em><a href='https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml'>ExtractVariables.MessageResponses.Get.Request</a></em>]
EV --> V[Validate messageId

<em><a href='https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml'>JavaScript.MessageResponses.Get.Validate</a></em>]
V --> Q2{Validation errors found?}
Q2 --> |Yes| 400[Raise 400 error

<em><a href='https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/policies/RaiseFault.4xxGeneric.xml'>RaiseFault.4xxGeneric</a></em>]
400 --> E
Q2 --> |No| MR[Create app-response request

<em><a href='https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml'>AssignMessage.MessageResponses.Get.Request</a></em>]
MR --> SEND[Send request to app-response service]
SEND --> RESP[Convert response

<em><a href='https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml'>AssignMessage.MessageResponses.Get.Response</a></em>]
RESP --> E
```

### Target Post Flow

This flow runs on all outgoing responses from the target.
Expand Down
4 changes: 4 additions & 0 deletions proxies/live/apiproxy/proxies/default.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<VirtualHost>secure</VirtualHost>
</HTTPProxyConnection>
[% include './partials/Partial.Proxy.Routes.xml' %]
<RouteRule name="app-response-target">
<Condition>(proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and (request.verb = "GET")</Condition>
<TargetEndpoint>app-response-target</TargetEndpoint>
</RouteRule>
<RouteRule name="communications-manager-target">
<TargetEndpoint>communications-manager-target</TargetEndpoint>
</RouteRule>
Expand Down
26 changes: 26 additions & 0 deletions proxies/live/apiproxy/targets/app-response-target.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<TargetEndpoint name="app-response-target">
<PreFlow>
<Request>
[% include './partials/Partial.Target.PreFlowRequest.xml' %]
</Request>
</PreFlow>
<Flows>
[% include './partials/Partial.Flows.GetMessageResponsesEndpoint.xml' %]
</Flows>
[% include './partials/Partial.Target.PostFlow.xml' %]
<FaultRules>
[% include './partials/Partial.Target.FaultRules.xml' %]
</FaultRules>
<HTTPTargetConnection>
<SSLInfo>
<Enabled>true</Enabled>
</SSLInfo>
<LoadBalancer>
<Server name="app-response-target"/>
</LoadBalancer>
<Path>{requestpath}</Path>
<Properties>
<Property name="io.timeout.millis">29000</Property>
</Properties>
</HTTPTargetConnection>
</TargetEndpoint>
1 change: 1 addition & 0 deletions proxies/sandbox/apiproxy/targets/sandbox.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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' %]
</Flows>
[% include './partials/Partial.Target.PostFlow.xml' %]
<FaultRules>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<Flow name="GetMessageResponsesEndpoint">
<Description>Handle get message responses</Description>
<Request>
{% if ENVIRONMENT_TYPE != 'sandbox' %}
<!-- app-response has no ref-tier backend; fail fast instead of routing to an undefined TargetServer -->
<Step>
<Name>RaiseFault.404NotFound</Name>
<Condition>environment.name = "ref"</Condition>
</Step>
{% endif %}
<Step>
<Name>ExtractVariables.MessageResponses.Get.Request</Name>
</Step>
<Step>
<Name>JavaScript.MessageResponses.Get.Validate</Name>
</Step>
<Step>
<Name>RaiseFault.4xxGeneric</Name>
<Condition>errors != null</Condition>
</Step>
<Step>
<Name>AssignMessage.MessageResponses.Get.Request</Name>
</Step>
{% if ENVIRONMENT_TYPE != 'sandbox' %}
<Step>
<Name>AssignMessage.AuthenticationDetails</Name>
</Step>
{% endif %}
</Request>
<Response>
<Step>
<Name>AssignMessage.MessageResponses.Get.Response</Name>
</Step>
</Response>
<Condition>
(proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and (request.verb = "GET")
</Condition>
</Flow>
5 changes: 5 additions & 0 deletions proxies/shared/partials/Partial.Target.FaultRules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
response.status.code = 404 and response.content Like "*Routing Config does not exist for clientId *" and response.content Like "* and routingPlanId *"
</Condition>
</Step>
<Step>
<!-- app-response returns JSON:API errors that EnhanceErrorDetails cannot map; standardise directly -->
<Name>RaiseFault.404NotFound</Name>
<Condition>(proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and response.status.code = 404</Condition>
</Step>
<Step>
<Name>RaiseFault.404NotFound</Name>
<Condition>response.status.code = 404 and data.errors == null</Condition>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Sets the backend request path and forwards correlation ID.
-->
<AssignMessage async="false" continueOnError="false" enabled="true" name="AssignMessage.MessageResponses.Get.Request">
<DisplayName>AssignMessage.MessageResponses.Get.Request</DisplayName>
<Properties/>
<AssignTo createNew="false" transport="http" type="request"/>
<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
<AssignVariable>
<Name>target.copy.pathsuffix</Name>
<Value>false</Value>
</AssignVariable>
<AssignVariable>
<Name>requestpath</Name>
<Template>/api/response/{data.messageId}</Template>
</AssignVariable>
<Set>
<Headers>
<Header name="X-Correlation-Id">{backendCorrelationId}</Header>
</Headers>
<Verb>GET</Verb>
</Set>
</AssignMessage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Passthrough response policy — no body transformation required for this endpoint.
-->
<AssignMessage async="false" continueOnError="false" enabled="true" name="AssignMessage.MessageResponses.Get.Response">
<DisplayName>AssignMessage.MessageResponses.Get.Response</DisplayName>
<Properties/>
<AssignTo createNew="false" transport="http" type="response"/>
<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
</AssignMessage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Extracts the messageId from the URI path into data.* flow variables.
-->
<ExtractVariables async="false" continueOnError="false" enabled="true" name="ExtractVariables.MessageResponses.Get.Request">
<VariablePrefix>data</VariablePrefix>
<Source>request</Source>
<URIPath>
<Pattern ignoreCase="true">/v1/message-responses/{messageId}</Pattern>
</URIPath>
<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
</ExtractVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Validates the messageId extracted from the get message responses request.
-->
<Javascript async="false" continueOnError="false" enabled="true" timeLimit="5000" name="JavaScript.MessageResponses.Get.Validate">
<DisplayName>JavaScript.MessageResponses.Get.Validate</DisplayName>
<Properties/>
<IncludeURL>jsc://helpers/validationErrors.js</IncludeURL>
<IncludeURL>jsc://helpers/validationChecks.js</IncludeURL>
<ResourceURL>jsc://MessageResponses.Get.Validate.js</ResourceURL>
</Javascript>
13 changes: 13 additions & 0 deletions proxies/shared/resources/jsc/MessageResponses.Get.Validate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* global context, validateUuidMessageId */

const errors = []

validateUuidMessageId(errors, context.getVariable("data.messageId"), "messageId");

if (errors.length > 0) {
context.setVariable("generic_status_code", errors[0].status);
context.setVariable("errors", JSON.stringify(errors));
} else {
context.setVariable("generic_status_code", null);
context.setVariable("errors", null);
}
4 changes: 4 additions & 0 deletions proxies/shared/resources/jsc/Routing.CheckValid.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const validPaths = [
{
match: /^\/channels\/nhsapp\/accounts$/,
methods: ['get']
},
{
match: /^\/v1\/message-responses\/.*$/,
methods: ['get']
}
];

Expand Down
11 changes: 11 additions & 0 deletions proxies/shared/resources/jsc/helpers/validationChecks.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ const validateUuid = (errors, fieldValue, fieldPointer) => {
return true
}

const validateUuidMessageId = (errors, fieldValue, parameter) => {
if (typeof fieldValue !== "string" || !uuidRegex.test(fieldValue)) {
errors.push(invalidParameterError(
parameter,
"The messageId path parameter is not a valid UUID."
));
return false
}
return true
}

const validateConstantString = (errors, fieldValue, fieldPointer, requiredValue) => {
if (isUndefined(fieldValue)) {
errors.push(missingError(fieldPointer));
Expand Down
16 changes: 16 additions & 0 deletions proxies/shared/resources/jsc/helpers/validationErrors.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ function invalidError(pointer) {
);
}

function invalidParameterError(parameter, detail) {
return {
"id": messageId + "." + errors.length,
"code": "CM_INVALID_REQUEST",
"links": {
"about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify"
},
"status": "400",
"title": "Invalid Request",
"detail": detail,
"source": {
"parameter": parameter
}
};
}

function duplicateError(pointer) {
return createErrorObject(
"CM_DUPLICATE_VALUE",
Expand Down
129 changes: 129 additions & 0 deletions sandbox/__test__/message_responses.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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 UUID_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8];

describe('/api/response/: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/response/${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/response/${VALID_MESSAGE_ID}`)
.set({ Authorization: 'banned' })
.expect(403, {
error: 'Forbidden'
})
.expect('Content-Type', /json/, done);
});

UUID_VERSIONS.forEach((version) => {
it(`returns a 200 for a valid UUID v${version} messageId`, (done) => {
const messageId = `11111111-1111-${version}111-8111-111111111111`;

request(server)
.get(`/api/response/${messageId}`)
.expect(200)
.expect('Content-Type', /json/, done);
});
});

it('returns a 404 when no responses are found', (done) => {
request(server)
.get(`/api/response/${NOT_FOUND_MESSAGE_ID}`)
.expect(404, {
errors: [
{
code: 'CM_NOT_FOUND',
status: '404',
title: 'Resource not found',
detail: 'The resource at the requested URI was not found.'
}
]
})
.expect('Content-Type', /json/, done);
});

it('returns a 502 when the downstream service is not responding', (done) => {
request(server)
.get(`/api/response/${BAD_GATEWAY_MESSAGE_ID}`)
.expect(502, {
message: 'Bad Gateway'
})
.expect('Content-Type', /json/, done);
});

it('returns a 415 when the content type is not supported', (done) => {
request(server)
.get(`/api/response/${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/response/${VALID_MESSAGE_ID}`)
.set('Prefer', 'code=429')
.expect(429, {
errors: [
{
code: 'CM_QUOTA',
status: '429',
title: 'Too many requests',
detail: 'You have made too many requests. Re-send the request after the time (in seconds) specified `Retry-After` header.'
}
]
})
.expect('Content-Type', /json/, done);
});

it('returns a 200 with correct response structure for a valid messageId', (done) => {
request(server)
.get(`/api/response/${VALID_MESSAGE_ID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
const { body } = res;
if (!Array.isArray(body.data)) throw new Error('response must contain a data array');
const first = body.data[0];
if (!first.id) throw new Error('missing id');
if (first.type !== 'RecipientResponse') throw new Error('incorrect type');
const { attributes } = first;
if (attributes.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId');
if (!attributes.messageReference) throw new Error('missing messageReference');
if (!attributes.code) throw new Error('missing code');
if (!attributes.channel) throw new Error('missing channel');
if (!attributes.channelStatus) throw new Error('missing channelStatus');
if (!attributes.cascadeType) throw new Error('missing cascadeType');
if (!attributes.authoredAt) throw new Error('missing authoredAt');
if (!attributes.timestamp) throw new Error('missing timestamp');
})
.end(done);
});
});
Loading
Loading