From 912f01ea7a947898fc15d0b14ce8248721d3317d Mon Sep 17 00:00:00 2001 From: David Wass Date: Wed, 13 Aug 2025 08:29:28 +0000 Subject: [PATCH] further sandbox examples including handling request body --- .gitignore | 1 + Makefile | 2 +- package.json | 2 +- sandbox/api/openapi.yaml | 50 ++++++++++++- sandbox/controllers/Controller.js | 4 +- .../responses/getLetters_pending-10.json | 72 ++++++++++++++++++ .../responses/getLetters_pending-5.json | 73 ++++++++++++++++++ .../responses/getLetters_pending.json | 63 +++++++++++++++- sandbox/expressServer.js | 24 +++++- sandbox/package-lock.json | 4 +- sandbox/services/LetterService.js | 12 +-- sandbox/utils/ResponseProvider.js | 74 ++++++++++++++++--- .../api/components/endpoints/listLetters.yml | 1 + .../api/components/parameters/pageNumber.yml | 9 +++ .../components/schemas/letterUpdateItem.yml | 4 + .../schemas/lettersListResponse.yml | 25 +++++++ 16 files changed, 392 insertions(+), 28 deletions(-) create mode 100644 sandbox/data/examples/getLetters/responses/getLetters_pending-10.json create mode 100644 sandbox/data/examples/getLetters/responses/getLetters_pending-5.json create mode 100644 specification/api/components/parameters/pageNumber.yml diff --git a/.gitignore b/.gitignore index 208321e1..1c38215d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist .DS_Store .reports /sandbox/*.log +/sandbox-staging diff --git a/Makefile b/Makefile index b259d767..b190f7b5 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ bundle-oas: generate-sandbox: $(MAKE) build-json-oas-spec APIM_ENV=sandbox - jq --slurpfile status sandbox/HealthcheckEndpoint.json '.paths += $status[0]' build/notify-supplier.json > tmp.json && mv tmp.json build/notify-supplier.json + jq --slurpfile status sandbox/HealthcheckEndpoint.json '.paths += $$status[0]' build/notify-supplier.json > tmp.json && mv tmp.json build/notify-supplier.json npm run generate-sandbox serve-swagger: diff --git a/package.json b/package.json index 3f03b3c0..b8e25d0b 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "bundle-oas": "mkdir -p build && redocly bundle specification/api/notify-supplier-phase1.yml --dereferenced -k --remove-unused-components --ext yml > build/notify-supplier.yml", "generate": "npm run generate:cs --buildver=$npm_config_buildver && npm run generate:html && npm run generate:ts --buildver=$npm_config_buildver && npm run generate:python", "generate-dependencies": "npm run generate-dependencies --workspaces --if-present", - "generate-sandbox": "openapi-generator-cli generate -g nodejs-express-server -i build/notify-supplier.yml --skip-validate-spec -o sandbox", + "generate-sandbox": "openapi-generator-cli generate -g nodejs-express-server -i build/notify-supplier.json --skip-validate-spec -o sandbox-staging", "generate:cs": "./sdk/generate-cs.sh $npm_config_buildver", "generate:cs-server": "./server/generate-cs-server.sh $npm_config_buildver", "generate:html": "docker run --rm --user $(id -u) -v ${PWD}:/local openapitools/openapi-generator-cli generate -i /local/build/notify-supplier.yml -g html -o /local/sdk/html --skip-validate-spec", diff --git a/sandbox/api/openapi.yaml b/sandbox/api/openapi.yaml index aaea909d..d09ecfb6 100644 --- a/sandbox/api/openapi.yaml +++ b/sandbox/api/openapi.yaml @@ -69,6 +69,18 @@ paths: - FORWARDED type: string style: form + - description: |- + The ordinal number of the page of results to be retrieved. If omitted, the + first page of results will be returned. Use the links section in the response + body to determine whether any further pages of results exist. + explode: true + in: query + name: page + required: false + schema: + example: 1 + type: number + style: form responses: "200": content: @@ -197,7 +209,7 @@ paths: id: 2WL5eYSWGzCHlGmzNxuqVusPxDg type: Letter schema: - $ref: "#/components/schemas/listLetters_200_response" + $ref: "#/components/schemas/postLetters_200_response" description: Letter Resources Updated successfully "404": content: @@ -972,6 +984,38 @@ paths: x-eov-operation-handler: controllers/DefaultController components: schemas: + listLetters_200_response_links: + additionalProperties: false + description: Contains links to other data pages + properties: + last: + description: URI of final page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=10 + format: uri + type: string + next: + description: URI of next page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=5 + format: uri + type: string + prev: + description: URI of prev page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=7 + format: uri + type: string + self: + description: URI of current page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=6 + format: uri + type: string + type: object + postLetters_200_response: + properties: + data: + items: + $ref: "#/components/schemas/listLetters_200_response_data_inner" + type: array + type: object createMI_request_data: properties: type: @@ -1086,6 +1130,8 @@ components: items: $ref: "#/components/schemas/listLetters_200_response_data_inner" type: array + links: + $ref: "#/components/schemas/listLetters_200_response_links" type: object getLetterStatus_200_response: properties: @@ -1094,6 +1140,8 @@ components: type: object postLetters_request_data_inner_attributes: properties: + specificationId: + type: string status: default: PENDING description: The supplier status of an individual letter diff --git a/sandbox/controllers/Controller.js b/sandbox/controllers/Controller.js index 48b8bb9e..f431b911 100644 --- a/sandbox/controllers/Controller.js +++ b/sandbox/controllers/Controller.js @@ -61,7 +61,7 @@ class Controller { if (codeGenDefinedBodyName !== undefined) { return codeGenDefinedBodyName; } - const refObjectPath = request.openapi.schema.requestBody.content['application/json'].schema.$ref; + const refObjectPath = request.openapi.schema.requestBody.content['application/vnd.api+json'].schema.$ref; if (refObjectPath !== undefined && refObjectPath.length > 0) { return (refObjectPath.substr(refObjectPath.lastIndexOf('/') + 1)); } @@ -72,7 +72,7 @@ class Controller { const requestParams = {}; if (request.openapi.schema.requestBody !== null) { const { content } = request.openapi.schema.requestBody; - if (content['application/json'] !== undefined) { + if (content['application/vnd.api+json'] !== undefined) { const requestBodyName = camelCase(this.getRequestBodyName(request)); requestParams[requestBodyName] = request.body; } else if (content['multipart/form-data'] !== undefined) { diff --git a/sandbox/data/examples/getLetters/responses/getLetters_pending-10.json b/sandbox/data/examples/getLetters/responses/getLetters_pending-10.json new file mode 100644 index 00000000..5044e297 --- /dev/null +++ b/sandbox/data/examples/getLetters/responses/getLetters_pending-10.json @@ -0,0 +1,72 @@ +{ + "data": [ + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68ctLKWlhxsoil13zANnOXDs", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68hADL5mBayE3gDOnQeQ7RGr", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68hesrGUpMmPzvBErnZ3Nx1s", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68gtwhetFGlca6JdckCLduQJ", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68fX5yazsb8XtbIKvnH7lyFV", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68cdGh1HVxxqsSTVezoulItF", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68ctCLllXxrmkrrcent290Jv", + "type": "Letter" + } + ], + "links": { + "last": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=10", + "prev": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=9", + "self": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=10" + } +} diff --git a/sandbox/data/examples/getLetters/responses/getLetters_pending-5.json b/sandbox/data/examples/getLetters/responses/getLetters_pending-5.json new file mode 100644 index 00000000..f2826180 --- /dev/null +++ b/sandbox/data/examples/getLetters/responses/getLetters_pending-5.json @@ -0,0 +1,73 @@ +{ + "data": [ + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68ctLKWlhxsoil13zANnOXDs", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68hADL5mBayE3gDOnQeQ7RGr", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68hesrGUpMmPzvBErnZ3Nx1s", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68gtwhetFGlca6JdckCLduQJ", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68fX5yazsb8XtbIKvnH7lyFV", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68cdGh1HVxxqsSTVezoulItF", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68ctCLllXxrmkrrcent290Jv", + "type": "Letter" + } + ], + "links": { + "last": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=10", + "next": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=6", + "prev": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=4", + "self": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=5" + } +} diff --git a/sandbox/data/examples/getLetters/responses/getLetters_pending.json b/sandbox/data/examples/getLetters/responses/getLetters_pending.json index db5382de..6216391a 100644 --- a/sandbox/data/examples/getLetters/responses/getLetters_pending.json +++ b/sandbox/data/examples/getLetters/responses/getLetters_pending.json @@ -6,8 +6,67 @@ "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", "status": "PENDING" }, - "id": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "id": "31B68ctLKWlhxsoil13zANnOXDs", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68hADL5mBayE3gDOnQeQ7RGr", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68hesrGUpMmPzvBErnZ3Nx1s", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68gtwhetFGlca6JdckCLduQJ", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68fX5yazsb8XtbIKvnH7lyFV", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68cdGh1HVxxqsSTVezoulItF", + "type": "Letter" + }, + { + "attributes": { + "requestedProductionStatus": "ACTIVE", + "specificationId": "2WL5eYSWGzCHlGmzNxuqVusPxDg", + "status": "PENDING" + }, + "id": "31B68ctCLllXxrmkrrcent290Jv", "type": "Letter" } - ] + ], + "links": { + "last": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=10", + "next": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=2", + "self": "https://api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=1" + } } diff --git a/sandbox/expressServer.js b/sandbox/expressServer.js index 5746e083..a32b631f 100644 --- a/sandbox/expressServer.js +++ b/sandbox/expressServer.js @@ -11,6 +11,8 @@ const bodyParser = require('body-parser'); const OpenApiValidator = require('express-openapi-validator'); const logger = require('./logger'); const config = require('./config'); +const getRawBody = require('raw-body'); +const contentType = require('content-type'); class ExpressServer { constructor(port, openApiYaml) { @@ -29,8 +31,11 @@ class ExpressServer { // this.setupAllowedMedia(); this.app.use(cors()); this.app.use(bodyParser.json({ limit: '14MB' })); - this.app.use(express.json()); - this.app.use(express.urlencoded({ extended: false })); + this.app.use(express.json({ + type: ['application/json', 'application/vnd.api+json'] + })); + this.app.use(express.urlencoded({ extended: true })); + this.app.use(cookieParser()); // Simple test to see that the server is up and responding this.app.get('/hello', (req, res) => res.send(`Hello World. path: ${this.openApiPath}`)); @@ -53,6 +58,21 @@ class ExpressServer { fileUploader: { dest: config.FILE_UPLOAD_PATH }, }), ); + this.app.use((err, req, res, next) => { + // Log full error + console.error('OpenAPI validation error:', err); + + // Handle OpenAPI validation errors + if (err.status && err.errors) { + res.status(err.status).json({ + message: err.message, + errors: err.errors, + }); + } else { + // Fallback error handler + next(err); + } + }); } launch() { diff --git a/sandbox/package-lock.json b/sandbox/package-lock.json index bf90cdb4..61b9c627 100644 --- a/sandbox/package-lock.json +++ b/sandbox/package-lock.json @@ -26,7 +26,7 @@ }, "license": "Unlicense", "name": "nhs-notify-supplier-api", - "version": "next" + "version": "0.0.1" }, "node_modules/@apidevtools/json-schema-ref-parser": { "dependencies": { @@ -5487,5 +5487,5 @@ } }, "requires": true, - "version": "next" + "version": "0.0.1" } diff --git a/sandbox/services/LetterService.js b/sandbox/services/LetterService.js index 3099c8cf..bc38f0c5 100644 --- a/sandbox/services/LetterService.js +++ b/sandbox/services/LetterService.js @@ -38,10 +38,10 @@ const getLetterStatus = ({ xRequestID, id, xCorrelationID }) => new Promise( * xCorrelationID String An optional ID which you can use to track transactions across multiple systems. It can take any value, but we recommend avoiding `.` characters. If not provided in the request, NHS Notify will default to a system generated ID in its place. The ID will be returned in a response header. (optional) * returns listLetters_200_response * */ -const listLetters = ({ xRequestID, status, xCorrelationID }) => new Promise( +const listLetters = ({ xRequestID, status, xCorrelationID, page }) => new Promise( async (resolve, reject) => { try { - const fileData = await ResponseProvider.loadByStatus(status); + const fileData = await ResponseProvider.getLettersResponse(status, page); resolve(Service.successResponse({ xRequestID, @@ -66,14 +66,16 @@ const listLetters = ({ xRequestID, status, xCorrelationID }) => new Promise( * xCorrelationID String An optional ID which you can use to track transactions across multiple systems. It can take any value, but we recommend avoiding `.` characters. If not provided in the request, NHS Notify will default to a system generated ID in its place. The ID will be returned in a response header. (optional) * returns getLetterStatus_200_response * */ -const patchLetters = ({ xRequestID, id, patchLettersRequest, xCorrelationID }) => new Promise( +const patchLetters = ({ xRequestID, id, body, xCorrelationID }) => new Promise( async (resolve, reject) => { + try { + const fileData = await ResponseProvider.patchLettersResponse(body); + resolve(Service.successResponse({ xRequestID, - id, - patchLettersRequest, xCorrelationID, + data: fileData, })); } catch (e) { reject(Service.rejectResponse( diff --git a/sandbox/utils/ResponseProvider.js b/sandbox/utils/ResponseProvider.js index d4fb86cc..b21dcbe3 100644 --- a/sandbox/utils/ResponseProvider.js +++ b/sandbox/utils/ResponseProvider.js @@ -1,18 +1,68 @@ +/* eslint-disable no-throw-literal */ +const { Console } = require('console'); const fs = require('fs/promises'); -class ResponseProvider { - static fileMap = { - PENDING: 'data/examples/getLetters/responses/getLetters_pending.json', - ACCEPTED: 'data/examples/getLetters/responses/getLetters_accepted.json', - }; - - static async loadByStatus(status) { - const filename = this.fileMap[status]; +// eslint-disable-next-line import/no-extraneous-dependencies +const lodash = require('lodash'); + +function mapExampleResponse(requestBody, exampleResponseMap) { + const match = Object.entries(exampleResponseMap).find(async ([requestBodyPath, response]) => { + try { + const requestBodyContent = await fs.readFile(requestBodyPath, 'utf8'); + const exampleRequestBody = JSON.parse(requestBodyContent); + return lodash.isEqual(requestBody, exampleRequestBody); + } catch (err) { + console.error(`Failed to process ${requestBodyPath}:`, err); + throw err; + } + }); + + return match ? match[1] : null; // Return the matched response, or undefined if no match +} + +function mapExampleGetResponse(parameterValue, exampleResponseMap) { + const match = Object.entries(exampleResponseMap).find(([requestParameter, response]) => { + try{ + return parameterValue === requestParameter; + } catch (err) { + console.error(`Failed to process ${parameterValue}:`, err); + throw err; + } + }); + return match ? match[1] : null; +} + + +module.exports = { + async getLettersResponse(status, page) { + const getLettersfileMap = { + PENDING: 'data/examples/getLetters/responses/getLetters_pending.json', + PENDING1: 'data/examples/getLetters/responses/getLetters_pending.json', + PENDING5: 'data/examples/getLetters/responses/getLetters_pending-5.json', + PENDING10: 'data/examples/getLetters/responses/getLetters_pending-10.json', + ACCEPTED: 'data/examples/getLetters/responses/getLetters_accepted.json', + }; + + const mapkey = page ? `${status}${page}` : status; + const filename = mapExampleGetResponse(mapkey, getLettersfileMap); if (!filename) { - throw { message: `Unsupported status: ${status}`, status: 400 }; + throw { message: `Not found: ${status}`, status: 404 }; } const content = await fs.readFile(filename, 'utf8'); return JSON.parse(content); - } -} -module.exports = ResponseProvider; + }, + + async patchLettersResponse(request) { + + const patchLettersFileMap = { + 'data/examples/patchLetter/requests/patchLetter.json': 'data/examples/patchLetter/responses/patchLetter.json', + }; + const filename = mapExampleResponse(request, patchLettersFileMap); + if (!filename) { + throw { message: 'Not found: ', status: 404 }; + } + + const content = await fs.readFile(filename, 'utf8'); + return JSON.parse(content); + }, +}; diff --git a/specification/api/components/endpoints/listLetters.yml b/specification/api/components/endpoints/listLetters.yml index 1fd72a24..eaa4b0a7 100644 --- a/specification/api/components/endpoints/listLetters.yml +++ b/specification/api/components/endpoints/listLetters.yml @@ -4,6 +4,7 @@ tags: - letter parameters: - $ref: "../parameters/letterStatus.yml" + - $ref: "../parameters/pageNumber.yml" description: The key use of this endpoint is to query letters which are ready to be printed responses: '200': diff --git a/specification/api/components/parameters/pageNumber.yml b/specification/api/components/parameters/pageNumber.yml new file mode 100644 index 00000000..d5ac2b80 --- /dev/null +++ b/specification/api/components/parameters/pageNumber.yml @@ -0,0 +1,9 @@ +name: page +in: query +description: |- + The ordinal number of the page of results to be retrieved. If omitted, the + first page of results will be returned. Use the links section in the response + body to determine whether any further pages of results exist. +schema: + type: number + example: 1 diff --git a/specification/api/components/schemas/letterUpdateItem.yml b/specification/api/components/schemas/letterUpdateItem.yml index 84f526ec..3b29b9b2 100644 --- a/specification/api/components/schemas/letterUpdateItem.yml +++ b/specification/api/components/schemas/letterUpdateItem.yml @@ -10,6 +10,10 @@ properties: attributes: type: object properties: + specificationId: + type: string + examples: + - 2WL5eYSWGzCHlGmzNxuqVusPxDg status: $ref: "./letterStatus.yml" requestedProductionStatus: diff --git a/specification/api/components/schemas/lettersListResponse.yml b/specification/api/components/schemas/lettersListResponse.yml index ea5407b9..e9d5805b 100644 --- a/specification/api/components/schemas/lettersListResponse.yml +++ b/specification/api/components/schemas/lettersListResponse.yml @@ -4,3 +4,28 @@ properties: type: array items: $ref: "./letterItem.yml" + links: + type: object + additionalProperties: false + description: Contains links to other data pages + properties: + last: + type: string + format: uri + description: URI of final page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=10 + next: + type: string + format: uri + description: URI of next page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=5 + prev: + type: string + format: uri + description: URI of prev page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=7 + self: + type: string + format: uri + description: URI of current page of data + example: https://dev.api.service.nhs.uk/nhs-notify-supplier/letters?status=PENDING&page=6