From 1449c6e0d40d4b8fe567047314299ffa85992ab6 Mon Sep 17 00:00:00 2001 From: Russell Pollock <68687538+BJSS-russell-pollock@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:42:45 +0000 Subject: [PATCH 1/7] [GPCAPIM-242]-[Prepare Manual API test for demonstrator Get Patient Record]-[RP] (#36) ## Description As a Tester I want to investigate and assess the GP Connect Demonstrator Get Patient Resource endpoint for potential use in the Steel Thread Get Patient Resource work. So that we can set up what we believe is the correct response body structure and enable a simple manual API test to support preparation. We are not proving anything at this stage. ## Context GP Connect demonstrator has an existing endpoint that is capable of retrieving a FIHR STU3 patient resource by patient id https://orange.testlab.nhs.uk/ - [ ] Refactoring (non-breaking change) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would change existing functionality) - [ ] Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have followed the code style of the project - [x] I have added tests to cover my changes - [x] I have updated the documentation accordingly - [] This PR is a result of pair or mob programming - [] Exceptions/Exclusions to coding standards (e.g. #noqa or #NOSONAR) are included within this Pull Request. --- ## Sensitive Information Declaration To ensure the utmost confidentiality and protect your and others privacy, we kindly ask you to NOT including [PII (Personal Identifiable Information) / PID (Personal Identifiable Data)](https://digital.nhs.uk/data-and-information/keeping-data-safe-and-benefitting-the-public) or any other sensitive data in this PR (Pull Request) and the codebase changes. We will remove any PR that do contain any sensitive information. We really appreciate your cooperation in this matter. - [x] I confirm that neither PII/PID nor sensitive data are included in this PR and the codebase changes. --- gateway-api/tests/manual-test/README.md | 101 ++++++++++++++++++ .../api-test/api-gateway/Hello-World.bru | 69 ++++++++++++ .../api-test/api-gateway/HelloWorld.json | 95 ++++++++++++++++ .../api-gateway/Retrieve-Patient-Record.bru | 80 ++++++++++++++ .../api-test/api-gateway/Set-to-localhost.bru | 20 ++++ .../api-test/api-gateway/bruno.json | 9 ++ .../api-test/api-gateway/mock-response.js | 62 +++++++++++ .../api-test/environments/local.bru | 3 + .../config/vocabularies/words/accept.txt | 1 + 9 files changed, 440 insertions(+) create mode 100644 gateway-api/tests/manual-test/README.md create mode 100644 gateway-api/tests/manual-test/api-test/api-gateway/Hello-World.bru create mode 100644 gateway-api/tests/manual-test/api-test/api-gateway/HelloWorld.json create mode 100644 gateway-api/tests/manual-test/api-test/api-gateway/Retrieve-Patient-Record.bru create mode 100644 gateway-api/tests/manual-test/api-test/api-gateway/Set-to-localhost.bru create mode 100644 gateway-api/tests/manual-test/api-test/api-gateway/bruno.json create mode 100644 gateway-api/tests/manual-test/api-test/api-gateway/mock-response.js create mode 100644 gateway-api/tests/manual-test/api-test/environments/local.bru diff --git a/gateway-api/tests/manual-test/README.md b/gateway-api/tests/manual-test/README.md new file mode 100644 index 00000000..5ac58caf --- /dev/null +++ b/gateway-api/tests/manual-test/README.md @@ -0,0 +1,101 @@ +# Manual API Test – Steel Thread + +This folder contains a **manual API test** used to support the **steel thread** for the Clinical Data Gateway (CDG). + +The intent of this test is to: + +- Validate that a response is returned using the **expected structure** +- Support early preparation and understanding only + +This is **not automated testing** and **does not prove functional completeness**. + +## Steel Thread Scope + +For the steel thread, CDG supports **reading a patient record for a single patient** using the following structure: + +```bash +POST https://[CDG_server]/FHIR/STU3/patient/$gpc.getstructuredrecord +``` + +- FHIR version: **STU3** +- Format: **FHIR JSON** +- Operation: **custom GP Connect FHIR operation** +- Scope: **single patient** + +No error handling, authentication edge cases, or non-happy paths are covered. + +## Tooling + +This manual test uses **usebruno** as the API testing tool. + +Bruno is: + +- Free and open source +- Installed locally + +The Bruno collection for this test lives **inside this repository**, so no external workspace or account is required. + +## Installing Bruno (macOS) + +Bruno can be installed using Homebrew. + +1. Install Homebrew: + + ```bash + https://brew.sh/ + ``` + +2. Install Bruno: + + ```bash + brew install bruno + ``` + +3. Launch Bruno: + + ```bash + bruno + ``` + +## Opening the Bruno Collection + +To add it in Bruno: + +1. Open Bruno +2. Select **Open Collection** +3. Navigate to: + + ```text + clinical-data-gateway-api/gateway-api/tests/manual-test/api-test + ``` + +4. Open the folder + +The collection is now loaded and ready to use. + +## Running the Manual Test + +1. Select the **Retrieve Patient Record** request in the collection +2. Set Bruno to use the local environment +3. In terminal run `node mock-response.js`. Output should read `Mock Retrieve Patient Record server running at http://localhost:8080` +4. Send the request + +A successful response should return a **FHIR STU3 response** that aligns with the expected steel-thread response shape. + +```json +POST {{environment}}/FHIR/STU3/patient/$gpc.getstructuredrecord +Content-Type: application/fhir+json +Accept: application/fhir+json +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ] +} +``` diff --git a/gateway-api/tests/manual-test/api-test/api-gateway/Hello-World.bru b/gateway-api/tests/manual-test/api-test/api-gateway/Hello-World.bru new file mode 100644 index 00000000..5b2cf661 --- /dev/null +++ b/gateway-api/tests/manual-test/api-test/api-gateway/Hello-World.bru @@ -0,0 +1,69 @@ +meta { + name: Hello-World + type: http + seq: 3 +} + +post { + url: http://127.0.0.1:5000/2015-03-31/functions/function/invocations + body: json + auth: inherit +} + +body:json { + {"payload": "world"} +} + +body:text { + { + } +} + +tests { + test("should return success", function () { + expect(res.getStatus()).to.equal(200); + }); + test("should return hello world response", function () { + const body = res.getBody(); + + expect(body.statusCode).to.equal(200); + expect(body.headers["Content-Type"]).to.equal("application/json"); + expect(body.body).to.equal("Hello, world!"); + }); +} + +settings { + encodeUrl: true + timeout: 0 +} + +example { + name: Example 200 Response + + request: { + url: http://127.0.0.1:5000/2015-03-31/functions/function/invocations + method: POST + mode: text + body:json: { + {"payload": "world"} + } + + body:text: { + {"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "Hello, world!"} + } + } + + response: { + status: { + code: 200 + text: OK + } + + body: { + type: text + content: ''' + + ''' + } + } +} diff --git a/gateway-api/tests/manual-test/api-test/api-gateway/HelloWorld.json b/gateway-api/tests/manual-test/api-test/api-gateway/HelloWorld.json new file mode 100644 index 00000000..b26efbaa --- /dev/null +++ b/gateway-api/tests/manual-test/api-test/api-gateway/HelloWorld.json @@ -0,0 +1,95 @@ +{ + "name": "Sample API Collection", + "version": "1", + "items": [ + { + "type": "http", + "name": "HelloWorld", + "filename": "HelloWorld.bru", + "seq": 2, + "settings": { + "encodeUrl": true, + "timeout": 0 + }, + "tags": [], + "examples": [], + "request": { + "url": "http://localhost:5000/health-path", + "method": "POST", + "headers": [ + { + "name": "", + "value": "", + "enabled": true + } + ], + "params": [], + "body": { + "mode": "json", + "json": "\"health\"", + "formUrlEncoded": [], + "multipartForm": [], + "file": [] + }, + "script": {}, + "vars": {}, + "assertions": [], + "tests": "", + "docs": "", + "auth": { + "mode": "inherit" + } + } + }, + { + "type": "http", + "name": "Hello-World-Message", + "filename": "Hello-World-Message.bru", + "seq": 3, + "settings": { + "encodeUrl": true, + "timeout": 0 + }, + "tags": [], + "examples": [], + "request": { + "url": "", + "method": "POST", + "headers": [], + "params": [], + "body": { + "mode": "none", + "formUrlEncoded": [], + "multipartForm": [], + "file": [] + }, + "script": {}, + "vars": {}, + "assertions": [], + "tests": "", + "docs": "", + "auth": { + "mode": "inherit" + } + } + } + ], + "environments": [], + "root": { + "request": { + "auth": { + "mode": "none" + } + } + }, + "brunoConfig": { + "version": "1", + "name": "Sample API Collection", + "type": "collection", + "ignore": ["node_modules", ".git"], + "size": 0.00043487548828125, + "filesCount": 3 + }, + "exportedAt": "2026-01-09T16:32:00.777Z", + "exportedUsing": "Bruno/3.0.1" +} diff --git a/gateway-api/tests/manual-test/api-test/api-gateway/Retrieve-Patient-Record.bru b/gateway-api/tests/manual-test/api-test/api-gateway/Retrieve-Patient-Record.bru new file mode 100644 index 00000000..e51957ec --- /dev/null +++ b/gateway-api/tests/manual-test/api-test/api-gateway/Retrieve-Patient-Record.bru @@ -0,0 +1,80 @@ +meta { + name: Retrieve Patient Record + type: http + seq: 3 +} + +post { + url: {{environment}}/FHIR/STU3/patient/$gpc.getstructuredrecord + body: text + auth: inherit +} + +headers { + Ssp-TraceID: {{$guid}} + Ssp-From: 200000000359 + Ssp-To: 918999198738 + Accept: application/fhir+json + Ssp-InteractionId: urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1 + Authorization: Bearer {{jwt_token}} + Content-Type: application/fhir+json +} + +body:text { + {"resourceType":"Parameters","parameter":[{"name":"patientNHSNumber","valueIdentifier":{"system":"https://fhir.nhs.uk/Id/nhs-number","value":"9690938118"}},{"name":"includeAllergies","part":[{"name":"includeResolvedAllergies","valueBoolean":true}]}]} +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + Required JWT Scope: patient/*.read +} + +example { + name: Example 200 Response + + request: { + url: {{environment}}/B82617/STU3/1/gpconnect/structured/fhir/Patient/$gpc.getstructuredrecord + method: POST + mode: text + headers: { + Ssp-TraceID: 369ae31c-8bfc-4df1-9861-1640c914c7f5 + Ssp-From: 200000000359 + Ssp-To: 918999198738 + Accept: application/fhir+json + Ssp-InteractionId: urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1 + } + + body:text: { + {"resourceType":"Parameters","parameter":[{"name":"patientNHSNumber","valueIdentifier":{"system":"https://fhir.nhs.uk/Id/nhs-number","value":"9690938118"}},{"name":"includeAllergies","part":[{"name":"includeResolvedAllergies","valueBoolean":true}]}]} + } + } + + response: { + headers: { + Transfer-Encoding: chunked + Connection: keep-alive + Strict-Transport-Security: max-age=31536000 + Cache-Control: no-store + Date: Mon, 15 Sep 2025 15:14:46 GMT + Location: https://{{environment}}/B82617/STU3/1/gpconnect/fhir/Bundle/369ae31c-8bfc-4df1-9861-1640c914c7f5 + Server: nginx + X-Powered-By: HAPI FHIR 3.0.0 REST Server (FHIR Server; FHIR 3.0.1/DSTU3) + Content-Type: application/fhir+json; charset=UTF-8 + } + + status: { + code: OK + text: 200 + } + body: { + type: text + content: ''' + {"resourceType":"Bundle","id":"369ae31c-8bfc-4df1-9861-1640c914c7f5","meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1"]},"type":"collection","entry":[{"resource":{"resourceType":"Patient","id":"16","meta":{"versionId":"1521806400000","profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1"]},"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-RegistrationDetails-1","extension":[{"url":"registrationPeriod","valuePeriod":{"start":"1962-07-13T00:00:00+01:00"}}]},{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-NHSCommunication-1","extension":[{"url":"language","valueCodeableConcept":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-HumanLanguage-1","code":"en","display":"English"}]}},{"url":"preferred","valueBoolean":false},{"url":"modeOfCommunication","valueCodeableConcept":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-LanguageAbilityMode-1","code":"RWR","display":"Received written"}]}},{"url":"communicationProficiency","valueCodeableConcept":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-LanguageAbilityProficiency-1","code":"E","display":"Excellent"}]}},{"url":"interpreterRequired","valueBoolean":false}]}],"identifier":[{"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-NHSNumberVerificationStatus-1","valueCodeableConcept":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-NHSNumberVerificationStatus-1","code":"01","display":"Number present and verified"}]}}],"system":"https://fhir.nhs.uk/Id/nhs-number","value":"9690938118"}],"active":true,"name":[{"use":"official","text":"Sibyl CRAINE","family":"CRAINE","given":["Sibyl"],"prefix":["MRS"]}],"telecom":[{"system":"phone","value":"01454587554","use":"home"}],"gender":"female","birthDate":"1983-11-24","address":[{"use":"home","type":"physical","line":["1 LANGHAM WALK"],"city":"STOCKTON-ON-TEES","district":"CLEVELAND","postalCode":"TS19 7NX"}],"generalPractitioner":[{"reference":"Practitioner/1"}],"managingOrganization":{"reference":"Organization/7"}}},{"resource":{"resourceType":"List","meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-List-1"]},"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-ListWarningCode-1","valueCode":"confidential-items"},{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-ListWarningCode-1","valueCode":"data-in-transit"},{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-ClinicalSetting-1","valueCodeableConcept":{"coding":[{"system":"http://snomed.info/sct","code":"1060971000000108","display":"General practice service"}]}}],"status":"current","mode":"snapshot","title":"Allergies and adverse reactions","code":{"coding":[{"system":"http://snomed.info/sct","code":"886921000000105","display":"Allergies and adverse reactions"}]},"subject":{"identifier":{"system":"https://fhir.nhs.uk/Id/nhs-number","value":"9690938118"}},"note":[{"text":"Items excluded due to confidentiality and/or patient preferences.\r\nPatient record transfer from previous GP practice not yet complete; information recorded before 08-Sep-2025 may be missing.\r\nInformation not available"}],"emptyReason":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-ListEmptyReasonCode-1","code":"no-content-recorded","display":"No Content Recorded"}]}}},{"resource":{"resourceType":"List","meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-List-1"]},"contained":[{"resourceType":"AllergyIntolerance","id":"11","meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-AllergyIntolerance-1"]},"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-AllergyIntoleranceEnd-1","extension":[{"url":"endDate","valueDateTime":"2016-07-01T12:00:00+01:00"},{"url":"reasonEnded","valueString":"No information available"}]}],"identifier":[{"system":"https://fhir.nhs.uk/Id/cross-care-setting-identifier","value":"b6c0cd9c-8600-11f0-9e03-00505692d4aa"}],"clinicalStatus":"resolved","verificationStatus":"unconfirmed","category":["medication"],"code":{"coding":[{"system":"http://snomed.info/sct","code":"294716003","display":"Biphasic insulin allergy (disorder)"}]},"patient":{"reference":"Patient/16"},"onsetDateTime":"2016-05-01T12:00:00+01:00","assertedDate":"2016-06-01T12:00:00+01:00","recorder":{"reference":"Practitioner/5"},"lastOccurrence":"2016-07-01T12:00:00+01:00","note":[{"text":"Vomiting and diarrhoea"}],"reaction":[{"manifestation":[{"coding":[{"system":"http://snomed.info/sct","code":"49237006","display":"Allergic diarrhea (disorder)"}]}],"description":"Vomiting and diarrhoea"}]},{"resourceType":"AllergyIntolerance","id":"12","meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-AllergyIntolerance-1"]},"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-AllergyIntoleranceEnd-1","extension":[{"url":"endDate","valueDateTime":"2016-07-02T12:00:00+01:00"},{"url":"reasonEnded","valueString":"No information available"}]}],"identifier":[{"system":"https://fhir.nhs.uk/Id/cross-care-setting-identifier","value":"b6c0ce77-8600-11f0-9e03-00505692d4aa"}],"clinicalStatus":"resolved","verificationStatus":"unconfirmed","category":["medication"],"code":{"coding":[{"system":"http://snomed.info/sct","code":"294716003","display":"Biphasic insulin allergy (disorder)"}]},"patient":{"reference":"Patient/16"},"onsetDateTime":"2016-05-02T12:00:00+01:00","assertedDate":"2016-06-02T12:00:00+01:00","recorder":{"reference":"Practitioner/5"},"lastOccurrence":"2016-07-02T12:00:00+01:00","note":[{"text":"Vomiting and diarrhoea"}],"reaction":[{"manifestation":[{"coding":[{"system":"http://snomed.info/sct","code":"49237006","display":"Allergic diarrhea (disorder)"}]}],"description":"Vomiting and diarrhoea"}]}],"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-ListWarningCode-1","valueCode":"confidential-items"},{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-ListWarningCode-1","valueCode":"data-in-transit"},{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-ClinicalSetting-1","valueCodeableConcept":{"coding":[{"system":"http://snomed.info/sct","code":"1060971000000108","display":"General practice service"}]}}],"status":"current","mode":"snapshot","title":"Ended allergies","code":{"coding":[{"system":"http://snomed.info/sct","code":"1103671000000101","display":"Ended allergies"}]},"subject":{"identifier":{"system":"https://fhir.nhs.uk/Id/nhs-number","value":"9690938118"}},"note":[{"text":"Items excluded due to confidentiality and/or patient preferences.\r\nPatient record transfer from previous GP practice not yet complete; information recorded before 08-Sep-2025 may be missing."}],"entry":[{"item":{"reference":"#11"}},{"item":{"reference":"#12"}}]}},{"resource":{"resourceType":"Practitioner","id":"1","meta":{"versionId":"1469444400000","lastUpdated":"2016-07-25T12:00:00.000+01:00","profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Practitioner-1"]},"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-NHSCommunication-1","extension":[{"url":"language","valueCodeableConcept":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-HumanLanguage-1","code":"de","display":"German"}]}}]},{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-NHSCommunication-1","extension":[{"url":"language","valueCodeableConcept":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-HumanLanguage-1","code":"en","display":"English"}]}}]}],"identifier":[{"system":"https://fhir.nhs.uk/Id/sds-user-id","value":"G13579135"}],"name":[{"use":"usual","family":"Gilbert","given":["Nichole"],"prefix":["Miss"]}],"gender":"female"}},{"resource":{"resourceType":"Practitioner","id":"5","meta":{"versionId":"1469444400000","lastUpdated":"2016-07-25T12:00:00.000+01:00","profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Practitioner-1"]},"extension":[{"url":"https://fhir.nhs.uk/STU3/StructureDefinition/Extension-CareConnect-GPC-NHSCommunication-1","extension":[{"url":"language","valueCodeableConcept":{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-HumanLanguage-1","code":"en","display":"English"}]}}]}],"identifier":[{"system":"https://fhir.nhs.uk/Id/sds-user-id","value":"G22222226"},{"system":"https://fhir.nhs.uk/Id/sds-role-profile-id","value":"PT2222"},{"system":"https://fhir.nhs.uk/Id/sds-role-profile-id","value":"PT4444"}],"name":[{"use":"usual","family":"Parsons","given":["Melissa"],"prefix":["Mrs"]}],"gender":"female"}},{"resource":{"resourceType":"PractitionerRole","id":"PT2222","meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-PractitionerRole-1"]},"practitioner":{"reference":"Practitioner/5"},"organization":{"reference":"Organization/2"},"code":[{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-SDSJobRoleName-1","code":"R0042","display":"paediatrician"}]}]}},{"resource":{"resourceType":"PractitionerRole","id":"PT4444","meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-PractitionerRole-1"]},"practitioner":{"reference":"Practitioner/5"},"organization":{"reference":"Organization/2"},"code":[{"coding":[{"system":"https://fhir.nhs.uk/STU3/CodeSystem/CareConnect-SDSJobRoleName-1","code":"R0042","display":"paediatrician"}]}]}},{"resource":{"resourceType":"Organization","id":"2","meta":{"versionId":"1469444400000","lastUpdated":"2016-07-25T12:00:00.000+01:00","profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Organization-1"]},"identifier":[{"system":"https://fhir.nhs.uk/Id/ods-organization-code","value":"R1A14"}],"name":"Test GP Care Trust","telecom":[{"system":"phone","value":"12345678","use":"work"}],"address":[{"use":"work","line":["24 Back Lane","Farsley"],"city":"Leeds","district":"West Yorkshire","postalCode":"GPC 113"}]}},{"resource":{"resourceType":"Organization","id":"7","meta":{"versionId":"1469444400000","lastUpdated":"2016-07-25T12:00:00.000+01:00","profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Organization-1"]},"identifier":[{"system":"https://fhir.nhs.uk/Id/ods-organization-code","value":"B82617"}],"name":"COXWOLD SURGERY","telecom":[{"system":"phone","value":"12345678","use":"work"}],"address":[{"use":"work","line":["NHS NPFIT Test Data Manager","Princes Exchange"],"city":"Leeds","district":"West Yorkshire","postalCode":"LS1 4HY"}]}}]} + ''' + } + } +} diff --git a/gateway-api/tests/manual-test/api-test/api-gateway/Set-to-localhost.bru b/gateway-api/tests/manual-test/api-test/api-gateway/Set-to-localhost.bru new file mode 100644 index 00000000..8696f999 --- /dev/null +++ b/gateway-api/tests/manual-test/api-test/api-gateway/Set-to-localhost.bru @@ -0,0 +1,20 @@ +meta { + name: Set to localhost + type: http + seq: 4 +} + +get { + url: https://pokeapi.co/api/v2/pokemon/garchomp + body: none + auth: inherit +} + +script:pre-request { + bru.setEnvVar('environment', 'localhost:8080'); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/gateway-api/tests/manual-test/api-test/api-gateway/bruno.json b/gateway-api/tests/manual-test/api-test/api-gateway/bruno.json new file mode 100644 index 00000000..e0633637 --- /dev/null +++ b/gateway-api/tests/manual-test/api-test/api-gateway/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "api-gateway", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/gateway-api/tests/manual-test/api-test/api-gateway/mock-response.js b/gateway-api/tests/manual-test/api-test/api-gateway/mock-response.js new file mode 100644 index 00000000..f252f601 --- /dev/null +++ b/gateway-api/tests/manual-test/api-test/api-gateway/mock-response.js @@ -0,0 +1,62 @@ +const http = require('http'); + +const PORT = 8080; + +const RESPONSE_BODY = { + "resourceType":"Bundle", + "id":"369ae31c-8bfc-4df1-9861-1640c914c7f5", + "meta":{"profile":["https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1"]}, + "type":"collection", + "entry":[ + { + "resource":{ + "resourceType":"Patient", + "id":"16", + "meta":{ + "versionId":"1521806400000", + "profile":["https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1"] + }, + "identifier":[ + { + "system":"https://fhir.nhs.uk/Id/nhs-number", + "value":"9690938118" + } + ], + "active":true, + "name":[ + { + "use":"official", + "text":"Sibyl CRAINE", + "family":"CRAINE", + "given":["Sibyl"], + "prefix":["MRS"] + } + ], + "gender":"female", + "birthDate":"1983-11-24" + } + } + ] +}; + + +const server = http.createServer((req, res) => { + if ( + req.method === 'POST' && + req.url === '/FHIR/STU3/patient/$gpc.getstructuredrecord' + ) { + res.writeHead(200, { + 'Content-Type': 'application/fhir+json' + }); + + res.end(JSON.stringify(RESPONSE_BODY, null, 2)); + return; + } + + res.writeHead(404); + res.end('Not Found'); +}); + +server.listen(PORT, () => { + console.log(`Mock Retrieve Patient Record server running at http://localhost:${PORT}`); +}); diff --git a/gateway-api/tests/manual-test/api-test/environments/local.bru b/gateway-api/tests/manual-test/api-test/environments/local.bru new file mode 100644 index 00000000..31fce92f --- /dev/null +++ b/gateway-api/tests/manual-test/api-test/environments/local.bru @@ -0,0 +1,3 @@ +vars { + environment: 127.0.0.1:8080 +} diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index e2b799fa..031d70f7 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -33,5 +33,6 @@ Syft Terraform toolchain Trufflehog +usebruno VMs [Vv]scode From 4e75b2d5c0a9f240fd88d9b507fd469bf6f6ebb0 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:05:39 +0000 Subject: [PATCH 2/7] [GPCAPIM-254]: Use Flask app to receive and return HTTP rquests, forwarding them on to the controller/handler to process. --- .github/actions/start-app/action.yaml | 50 +++ .../actions/start-local-lambda/action.yaml | 50 --- .github/workflows/stage-2-test.yaml | 16 +- Makefile | 7 +- gateway-api/lambda_handler.py | 38 -- gateway-api/openapi.yaml | 379 ++++++------------ gateway-api/poetry.lock | 129 +++++- gateway-api/pyproject.toml | 5 +- gateway-api/src/fhir/__init__.py | 20 + gateway-api/src/fhir/bundle.py | 18 + gateway-api/src/fhir/human_name.py | 9 + gateway-api/src/fhir/identifier.py | 8 + gateway-api/src/fhir/operation_outcome.py | 14 + gateway-api/src/fhir/parameters.py | 15 + gateway-api/src/fhir/patient.py | 15 + gateway-api/src/fhir/py.typed | 0 gateway-api/src/gateway_api/app.py | 62 +++ gateway-api/src/gateway_api/conftest.py | 20 + .../get_structured_record/__init__.py | 6 + .../get_structured_record/handler.py | 38 ++ .../get_structured_record/request.py | 58 +++ .../get_structured_record/test_request.py | 58 +++ gateway-api/src/gateway_api/handler.py | 17 - gateway-api/src/gateway_api/test_app.py | 104 +++++ gateway-api/src/gateway_api/test_handler.py | 54 --- gateway-api/test_lambda_handler.py | 63 --- .../acceptance/features/happy_path.feature | 16 + ...test_hello_world.py => test_happy_path.py} | 8 +- .../tests/acceptance/steps/happy_path.py | 67 ++++ .../acceptance/steps/hello_world_steps.py | 70 ---- gateway-api/tests/conftest.py | 86 ++-- ...GatewayAPIConsumer-GatewayAPIProvider.json | 56 ++- .../tests/contract/test_consumer_contract.py | 96 ++++- .../integration/test_get_structured_record.py | 40 ++ gateway-api/tests/integration/test_main.py | 49 --- infrastructure/images/gateway-api/Dockerfile | 23 +- scripts/tests/run-test.sh | 2 +- 37 files changed, 1091 insertions(+), 675 deletions(-) create mode 100644 .github/actions/start-app/action.yaml delete mode 100644 .github/actions/start-local-lambda/action.yaml delete mode 100644 gateway-api/lambda_handler.py create mode 100644 gateway-api/src/fhir/__init__.py create mode 100644 gateway-api/src/fhir/bundle.py create mode 100644 gateway-api/src/fhir/human_name.py create mode 100644 gateway-api/src/fhir/identifier.py create mode 100644 gateway-api/src/fhir/operation_outcome.py create mode 100644 gateway-api/src/fhir/parameters.py create mode 100644 gateway-api/src/fhir/patient.py create mode 100644 gateway-api/src/fhir/py.typed create mode 100644 gateway-api/src/gateway_api/app.py create mode 100644 gateway-api/src/gateway_api/conftest.py create mode 100644 gateway-api/src/gateway_api/get_structured_record/__init__.py create mode 100644 gateway-api/src/gateway_api/get_structured_record/handler.py create mode 100644 gateway-api/src/gateway_api/get_structured_record/request.py create mode 100644 gateway-api/src/gateway_api/get_structured_record/test_request.py delete mode 100644 gateway-api/src/gateway_api/handler.py create mode 100644 gateway-api/src/gateway_api/test_app.py delete mode 100644 gateway-api/src/gateway_api/test_handler.py delete mode 100644 gateway-api/test_lambda_handler.py create mode 100644 gateway-api/tests/acceptance/features/happy_path.feature rename gateway-api/tests/acceptance/scenarios/{test_hello_world.py => test_happy_path.py} (52%) create mode 100644 gateway-api/tests/acceptance/steps/happy_path.py delete mode 100644 gateway-api/tests/acceptance/steps/hello_world_steps.py create mode 100644 gateway-api/tests/integration/test_get_structured_record.py delete mode 100644 gateway-api/tests/integration/test_main.py diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml new file mode 100644 index 00000000..0f6d6d20 --- /dev/null +++ b/.github/actions/start-app/action.yaml @@ -0,0 +1,50 @@ +name: "Start local app" +description: "Start Flask app that will handle requests" +inputs: + deploy-command: + description: "Command to start app" + required: false + default: "make deploy" + health-path: + description: "Health check path" + required: false + default: "/health" + max-seconds: + description: "Maximum seconds to wait for readiness" + required: false + default: "60" + python-version: + description: "Python version to install" + required: true +runs: + using: "composite" + steps: + - name: "Start app" + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python-version }} + run: | + set -euo pipefail + echo "Starting app: '${{ inputs.deploy-command }}'" + nohup ${{ inputs.deploy-command }} > /tmp/app.log 2>&1 & + echo $! > /tmp/app.pid + echo "PID: $(cat /tmp/app.pid)" + - name: "Wait for app to be ready" + shell: bash + run: | + set -euo pipefail + BASE_URL="${BASE_URL:-http://localhost:5000}" + HEALTH_URL="${BASE_URL}${{ inputs.health-path }}" + MAX="${{ inputs.max-seconds }}" + echo "Waiting for app at ${HEALTH_URL} (max ${MAX}s)..." + for i in $(seq 1 "${MAX}"); do + if curl -sSf -X GET "${HEALTH_URL}" >/dev/null; then + echo "App is ready" + exit 0 + fi + sleep 1 + done + echo "App did not become ready in time" + echo "---- recent app log ----" + tail -n 200 /tmp/app.log || true + exit 1 diff --git a/.github/actions/start-local-lambda/action.yaml b/.github/actions/start-local-lambda/action.yaml deleted file mode 100644 index 49d77405..00000000 --- a/.github/actions/start-local-lambda/action.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: "Start local Lambda environment" -description: "Start a local AWS Lambda environment for testing" -inputs: - deploy-command: - description: "Command to start local Lambda" - required: false - default: "make deploy" - health-path: - description: "Health probe path to POST" - required: false - default: "/2015-03-31/functions/function/invocations" - max-seconds: - description: "Maximum seconds to wait for readiness" - required: false - default: "60" -python-version: - description: "Python version to install" - required: true -runs: - using: "composite" - steps: - - name: "Start local Lambda environment" - shell: bash - env: - PYTHON_VERSION: ${{ inputs.python-version }} - run: | - set -euo pipefail - echo "Starting local Lambda: '${{ inputs.deploy-command }}'" - nohup ${{ inputs.deploy-command }} >/tmp/lambda.log 2>&1 & - echo $! > /tmp/lambda.pid - echo "PID: $(cat /tmp/lambda.pid)" - - name: "Wait for Lambda to be ready" - shell: bash - run: | - set -euo pipefail - BASE_URL="${BASE_URL:-http://localhost:5000}" - HEALTH_URL="${BASE_URL}${{ inputs.health-path }}" - MAX="${{ inputs.max-seconds }}" - echo "Waiting for Lambda at ${HEALTH_URL} (max ${MAX}s)..." - for i in $(seq 1 "${MAX}"); do - if curl -sSf -X POST "${HEALTH_URL}" -d '{}' >/dev/null; then - echo "Lambda is ready" - exit 0 - fi - sleep 1 - done - echo "Lambda did not become ready in time" - echo "---- recent lambda log ----" - tail -n 200 /tmp/lambda.log || true - exit 1 diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 66e4a240..32a5fd2b 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -68,8 +68,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run contract tests" @@ -98,8 +98,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run schema validation tests" @@ -128,8 +128,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run integration test" @@ -158,8 +158,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} max-seconds: 90 diff --git a/Makefile b/Makefile index 3e634518..40b5f7fe 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ IMAGE_REPOSITORY := ${ECR_URL} endif IMAGE_NAME := ${IMAGE_REPOSITORY}:${IMAGE_TAG} +COMMIT_VERSION := $(shell git rev-parse --short HEAD) +BUILD_DATE := $(shell date -u +"%Y%m%d") # ============================================================================== # Example CI/CD targets are: dependencies, build, publish, deploy, clean, etc. @@ -34,9 +36,8 @@ build-gateway-api: dependencies @poetry run mypy --no-namespace-packages . @echo "Packaging dependencies..." @poetry build --format=wheel - @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_1_x86_64 --only-binary=:all: + @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all: # Copy main file separately as it is not included within the package. - @cp lambda_handler.py ./target/gateway-api/ @rm -rf ../infrastructure/images/gateway-api/resources/build/ @mkdir ../infrastructure/images/gateway-api/resources/build/ @cp -r ./target/gateway-api ../infrastructure/images/gateway-api/resources/build/ @@ -46,7 +47,7 @@ build-gateway-api: dependencies .PHONY: build build: build-gateway-api # Build the project artefact @Pipeline @echo "Building Docker x86 image using Docker. Utilising python version: ${PYTHON_VERSION} ..." - @$(docker) buildx build --platform linux/amd64 --load --provenance=false --build-arg PYTHON_VERSION=${PYTHON_VERSION} -t ${IMAGE_NAME} infrastructure/images/gateway-api + @$(docker) buildx build --platform linux/amd64 --load --provenance=false --build-arg PYTHON_VERSION=${PYTHON_VERSION} --build-arg COMMIT_VERSION=${COMMIT_VERSION} --build-arg BUILD_DATE=${BUILD_DATE} -t ${IMAGE_NAME} infrastructure/images/gateway-api @echo "Docker image '${IMAGE_NAME}' built successfully!" publish: # Publish the project artefact @Pipeline diff --git a/gateway-api/lambda_handler.py b/gateway-api/lambda_handler.py deleted file mode 100644 index 554f6e28..00000000 --- a/gateway-api/lambda_handler.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import TypedDict - -from gateway_api.handler import User, greet - - -class LambdaResponse[T](TypedDict): - """A lambda response including a body with a generic type.""" - - statusCode: int - headers: dict[str, str] - body: T - - -def _with_default_headers[T](status_code: int, body: T) -> LambdaResponse[T]: - return { - "statusCode": status_code, - "headers": {"Content-Type": "application/json"}, - "body": body, - } - - -def handler(event: dict[str, str], context: dict[str, str]) -> LambdaResponse[str]: - print(f"Received event: {event}") - - if "payload" not in event: - return _with_default_headers(status_code=400, body="Name is required") - - name = event["payload"] - if not name: - return _with_default_headers(status_code=400, body="Name cannot be empty") - user = User(name=name) - - try: - return _with_default_headers(status_code=200, body=f"{greet(user)}") - except ValueError: - return _with_default_headers( - status_code=404, body=f"Provided name cannot be found. name={name}" - ) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index b6799f7c..96b3f30e 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -9,283 +9,154 @@ servers: - url: http://localhost:5000 description: Local development server paths: - /2015-03-31/functions/function/invocations: + /patient/$gpc.getstructuredrecord: post: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld + summary: Get structured record + description: Returns a FHIR Bundle containing patient structured record + operationId: getStructuredRecord + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: [application/fhir+json] + required: true requestBody: - required: false + required: true content: - application/json: + application/fhir+json: schema: type: object properties: - payload: + resourceType: type: string - description: The payload to be processed + example: "Parameters" + parameter: + type: array + items: + type: object + properties: + name: + type: string + example: "patientNHSNumber" + valueIdentifier: + type: object + properties: + system: + type: string + example: "https://fhir.nhs.uk/Id/nhs-number" + value: + type: string + example: "9999999999" responses: '200': description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - get: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: + parameters: + - in: header + name: Content-Type schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response + type: string + enum: [application/fhir+json] + required: true content: - text/plain: + application/fhir+json: schema: type: object properties: - status_code: + statusCode: type: integer description: Status code of the interaction + example: 200 + headers: + type: object + properties: + Content-Type: + type: string + example: "application/fhir+json" body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - put: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - patch: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed + type: object + description: FHIR Bundle containing patient data + properties: + resourceType: + type: string + example: "Bundle" + id: + type: string + example: "example-patient-bundle" + type: + type: string + example: "collection" + timestamp: + type: string + format: date-time + example: "2026-01-12T10:00:00Z" + entry: + type: array + items: + type: object + properties: + fullUrl: + type: string + example: "urn:uuid:123e4567-e89b-12d3-a456-426614174000" + resource: + type: object + properties: + resourceType: + type: string + example: "Patient" + id: + type: string + example: "9999999999" + identifier: + type: array + items: + type: object + properties: + system: + type: string + example: "https://fhir.nhs.uk/Id/nhs-number" + value: + type: string + example: "9999999999" + name: + type: array + items: + type: object + properties: + use: + type: string + example: "official" + family: + type: string + example: "Doe" + given: + type: array + items: + type: string + example: ["John"] + gender: + type: string + example: "male" + birthDate: + type: string + format: date + example: "1985-04-12" + /health: + get: + summary: Health check + description: Returns the health status of the API + operationId: healthCheck responses: '200': - description: Successful response + description: Service is healthy content: - text/plain: + application/json: schema: type: object properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: + status: type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred + example: "healthy" + required: + - status - '404': - description: Route not found - content: - text/html: - schema: - type: string - delete: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - trace: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 338577d4..8ec2ddde 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -63,6 +63,18 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -301,7 +313,7 @@ version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -332,11 +344,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\""} [[package]] name = "coverage" @@ -443,6 +456,30 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "flask" +version = "3.1.2" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "fqdn" version = "1.5.1" @@ -668,13 +705,25 @@ files = [ [package.dependencies] arrow = ">=0.15.0" +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -808,7 +857,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -1981,6 +2030,62 @@ files = [ requests = "*" starlette = ">=0.20.1" +[[package]] +name = "types-click" +version = "7.1.8" +description = "Typing stubs for click" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-click-7.1.8.tar.gz", hash = "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"}, + {file = "types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81"}, +] + +[[package]] +name = "types-flask" +version = "1.1.6" +description = "Typing stubs for Flask" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Flask-1.1.6.tar.gz", hash = "sha256:aac777b3abfff9436e6b01f6d08171cf23ea6e5be71cbf773aaabb1c5763e9cf"}, + {file = "types_Flask-1.1.6-py3-none-any.whl", hash = "sha256:6ab8a9a5e258b76539d652f6341408867298550b19b81f0e41e916825fc39087"}, +] + +[package.dependencies] +types-click = "*" +types-Jinja2 = "*" +types-Werkzeug = "*" + +[[package]] +name = "types-jinja2" +version = "2.11.9" +description = "Typing stubs for Jinja2" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Jinja2-2.11.9.tar.gz", hash = "sha256:dbdc74a40aba7aed520b7e4d89e8f0fe4286518494208b35123bcf084d4b8c81"}, + {file = "types_Jinja2-2.11.9-py3-none-any.whl", hash = "sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2"}, +] + +[package.dependencies] +types-MarkupSafe = "*" + +[[package]] +name = "types-markupsafe" +version = "1.1.10" +description = "Typing stubs for MarkupSafe" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-MarkupSafe-1.1.10.tar.gz", hash = "sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1"}, + {file = "types_MarkupSafe-1.1.10-py3-none-any.whl", hash = "sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5"}, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -2008,6 +2113,18 @@ files = [ [package.dependencies] urllib3 = ">=2" +[[package]] +name = "types-werkzeug" +version = "1.0.9" +description = "Typing stubs for Werkzeug" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Werkzeug-1.0.9.tar.gz", hash = "sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c"}, + {file = "types_Werkzeug-1.0.9-py3-none-any.whl", hash = "sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -2083,7 +2200,7 @@ version = "3.1.5" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, @@ -2243,4 +2360,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "67e8839de72625c8f7c4d42aea6ea55afaf9f738aef2267bb4dac2f83a389f8e" +content-hash = "30cdb09db37902c7051aa190c1e4c374dbfa6a14ca0c69131c0295ee33e7338f" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 2242551f..fa79be03 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -10,10 +10,13 @@ requires-python = ">3.13,<4.0.0" [tool.poetry.dependencies] clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } +flask = "^3.1.2" +types-flask = "^1.1.6" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, - {include = "stubs", from = "stubs"}] + {include = "stubs", from = "stubs"}, + {include = "fhir", from = "src"}] [tool.coverage.run] relative_files = true diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py new file mode 100644 index 00000000..4ad915ee --- /dev/null +++ b/gateway-api/src/fhir/__init__.py @@ -0,0 +1,20 @@ +"""FHIR data types and resources.""" + +from fhir.bundle import Bundle, BundleEntry +from fhir.human_name import HumanName +from fhir.identifier import Identifier +from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue +from fhir.parameters import Parameter, Parameters +from fhir.patient import Patient + +__all__ = [ + "Bundle", + "BundleEntry", + "HumanName", + "Identifier", + "OperationOutcome", + "OperationOutcomeIssue", + "Parameter", + "Parameters", + "Patient", +] diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py new file mode 100644 index 00000000..5fbc9a3b --- /dev/null +++ b/gateway-api/src/fhir/bundle.py @@ -0,0 +1,18 @@ +"""FHIR Bundle resource.""" + +from typing import TypedDict + +from fhir.patient import Patient + + +class BundleEntry(TypedDict): + fullUrl: str + resource: Patient + + +class Bundle(TypedDict): + resourceType: str + id: str + type: str + timestamp: str + entry: list[BundleEntry] diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py new file mode 100644 index 00000000..2a73deb0 --- /dev/null +++ b/gateway-api/src/fhir/human_name.py @@ -0,0 +1,9 @@ +"""FHIR HumanName type.""" + +from typing import TypedDict + + +class HumanName(TypedDict): + use: str + family: str + given: list[str] diff --git a/gateway-api/src/fhir/identifier.py b/gateway-api/src/fhir/identifier.py new file mode 100644 index 00000000..4e59908d --- /dev/null +++ b/gateway-api/src/fhir/identifier.py @@ -0,0 +1,8 @@ +"""FHIR Identifier type.""" + +from typing import TypedDict + + +class Identifier(TypedDict): + system: str + value: str diff --git a/gateway-api/src/fhir/operation_outcome.py b/gateway-api/src/fhir/operation_outcome.py new file mode 100644 index 00000000..d25765f5 --- /dev/null +++ b/gateway-api/src/fhir/operation_outcome.py @@ -0,0 +1,14 @@ +"""FHIR OperationOutcome resource.""" + +from typing import TypedDict + + +class OperationOutcomeIssue(TypedDict): + severity: str + code: str + diagnostics: str + + +class OperationOutcome(TypedDict): + resourceType: str + issue: list[OperationOutcomeIssue] diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py new file mode 100644 index 00000000..30b7cce8 --- /dev/null +++ b/gateway-api/src/fhir/parameters.py @@ -0,0 +1,15 @@ +"""FHIR Parameters resource.""" + +from typing import TypedDict + +from fhir.identifier import Identifier + + +class Parameter(TypedDict): + name: str + valueIdentifier: Identifier + + +class Parameters(TypedDict): + resourceType: str + parameter: list[Parameter] diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py new file mode 100644 index 00000000..33d0ce41 --- /dev/null +++ b/gateway-api/src/fhir/patient.py @@ -0,0 +1,15 @@ +"""FHIR Patient resource.""" + +from typing import TypedDict + +from fhir.human_name import HumanName +from fhir.identifier import Identifier + + +class Patient(TypedDict): + resourceType: str + id: str + identifier: list[Identifier] + name: list[HumanName] + gender: str + birthDate: str diff --git a/gateway-api/src/fhir/py.typed b/gateway-api/src/fhir/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py new file mode 100644 index 00000000..8174fe17 --- /dev/null +++ b/gateway-api/src/gateway_api/app.py @@ -0,0 +1,62 @@ +import os +from typing import TypedDict + +from flask import Flask, request +from flask.wrappers import Response + +from gateway_api.get_structured_record import ( + GetStructuredRecordHandler, + GetStructuredRecordRequest, +) + +app = Flask(__name__) + + +class HealthCheckResponse(TypedDict): + status: str + version: str + + +def get_app_host() -> str: + host = os.getenv("FLASK_HOST") + if host is None: + raise RuntimeError("FLASK_HOST environment variable is not set.") + print(f"Starting Flask app on host: {host}") + return host + + +def get_app_port() -> int: + port = os.getenv("FLASK_PORT") + if port is None: + raise RuntimeError("FLASK_PORT environment variable is not set.") + print(f"Starting Flask app on port: {port}") + return int(port) + + +@app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) +def get_structured_record() -> Response: + try: + get_structured_record_request = GetStructuredRecordRequest(request) + GetStructuredRecordHandler.handle(get_structured_record_request) + except Exception as e: + get_structured_record_request.set_negative_response(str(e)) + return get_structured_record_request.build_response() + + +@app.route("/health", methods=["GET"]) +def health_check() -> HealthCheckResponse: + """Health check endpoint.""" + version: str = "unkonwn" + + commit_version: str | None = os.getenv("COMMIT_VERSION") + build_date: str | None = os.getenv("BUILD_DATE") + if commit_version and build_date: + version = f"{build_date}.{commit_version}" + + return {"status": "healthy", "version": version} + + +if __name__ == "__main__": + host, port = get_app_host(), get_app_port() + print(f"Version: {os.getenv('COMMIT_VERSION')}") + app.run(host=host, port=port) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py new file mode 100644 index 00000000..05307c86 --- /dev/null +++ b/gateway-api/src/gateway_api/conftest.py @@ -0,0 +1,20 @@ +"""Pytest configuration and shared fixtures for gateway API tests.""" + +import pytest +from fhir.parameters import Parameters + + +@pytest.fixture +def valid_simple_request_payload() -> Parameters: + return { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py new file mode 100644 index 00000000..c279cb73 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -0,0 +1,6 @@ +"""Get Structured Record module.""" + +from gateway_api.get_structured_record.handler import GetStructuredRecordHandler +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +__all__ = ["GetStructuredRecordHandler", "GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structured_record/handler.py b/gateway-api/src/gateway_api/get_structured_record/handler.py new file mode 100644 index 00000000..15479f28 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/handler.py @@ -0,0 +1,38 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fhir import Bundle + +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + + +class GetStructuredRecordHandler: + @classmethod + def handle(cls, request: GetStructuredRecordRequest) -> None: + bundle: Bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + {"use": "official", "family": "Doe", "given": ["John"]} + ], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + request.set_positive_response(bundle) diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py new file mode 100644 index 00000000..141c3cda --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -0,0 +1,58 @@ +import json + +from fhir import OperationOutcome, Parameters +from fhir.bundle import Bundle +from fhir.operation_outcome import OperationOutcomeIssue +from flask.wrappers import Request, Response + + +class GetStructuredRecordRequest: + INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" + RESOURCE: str = "patient" + FHIR_OPERATION: str = "$gpc.getstructuredrecord" + + def __init__(self, request: Request) -> None: + self._http_request = request + self._headers = request.headers + self._request_body: Parameters = request.get_json() + self._response_body: Bundle | OperationOutcome | None = None + self._status_code: int | None = None + + @property + def trace_id(self) -> str: + trace_id: str = self._headers["Ssp-TraceID"] + return trace_id + + @property + def nhs_number(self) -> str: + nhs_number: str = self._request_body["parameter"][0]["valueIdentifier"]["value"] + return nhs_number + + @property + def ods_from(self) -> str: + ods_from: str = self._headers["ODS-from"] + return ods_from + + def build_response(self) -> Response: + return Response( + response=json.dumps(self._response_body), + status=self._status_code, + mimetype="application/fhir+json", + ) + + def set_positive_response(self, bundle: Bundle) -> None: + self._status_code = 200 + self._response_body = bundle + + def set_negative_response(self, error: str) -> None: + self._status_code = 500 + self._response_body = OperationOutcome( + resourceType="OperationOutcome", + issue=[ + OperationOutcomeIssue( + severity="error", + code="exception", + diagnostics=error, + ) + ], + ) diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py new file mode 100644 index 00000000..7ff082c5 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -0,0 +1,58 @@ +import pytest +from fhir.parameters import Parameters +from flask import Request + +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + + +class MockRequest: + def __init__(self, headers: dict[str, str], body: Parameters) -> None: + self.headers = headers + self.body = body + + def get_json(self) -> Parameters: + return self.body + + +@pytest.fixture +def mock_request_with_headers(valid_simple_request_payload: Parameters) -> MockRequest: + headers = { + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + } + return MockRequest(headers, valid_simple_request_payload) + + +class TestGetStructuredRecordRequest: + def test_trace_id_is_pulled_from_ssp_traceid_header( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.trace_id + expected = "test-trace-id" + assert actual == expected + + def test_ods_is_pulled_from_ssp_from_header( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.ods_from + expected = "test-ods" + assert actual == expected + + def test_nhs_number_is_pulled_from_request_body( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.nhs_number + expected = "9999999999" + assert actual == expected diff --git a/gateway-api/src/gateway_api/handler.py b/gateway-api/src/gateway_api/handler.py deleted file mode 100644 index a3f66b94..00000000 --- a/gateway-api/src/gateway_api/handler.py +++ /dev/null @@ -1,17 +0,0 @@ -from clinical_data_common import get_hello - - -class User: - def __init__(self, name: str): - self._name = name - - @property - def name(self) -> str: - return self._name - - -def greet(user: User) -> str: - if user.name == "nonexistent": - raise ValueError("nonexistent user provided.") - hello = get_hello() - return f"{hello}{user.name}!" diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py new file mode 100644 index 00000000..18a4b0f2 --- /dev/null +++ b/gateway-api/src/gateway_api/test_app.py @@ -0,0 +1,104 @@ +"""Unit tests for the Flask app endpoints.""" + +import os +from collections.abc import Generator +from typing import TYPE_CHECKING + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from gateway_api.app import app, get_app_host, get_app_port + +if TYPE_CHECKING: + from fhir.parameters import Parameters + + +@pytest.fixture +def client() -> Generator[FlaskClient[Flask], None, None]: + app.config["TESTING"] = True + with app.test_client() as client: + yield client + + +class TestAppInitialization: + def test_get_app_host_returns_set_host_name(self) -> None: + os.environ["FLASK_HOST"] = "host_is_set" + + actual = get_app_host() + assert actual == "host_is_set" + + def test_get_app_host_raises_runtime_error_if_host_name_not_set(self) -> None: + del os.environ["FLASK_HOST"] + + with pytest.raises(RuntimeError): + _ = get_app_host() + + def test_get_app_port_returns_set_port_number(self) -> None: + os.environ["FLASK_PORT"] = "8080" + + actual = get_app_port() + assert actual == 8080 + + def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: + del os.environ["FLASK_PORT"] + + with pytest.raises(RuntimeError): + _ = get_app_port() + + +class TestGetStructuredRecord: + def test_get_structured_record_returns_200_with_bundle( + self, client: FlaskClient[Flask], valid_simple_request_payload: "Parameters" + ) -> None: + response = client.post( + "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload + ) + + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, dict) + assert data.get("resourceType") == "Bundle" + assert data.get("id") == "example-patient-bundle" + assert data.get("type") == "collection" + assert "entry" in data + assert isinstance(data["entry"], list) + assert len(data["entry"]) > 0 + assert data["entry"][0]["resource"]["resourceType"] == "Patient" + assert data["entry"][0]["resource"]["id"] == "9999999999" + assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" + + def test_get_structured_record_handles_exception( + self, + client: FlaskClient[Flask], + monkeypatch: pytest.MonkeyPatch, + valid_simple_request_payload: "Parameters", + ) -> None: + monkeypatch.setattr( + "gateway_api.get_structured_record.GetStructuredRecordHandler.handle", + Exception(), + ) + + response = client.post( + "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload + ) + assert response.status_code == 500 + + +class TestHealthCheck: + def test_health_check_returns_200_and_healthy_status( + self, client: FlaskClient[Flask] + ) -> None: + response = client.get("/health") + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "healthy" + + @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "PATCH"]) + def test_health_check_only_accepts_get_method( + self, client: FlaskClient[Flask], method: str + ) -> None: + """Test that health_check only accepts GET method.""" + response = client.open("/health", method=method) + assert response.status_code == 405 diff --git a/gateway-api/src/gateway_api/test_handler.py b/gateway-api/src/gateway_api/test_handler.py deleted file mode 100644 index f2092af7..00000000 --- a/gateway-api/src/gateway_api/test_handler.py +++ /dev/null @@ -1,54 +0,0 @@ -import pytest - -from gateway_api.handler import User, greet - - -class TestUser: - """Test suite for the User class.""" - - @pytest.mark.parametrize( - "name", - [ - "Alice", - "Bob", - "", - "O'Brien", - ], - ) - def test_user_initialization(self, name: str) -> None: - """Test that a User can be initialized with various names.""" - user = User(name) - assert user.name == name - - def test_user_name_is_immutable(self) -> None: - """Test that the name property cannot be directly modified.""" - user = User("Charlie") - with pytest.raises(AttributeError): - user.name = "David" # type: ignore[misc] - - -class TestGreet: - """Test suite for the greet function.""" - - @pytest.mark.parametrize( - ("name", "expected_greeting"), - [ - ("Alice", "Hello, Alice!"), - ("Bob", "Hello, Bob!"), - ("", "Hello, !"), - ("O'Brien", "Hello, O'Brien!"), - ("Nonexistent", "Hello, Nonexistent!"), - ("nonexistent ", "Hello, nonexistent !"), - ], - ) - def test_greet_with_valid_users(self, name: str, expected_greeting: str) -> None: - """Test that greet returns the correct greeting for various valid users.""" - user = User(name) - result = greet(user) - assert result == expected_greeting - - def test_greet_with_nonexistent_user_raises_value_error(self) -> None: - """Test that greet raises ValueError for nonexistent user.""" - user = User("nonexistent") - with pytest.raises(ValueError, match="nonexistent user provided."): - greet(user) diff --git a/gateway-api/test_lambda_handler.py b/gateway-api/test_lambda_handler.py deleted file mode 100644 index df38367d..00000000 --- a/gateway-api/test_lambda_handler.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from lambda_handler import handler - - -class TestHandler: - """Unit tests for the Lambda handler function.""" - - @pytest.mark.parametrize( - ("name", "expected_greeting"), - [ - ("Alice", "Hello, Alice!"), - ("Bob", "Hello, Bob!"), - ("John Doe", "Hello, John Doe!"), - ("user123", "Hello, user123!"), - ], - ids=["simple_name_alice", "simple_name_bob", "name_with_space", "alphanumeric"], - ) - def test_handler_success(self, name: str, expected_greeting: str) -> None: - """Test handler returns 200 with greeting for valid names.""" - # Arrange - event = {"payload": name} - context: dict[str, str] = {} - - # Act - response = handler(event, context) - - # Assert - assert response["statusCode"] == 200 - assert response["body"] == expected_greeting - assert response["headers"] == {"Content-Type": "application/json"} - - @pytest.mark.parametrize( - ("event", "expected_status", "expected_body"), - [ - ({"other_key": "value"}, 400, "Name is required"), - ({"payload": ""}, 400, "Name cannot be empty"), - ({"payload": None}, 400, "Name cannot be empty"), - ( - {"payload": "nonexistent"}, - 404, - "Provided name cannot be found. name=nonexistent", - ), - ], - ids=[ - "missing_payload_key", - "empty_payload", - "none_payload", - "nonexistent_user", - ], - ) - def test_handler_error_cases( - self, event: dict[str, str], expected_status: int, expected_body: str - ) -> None: - """Test handler returns appropriate error responses for invalid or - nonexistent input. - """ - # Act - response = handler(event, {}) - - # Assert - assert response["statusCode"] == expected_status - assert response["body"] == expected_body - assert response["headers"] == {"Content-Type": "application/json"} diff --git a/gateway-api/tests/acceptance/features/happy_path.feature b/gateway-api/tests/acceptance/features/happy_path.feature new file mode 100644 index 00000000..a2afa5b5 --- /dev/null +++ b/gateway-api/tests/acceptance/features/happy_path.feature @@ -0,0 +1,16 @@ +Feature: Gateway API Hello World + As an API consumer + I want to interact with the Gateway API + So that I can verify it responds correctly to valid and invalid requests + + Background: The API is running + Given the API is running + + Scenario: Get structured record request + When I send a valid Parameters resource to the endpoint + Then the response status code should be 200 + And the response should contain a valid Bundle resource + + Scenario: Accessing a non-existent endpoint returns a 404 + When I send a valid Parameters resource to a nonexistent endpoint + Then the response status code should be 404 diff --git a/gateway-api/tests/acceptance/scenarios/test_hello_world.py b/gateway-api/tests/acceptance/scenarios/test_happy_path.py similarity index 52% rename from gateway-api/tests/acceptance/scenarios/test_hello_world.py rename to gateway-api/tests/acceptance/scenarios/test_happy_path.py index 93ed4a1a..e175a034 100644 --- a/gateway-api/tests/acceptance/scenarios/test_hello_world.py +++ b/gateway-api/tests/acceptance/scenarios/test_happy_path.py @@ -4,16 +4,16 @@ from pytest_bdd import scenario -from tests.acceptance.steps.hello_world_steps import * # noqa: F403,S2208 - Required to import all hello world steps. +from tests.acceptance.steps.happy_path import * # noqa: F403,S2208 - Required to import all happy path steps. -@scenario("hello_world.feature", "Get hello world message") -def test_hello_world() -> None: +@scenario("happy_path.feature", "Get structured record request") +def test_structured_record_request() -> None: # No body required here as this method simply provides a binding to the BDD step pass -@scenario("hello_world.feature", "Accessing a non-existent endpoint returns a 404") +@scenario("happy_path.feature", "Accessing a non-existent endpoint returns a 404") def test_nonexistent_route() -> None: # No body required here as this method simply provides a binding to the BDD step pass diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py new file mode 100644 index 00000000..e9c813c8 --- /dev/null +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -0,0 +1,67 @@ +"""Step definitions for Gateway API happy path feature.""" + +import json +from datetime import timedelta + +import requests +from fhir.bundle import Bundle +from fhir.parameters import Parameters +from pytest_bdd import given, parsers, then, when + +from tests.acceptance.conftest import ResponseContext +from tests.conftest import Client + + +@given("the API is running") +def check_api_is_running(client: Client) -> None: + response = client.send_health_check() + assert response.status_code == 200 + + +@when("I send a valid Parameters resource to the endpoint") +def send_get_request( + client: Client, + response_context: ResponseContext, + simple_request_payload: Parameters, +) -> None: + response_context.response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + + +@when("I send a valid Parameters resource to a nonexistent endpoint") +def send_to_nonexistent_endpoint( + client: Client, + response_context: ResponseContext, + simple_request_payload: Parameters, +) -> None: + nonexistent_endpoint = f"{client.base_url}/nonexistent" + response_context.response = requests.post( + url=nonexistent_endpoint, + data=json.dumps(simple_request_payload), + timeout=timedelta(seconds=1).total_seconds(), + ) + + +@then( + parsers.cfparse( + "the response status code should be {expected_status:d}", + extra_types={"expected_status": int}, + ) +) +def check_status_code(response_context: ResponseContext, expected_status: int) -> None: + assert response_context.response is not None, "Response has not been set." + assert response_context.response.status_code == expected_status, ( + f"Expected status {expected_status}, " + f"got {response_context.response.status_code}" + ) + + +@then("the response should contain a valid Bundle resource") +def check_response_contains( + response_context: ResponseContext, expected_response_payload: Bundle +) -> None: + assert response_context.response, "Response has not been set." + assert response_context.response.json() == expected_response_payload, ( + "Expected response payload does not match actual response payload." + ) diff --git a/gateway-api/tests/acceptance/steps/hello_world_steps.py b/gateway-api/tests/acceptance/steps/hello_world_steps.py deleted file mode 100644 index a7439725..00000000 --- a/gateway-api/tests/acceptance/steps/hello_world_steps.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Step definitions for Gateway API hello world feature.""" - -from pytest_bdd import given, parsers, then, when - -from tests.acceptance.conftest import ResponseContext -from tests.conftest import Client - - -@given("the API is running") -def step_api_is_running(client: Client) -> None: - """Verify the API test client is available. - - Args: - client: Test client from conftest.py - """ - response = client.send("test") - assert response.text is not None - assert response.status_code == 200 - - -@when(parsers.cfparse('I send "{message}" to the endpoint')) -def step_send_get_request( - client: Client, message: str, response_context: ResponseContext -) -> None: - """Send a GET request to the specified endpoint. - - Args: - client: Test client - endpoint: The API endpoint path to request - """ - response_context.response = client.send(message) - - -# fmt: off -@then(parsers.cfparse("the response status code should be {expected_status:d}",extra_types={"expected_status": int})) # noqa: E501 - BDD steps must be declared on a singular line. -# fmt: on -def step_check_status_code( - response_context: ResponseContext, expected_status: int -) -> None: - """Verify the response status code matches expected value. - - Args: - context: Behave context containing the response - expected_status: Expected HTTP status code - """ - assert response_context.response, "Response has not been set." - - data = response_context.response.json() - - assert data["statusCode"] == expected_status, ( - f"Expected status {expected_status}, " - f"got {response_context.response.status_code}" - ) - - -@then(parsers.cfparse('the response should contain "{expected_text}"')) -def step_check_response_contains( - response_context: ResponseContext, expected_text: str -) -> None: - """Verify the response contains the expected text. - - Args: - context: Behave context containing the response - expected_text: Text that should be in the response - """ - assert response_context.response, "Response has not been set." - - assert expected_text in response_context.response.text, ( - f"Expected '{expected_text}' in response, got: {response_context.response.text}" - ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index d5fba218..5facb089 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -1,6 +1,5 @@ """Pytest configuration and shared fixtures for gateway API tests.""" -import json import os from datetime import timedelta from typing import cast @@ -8,6 +7,8 @@ import pytest import requests from dotenv import find_dotenv, load_dotenv +from fhir.bundle import Bundle +from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root # find_dotenv searches upward from current directory for .env file @@ -17,42 +18,81 @@ class Client: """A simple HTTP client for testing purposes.""" - def __init__(self, lambda_url: str, timeout: timedelta = timedelta(seconds=1)): - self._lambda_url = lambda_url + def __init__(self, base_url: str, timeout: timedelta = timedelta(seconds=1)): + self.base_url = base_url self._timeout = timeout.total_seconds() - def send(self, data: str) -> requests.Response: + def send_to_get_structured_record_endpoint(self, payload: str) -> requests.Response: """ - Send a request to the APIs with some given parameters. - Args: - data: The data to send in the request payload - Returns: - Response object from the request + Send a request to the get_structured_record endpoint with the given NHS number. """ - return self._send(data=data, include_payload=True) + url = f"{self.base_url}/patient/$gpc.getstructuredrecord" + headers = {"Content-Type": "application/fhir+json"} + return requests.post( + url=url, + data=payload, + headers=headers, + timeout=self._timeout, + ) - def send_without_payload(self) -> requests.Response: + def send_health_check(self) -> requests.Response: """ - Send a request to the APIs without a payload. + Send a health check request to the API. Returns: Response object from the request """ - return self._send(data=None, include_payload=False) - - def _send(self, data: str | None, include_payload: bool) -> requests.Response: - json_data = {"payload": data} if include_payload else {} - - return requests.post( - f"{self._lambda_url}/2015-03-31/functions/function/invocations", - data=json.dumps(json_data), - timeout=self._timeout, - ) + url = f"{self.base_url}/health" + return requests.get(url=url, timeout=self._timeout) + + +@pytest.fixture +def simple_request_payload() -> Parameters: + return { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + + +@pytest.fixture +def expected_response_payload() -> Bundle: + return { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [{"use": "official", "family": "Doe", "given": ["John"]}], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } @pytest.fixture(scope="module") def client(base_url: str) -> Client: """Create a test client for the application.""" - return Client(lambda_url=base_url) + return Client(base_url=base_url) @pytest.fixture(scope="module") diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 681c19d7..6d60fef5 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -16,33 +16,73 @@ "type": "Synchronous/HTTP" }, { - "description": "a request for the hello world message", + "description": "a request for structured record", "pending": false, "request": { "body": { "content": { - "payload": "World" + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ], + "resourceType": "Parameters" }, - "contentType": "application/json", + "contentType": "application/fhir+json", "encoded": false }, "headers": { "Content-Type": [ - "application/json" + "application/fhir+json" ] }, "method": "POST", - "path": "/2015-03-31/functions/function/invocations" + "path": "/patient/$gpc.getstructuredrecord" }, "response": { "body": { - "content": "{\"statusCode\": 200, \"headers\": {\"Content-Type\": \"application/json\"}, \"body\": \"Hello, World!\"}", - "contentType": "text/plain;charset=utf-8", + "content": { + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "birthDate": "1985-04-12", + "gender": "male", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + ], + "name": [ + { + "family": "Doe", + "given": [ + "John" + ], + "use": "official" + } + ], + "resourceType": "Patient" + } + } + ], + "id": "example-patient-bundle", + "resourceType": "Bundle", + "timestamp": "2026-01-12T10:00:00Z", + "type": "collection" + }, + "contentType": "application/fhir+json", "encoded": false }, "headers": { "Content-Type": [ - "text/plain;charset=utf-8" + "application/fhir+json" ] }, "status": 200 diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index ac0d11d1..2f828234 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -4,6 +4,8 @@ interactions with the provider (the Flask API). """ +import json + import requests from pact import Pact @@ -11,50 +13,102 @@ class TestConsumerContract: """Consumer contract tests to define expected API behavior.""" - def test_get_hello_world(self) -> None: - """Test the consumer's expectation of the hello world endpoint. + def test_get_structured_record(self) -> None: + """Test the consumer's expectation of the get structured record endpoint. This test defines the contract: when the consumer requests - GET/PUT/POST/PATCH/TRACE/DELETE to the - /2015-03-31/functions/function/invocations endpoint, with a payload of "World", - a 200 response containing "Hello, World!" is returned. + POST to the /patient/$gpc.getstructuredrecord endpoint, + a 200 response containing a FHIR Bundle is returned. """ pact = Pact(consumer="GatewayAPIConsumer", provider="GatewayAPIProvider") + expected_bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + {"use": "official", "family": "Doe", "given": ["John"]} + ], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + # Define the expected interaction ( - pact.upon_receiving("a request for the hello world message") - .with_body({"payload": "World"}) - .with_request( - method="POST", - path="/2015-03-31/functions/function/invocations", - ) - .will_respond_with(status=200) + pact.upon_receiving("a request for structured record") .with_body( { - "statusCode": 200, - "headers": {"Content-Type": "application/json"}, - "body": "Hello, World!", + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], }, - content_type="text/plain;charset=utf-8", + content_type="application/fhir+json", ) + .with_header("Content-Type", "application/fhir+json") + .with_request( + method="POST", + path="/patient/$gpc.getstructuredrecord", + ) + .will_respond_with(status=200) + .with_body(expected_bundle, content_type="application/fhir+json") + .with_header("Content-Type", "application/fhir+json") ) # Start the mock server and execute the test with pact.serve() as server: # Make the actual request to the mock provider response = requests.post( - f"{server.url}/2015-03-31/functions/function/invocations", - json={"payload": "World"}, + f"{server.url}/patient/$gpc.getstructuredrecord", + data=json.dumps( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + ), + headers={"Content-Type": "application/fhir+json"}, timeout=10, ) # Verify the response matches expectations assert response.status_code == 200 body = response.json() - assert body["body"] == "Hello, World!" - assert body["statusCode"] == 200 - assert body["headers"] == {"Content-Type": "application/json"} + assert body["resourceType"] == "Bundle" + assert body["id"] == "example-patient-bundle" + assert body["type"] == "collection" + assert len(body["entry"]) == 1 + assert body["entry"][0]["resource"]["resourceType"] == "Patient" + assert body["entry"][0]["resource"]["id"] == "9999999999" # Write the pact file after the test pact.write_file("tests/contract/pacts") diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py new file mode 100644 index 00000000..0215d840 --- /dev/null +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -0,0 +1,40 @@ +"""Integration tests for the gateway API using pytest.""" + +import json + +from fhir.bundle import Bundle +from fhir.parameters import Parameters + +from tests.conftest import Client + + +class TestGetStructuredRecord: + def test_happy_path_returns_200( + self, client: Client, simple_request_payload: Parameters + ) -> None: + """Test that the root endpoint returns a 200 status code.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert response.status_code == 200 + + def test_happy_path_returns_correct_message( + self, + client: Client, + simple_request_payload: Parameters, + expected_response_payload: Bundle, + ) -> None: + """Test that the root endpoint returns the correct message.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert response.json() == expected_response_payload + + def test_happy_path_content_type( + self, client: Client, simple_request_payload: Parameters + ) -> None: + """Test that the response has the correct content type.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert "application/fhir+json" in response.headers["Content-Type"] diff --git a/gateway-api/tests/integration/test_main.py b/gateway-api/tests/integration/test_main.py deleted file mode 100644 index 18c71e09..00000000 --- a/gateway-api/tests/integration/test_main.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Integration tests for the gateway API using pytest.""" - -from tests.conftest import Client - - -class TestHelloWorld: - """Test suite for the hello world endpoint.""" - - def test_hello_world_returns_200(self, client: Client) -> None: - """Test that the root endpoint returns a 200 status code.""" - response = client.send("world") - assert response.status_code == 200 - - def test_hello_world_returns_correct_message(self, client: Client) -> None: - """Test that the root endpoint returns the correct message.""" - response = client.send("World") - assert response.json()["body"] == "Hello, World!" - - def test_hello_world_content_type(self, client: Client) -> None: - """Test that the response has the correct content type.""" - response = client.send("world") - assert "text/plain" in response.headers["Content-Type"] - - def test_nonexistent_returns_error(self, client: Client) -> None: - """Test that non-existent routes return 404.""" - response = client.send("nonexistent") - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Provided name cannot be found. name=nonexistent" - - status_code = response.json().get("statusCode") - assert status_code == 404 - - def test_no_payload_returns_error(self, client: Client) -> None: - """Test that an error is returned when no payload is provided.""" - response = client.send_without_payload() - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Name is required" - - def test_empty_name_returns_error(self, client: Client) -> None: - """Test that an error is returned when an empty name is provided.""" - response = client.send("") - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Name cannot be empty" diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 121dc611..54824a4b 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -1,11 +1,24 @@ # Retrieve the python version from build arguments, deliberately set to "invalid" by default to highlight when no version is provided when building the container. ARG PYTHON_VERSION=invalid -# Use the specified python version to retrieve the required base lambda image. -ARG url=public.ecr.aws/lambda/python:${PYTHON_VERSION} -FROM $url +FROM python:${PYTHON_VERSION}-alpine3.23 AS gateway-api + +RUN addgroup -S nonroot \ + && adduser -S gateway_api_user -G nonroot COPY resources/ /resources -COPY /resources/build/gateway-api ${LAMBDA_TASK_ROOT} +WORKDIR /resources/build/gateway-api + +ENV PYTHONPATH=/resources/build/gateway-api +ENV FLASK_HOST="0.0.0.0" +ENV FLASK_PORT="8080" + +ARG COMMIT_VERSION +ENV COMMIT_VERSION=$COMMIT_VERSION +ARG BUILD_DATE +ENV BUILD_DATE=$BUILD_DATE + +USER gateway_api_user +ENTRYPOINT ["python"] +CMD ["gateway_api/app.py"] -CMD [ "lambda_handler.handler" ] diff --git a/scripts/tests/run-test.sh b/scripts/tests/run-test.sh index d2c3177c..8d1cefec 100755 --- a/scripts/tests/run-test.sh +++ b/scripts/tests/run-test.sh @@ -25,7 +25,7 @@ cd "$(git rev-parse --show-toplevel)" # Determine test path based on test type if [[ "$TEST_TYPE" = "unit" ]]; then - TEST_PATH="test_*.py src/*/test_*.py" + TEST_PATH="src" else TEST_PATH="tests/${TEST_TYPE}/" fi From 06903ffa8b4044931d888d717a8c83cd8c026e11 Mon Sep 17 00:00:00 2001 From: neil-sproston Date: Thu, 29 Jan 2026 14:19:25 +0000 Subject: [PATCH 3/7] [GPCAPIM-256] Enable mTLS --- .github/workflows/preview-env.yml | 46 +++++++++++++++++++-- infrastructure/environments/preview/main.tf | 4 +- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 4c6f1151..a1a6750e 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -195,6 +195,16 @@ jobs: --services ${{ steps.tf-output.outputs.ecs_service }} \ --region ${{ env.AWS_REGION }} + - name: Get mTLS certs for testing + if: github.event.action != 'closed' + id: mtls-certs + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + secret-ids: | + /cds/gateway/dev/mtls/client1-key-secret + /cds/gateway/dev/mtls/client1-key-public + name-transformation: lowercase + - name: Smoke test preview URL if: github.event.action != 'closed' id: smoke-test @@ -209,7 +219,18 @@ jobs: fi # Reachability check: allow 404 (app routes might not exist yet) but fail otherwise - STATUS=$(curl --silent --output /tmp/preview.headers --write-out '%{http_code}' --head --max-time 30 "$PREVIEW_URL" || true) + printf '%s' "$_cds_gateway_dev_mtls_client1_key_secret" > /tmp/client1-key.pem + printf '%s' "$_cds_gateway_dev_mtls_client1_key_public" > /tmp/client1-cert.pem + STATUS=$(curl \ + --cert /tmp/client1-cert.pem \ + --key /tmp/client1-key.pem \ + --silent \ + --output /tmp/preview.headers \ + --write-out '%{http_code}' \ + --head \ + --max-time 30 "$PREVIEW_URL"/health || true) + rm -f /tmp/client1-key.pem + rm -f /tmp/client1-cert.pem if [ "$STATUS" = "404" ]; then echo "Preview responded with expected 404" @@ -226,7 +247,11 @@ jobs: fi echo "Preview responded with unexpected status $STATUS" - cat /tmp/preview.headers + if [ -f /tmp/preview.headers ]; then + echo "Response headers:" + cat /tmp/preview.headers + fi + echo "http_status=$STATUS" >> "$GITHUB_OUTPUT" echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT" exit 0 @@ -277,7 +302,7 @@ jobs: const lines = [ '**Deployment Complete**', - `- Preview URL: [${url}](${url})`, + `- Preview URL: [${url}](${url}) — [Health endpoint](${url}/health)`, `- Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, `- ECS Cluster: \`${cluster}\``, `- ECS Service: \`${service}\``, @@ -290,3 +315,18 @@ jobs: issue_number: issueNumber, body: lines.join('\n'), }); + + # ---------- Security scanning ---------- + - name: Trivy filesystem scan + if: github.event.action != 'closed' + uses: nhs-england-tools/trivy-action/image-scan@3456c1657a37d500027fd782e6b08911725392da + with: + image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} + artifact-name: trivy-scan-${{ steps.meta.outputs.branch_name }} + + - name: Generate SBOM + uses: nhs-england-tools/trivy-action/sbom-scan@3456c1657a37d500027fd782e6b08911725392da + if: github.event.action != 'closed' + with: + image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} + artifact-name: trivy-sbom-${{ steps.meta.outputs.branch_name }} diff --git a/infrastructure/environments/preview/main.tf b/infrastructure/environments/preview/main.tf index f28bba5a..faab12f0 100644 --- a/infrastructure/environments/preview/main.tf +++ b/infrastructure/environments/preview/main.tf @@ -52,8 +52,8 @@ resource "aws_lb_target_group" "branch" { vpc_id = local.vpc_id health_check { - path = "/" - matcher = "200-499" + path = "/health" + matcher = "200-299" interval = 30 timeout = 5 unhealthy_threshold = 2 From e042737e036db918aceb7237823b5b26c025b8fa Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:57:53 +0000 Subject: [PATCH 4/7] [GPCAPIM-255] Controller module and module integration Creates the controller class that orchestrates calls to the other gateway components and to the GP provider. Integrates this with the API gateway, the PDS search module and the GP provider module. Updates integration-type tests to work with the steel thread. --- gateway-api/openapi.yaml | 100 +++ gateway-api/poetry.lock | 12 +- gateway-api/pyproject.toml | 2 +- gateway-api/src/gateway_api/app.py | 24 +- .../src/gateway_api/common/__init__.py | 0 gateway-api/src/gateway_api/common/common.py | 63 ++ gateway-api/src/gateway_api/common/py.typed | 0 .../src/gateway_api/common/test_common.py | 55 ++ gateway-api/src/gateway_api/controller.py | 313 ++++++++ .../get_structured_record/__init__.py | 8 +- .../get_structured_record/handler.py | 38 - .../get_structured_record/request.py | 58 +- .../get_structured_record/test_request.py | 241 ++++++- gateway-api/src/gateway_api/pds_search.py | 35 +- .../src/gateway_api/provider_request.py | 23 +- gateway-api/src/gateway_api/test_app.py | 127 +++- .../src/gateway_api/test_controller.py | 666 ++++++++++++++++++ .../src/gateway_api/test_pds_search.py | 103 ++- .../src/gateway_api/test_provider_request.py | 26 +- gateway-api/stubs/stubs/stub_pds.py | 158 +++-- gateway-api/stubs/stubs/stub_provider.py | 153 ++-- .../acceptance/features/happy_path.feature | 2 +- .../tests/acceptance/steps/happy_path.py | 12 +- gateway-api/tests/conftest.py | 48 +- ...GatewayAPIConsumer-GatewayAPIProvider.json | 35 +- .../tests/contract/test_consumer_contract.py | 60 +- .../tests/contract/test_provider_contract.py | 1 + .../integration/test_get_structured_record.py | 5 +- .../tests/schema/test_openapi_schema.py | 11 +- ruff.toml | 4 +- 30 files changed, 2020 insertions(+), 363 deletions(-) create mode 100644 gateway-api/src/gateway_api/common/__init__.py create mode 100644 gateway-api/src/gateway_api/common/common.py create mode 100644 gateway-api/src/gateway_api/common/py.typed create mode 100644 gateway-api/src/gateway_api/common/test_common.py create mode 100644 gateway-api/src/gateway_api/controller.py delete mode 100644 gateway-api/src/gateway_api/get_structured_record/handler.py create mode 100644 gateway-api/src/gateway_api/test_controller.py diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index 96b3f30e..1d03ded7 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -21,32 +21,60 @@ paths: type: string enum: [application/fhir+json] required: true + - in: header + name: Ods-from + required: true + schema: + type: string + example: "A12345" + minLength: 1 + - in: header + name: Ssp-TraceID + required: true + schema: + type: string + example: "trace-1234" + minLength: 1 requestBody: required: true content: application/fhir+json: schema: type: object + required: + - resourceType + - parameter properties: resourceType: type: string + enum: ["Parameters"] example: "Parameters" parameter: type: array + minItems: 1 items: type: object + required: + - name + - valueIdentifier properties: name: type: string + enum: ["patientNHSNumber"] example: "patientNHSNumber" valueIdentifier: type: object + required: + - system + - value properties: system: type: string + minLength: 1 example: "https://fhir.nhs.uk/Id/nhs-number" value: type: string + pattern: "^[0-9]{10}$" example: "9999999999" responses: '200': @@ -141,6 +169,78 @@ paths: type: string format: date example: "1985-04-12" + '400': + description: Bad request - invalid input parameters + content: + application/fhir+json: + schema: + type: object + properties: + resourceType: + type: string + example: "OperationOutcome" + issue: + type: array + items: + type: object + properties: + severity: + type: string + example: "error" + code: + type: string + example: "invalid" + diagnostics: + type: string + example: "Invalid NHS number format" + '404': + description: Patient not found + content: + application/fhir+json: + schema: + type: object + properties: + resourceType: + type: string + example: "OperationOutcome" + issue: + type: array + items: + type: object + properties: + severity: + type: string + example: "error" + code: + type: string + example: "not-found" + diagnostics: + type: string + example: "Patient not found" + '500': + description: Internal server error + content: + application/fhir+json: + schema: + type: object + properties: + resourceType: + type: string + example: "OperationOutcome" + issue: + type: array + items: + type: object + properties: + severity: + type: string + example: "error" + code: + type: string + example: "exception" + diagnostics: + type: string + example: "Internal server error" /health: get: summary: Health check diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 8ec2ddde..88b054f5 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -81,7 +81,7 @@ version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, @@ -190,7 +190,7 @@ version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -669,7 +669,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1733,7 +1733,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -2170,7 +2170,7 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, @@ -2360,4 +2360,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "30cdb09db37902c7051aa190c1e4c374dbfa6a14ca0c69131c0295ee33e7338f" +content-hash = "a452bd22e2386a3ff58b4c7a5ac2cb571de9e3d49a4fbc161ffd3aafa2a7bf44" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index fa79be03..748ebd4f 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -12,6 +12,7 @@ requires-python = ">3.13,<4.0.0" clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } flask = "^3.1.2" types-flask = "^1.1.6" +requests = "^2.32.5" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, @@ -51,7 +52,6 @@ dev = [ "pytest-html (>=4.1.1,<5.0.0)", "pact-python>=2.0.0", "python-dotenv>=1.0.0", - "requests>=2.31.0", "schemathesis>=4.4.1", "types-requests (>=2.32.4.20250913,<3.0.0.0)", "types-pyyaml (>=6.0.12.20250915,<7.0.0.0)", diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 8174fe17..265601e5 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,9 +4,10 @@ from flask import Flask, request from flask.wrappers import Response +from gateway_api.controller import Controller from gateway_api.get_structured_record import ( - GetStructuredRecordHandler, GetStructuredRecordRequest, + RequestValidationError, ) app = Flask(__name__) @@ -37,9 +38,28 @@ def get_app_port() -> int: def get_structured_record() -> Response: try: get_structured_record_request = GetStructuredRecordRequest(request) - GetStructuredRecordHandler.handle(get_structured_record_request) + except RequestValidationError as e: + response = Response( + response=str(e), + status=400, + content_type="text/plain", + ) + return response + except Exception as e: + response = Response( + response=f"Internal Server Error: {e}", + status=500, + content_type="text/plain", + ) + return response + + try: + controller = Controller() + flask_response = controller.run(request=get_structured_record_request) + get_structured_record_request.set_response_from_flaskresponse(flask_response) except Exception as e: get_structured_record_request.set_negative_response(str(e)) + return get_structured_record_request.build_response() diff --git a/gateway-api/src/gateway_api/common/__init__.py b/gateway-api/src/gateway_api/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py new file mode 100644 index 00000000..3891b8f3 --- /dev/null +++ b/gateway-api/src/gateway_api/common/common.py @@ -0,0 +1,63 @@ +""" +Shared lightweight types and helpers used across the gateway API. +""" + +import re +from dataclasses import dataclass + +# This project uses JSON request/response bodies as strings in the controller layer. +# The alias is used to make intent clearer in function signatures. +type json_str = str + + +@dataclass +class FlaskResponse: + """ + Lightweight response container returned by controller entry points. + + This mirrors the minimal set of fields used by the surrounding web framework. + + :param status_code: HTTP status code for the response (e.g., 200, 400, 404). + :param data: Response body as text, if any. + :param headers: Response headers, if any. + """ + + status_code: int + data: str | None = None + headers: dict[str, str] | None = None + + +def validate_nhs_number(value: str | int) -> bool: + """ + Validate an NHS number using the NHS modulus-11 check digit algorithm. + + The input may be a string or integer. Any non-digit separators in string + inputs (spaces, hyphens, etc.) are ignored. + + :param value: NHS number as a string or integer. Non-digit characters + are ignored when a string is provided. + :returns: ``True`` if the number is a valid NHS number, otherwise ``False``. + """ + str_value = str(value) # Just in case they passed an integer + digits = re.sub(r"[\s-]", "", str_value or "") + + if len(digits) != 10: + return False + if not digits.isdigit(): + return False + + first_nine = [int(ch) for ch in digits[:9]] + provided_check_digit = int(digits[9]) + + weights = list(range(10, 1, -1)) + total = sum(d * w for d, w in zip(first_nine, weights, strict=True)) + + remainder = total % 11 + check = 11 - remainder + + if check == 11: + check = 0 + if check == 10: + return False # invalid NHS number + + return check == provided_check_digit diff --git a/gateway-api/src/gateway_api/common/py.typed b/gateway-api/src/gateway_api/common/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py new file mode 100644 index 00000000..5deea64f --- /dev/null +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -0,0 +1,55 @@ +""" +Unit tests for :mod:`gateway_api.common.common`. +""" + +import pytest + +from gateway_api.common import common + + +@pytest.mark.parametrize( + ("nhs_number", "expected"), + [ + ("9434765919", True), # Just a number + ("943 476 5919", True), # Spaces are permitted + ("987-654-3210", True), # Hyphens are permitted + (9434765919, True), # Integer input is permitted + ("", False), # Empty string is invalid + ("943476591", False), # 9 digits + ("94347659190", False), # 11 digits + ("9434765918", False), # wrong check digit + ("NOT_A_NUMB", False), # non-numeric + ("943SOME_LETTERS4765919", False), # non-numeric in a valid NHS number + ], +) +def test_validate_nhs_number(nhs_number: str | int, expected: bool) -> None: + """ + Validate that separators (spaces, hyphens) are ignored and valid numbers pass. + """ + assert common.validate_nhs_number(nhs_number) is expected + + +@pytest.mark.parametrize( + ("nhs_number", "expected"), + [ + # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid + ("0000000000", True), + # First 9 digits produce remainder 1 => check 10 => invalid + ("0000000060", False), + ], +) +def test_validate_nhs_number_check_edge_cases_10_and_11( + nhs_number: str | int, expected: bool +) -> None: + """ + validate_nhs_number should behave correctly when the computed ``check`` value + is 10 or 11. + + - If ``check`` computes to 11, it should be treated as 0, so a number with check + digit 0 should validate successfully. + - If ``check`` computes to 10, the number is invalid and validation should return + False. + """ + # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid + # with check digit 0 + assert common.validate_nhs_number(nhs_number) is expected diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py new file mode 100644 index 00000000..4a17d08c --- /dev/null +++ b/gateway-api/src/gateway_api/controller.py @@ -0,0 +1,313 @@ +""" +Controller layer for orchestrating calls to external services +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from gateway_api.provider_request import GpProviderClient + +if TYPE_CHECKING: + from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +__all__ = ["json"] # Make mypy happy in tests + +from dataclasses import dataclass + +from gateway_api.common.common import FlaskResponse +from gateway_api.pds_search import PdsClient, PdsSearchResults + + +@dataclass +class RequestError(Exception): + """ + Raised (and handled) when there is a problem with the incoming request. + + Instances of this exception are caught by controller entry points and converted + into an appropriate :class:`FlaskResponse`. + + :param status_code: HTTP status code that should be returned. + :param message: Human-readable error message. + """ + + status_code: int + message: str + + def __str__(self) -> str: + """ + Coercing this exception to a string returns the error message. + + :returns: The error message. + """ + return self.message + + +@dataclass +class SdsSearchResults: + """ + Stub SDS search results dataclass. + + Replace this with the real one once it's implemented. + + :param asid: Accredited System ID. + :param endpoint: Endpoint URL associated with the organisation, if applicable. + """ + + asid: str + endpoint: str | None + + +class SdsClient: + """ + Stub SDS client for obtaining ASID from ODS code. + + Replace this with the real one once it's implemented. + """ + + SANDBOX_URL = "https://example.invalid/sds" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + """ + Create an SDS client. + + :param auth_token: Authentication token to present to SDS. + :param base_url: Base URL for SDS. + :param timeout: Timeout in seconds for SDS calls. + """ + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + """ + Retrieve SDS org details for a given ODS code. + + This is a placeholder implementation that always returns an ASID and endpoint. + + :param ods_code: ODS code to look up. + :returns: SDS search results or ``None`` if not found. + """ + # Placeholder implementation + return SdsSearchResults( + asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" + ) + + +class Controller: + """ + Orchestrates calls to PDS -> SDS -> GP provider. + + Entry point: + - ``call_gp_provider(request_body_json, headers, auth_token) -> FlaskResponse`` + """ + + gp_provider_client: GpProviderClient | None + + def __init__( + self, + pds_base_url: str = PdsClient.SANDBOX_URL, + sds_base_url: str = "https://example.invalid/sds", + nhsd_session_urid: str | None = None, + timeout: int = 10, + ) -> None: + """ + Create a controller instance. + + :param pds_base_url: Base URL for PDS client. + :param sds_base_url: Base URL for SDS client. + :param nhsd_session_urid: Session URID for NHS Digital session handling. + :param timeout: Timeout in seconds for downstream calls. + """ + self.pds_base_url = pds_base_url + self.sds_base_url = sds_base_url + self.nhsd_session_urid = nhsd_session_urid + self.timeout = timeout + self.gp_provider_client = None + + def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: + """ + Controller entry point + + Expects a GetStructuredRecordRequest instance that contains the header and body + details of the HTTP request received + + Orchestration steps: + 1) Call PDS to obtain the patient's GP (provider) ODS code. + 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. + 3) Call SDS using consumer ODS to obtain consumer ASID. + 4) Call GP provider to obtain patient records. + + :param request: A GetStructuredRecordRequest instance. + :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the + outcome. + """ + auth_token = self.get_auth_token() + + try: + provider_ods = self._get_pds_details( + auth_token, request.ods_from.strip(), request.nhs_number + ) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + try: + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + auth_token, request.ods_from.strip(), provider_ods + ) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + # Call GP provider with correct parameters + self.gp_provider_client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + response = self.gp_provider_client.access_structured_record( + trace_id=request.trace_id, + body=request.request_body, + ) + + # If we get a None from the GP provider, that means that either the service did + # not respond or we didn't make the request to the service in the first place. + # Therefore a None is a 502, any real response just pass straight back. + return FlaskResponse( + status_code=response.status_code if response is not None else 502, + data=response.text if response is not None else "GP provider service error", + headers=dict(response.headers) if response is not None else None, + ) + + def get_auth_token(self) -> str: + """ + Retrieve the authorization token. + + This is a placeholder implementation. Replace with actual logic to obtain + the auth token as needed. + + :returns: Authorization token as a string. + """ + # Placeholder implementation + return "PLACEHOLDER_AUTH_TOKEN" + + def _get_pds_details( + self, auth_token: str, consumer_ods: str, nhs_number: str + ) -> str: + """ + Call PDS to find the provider ODS code (GP ODS code) for a patient. + + :param auth_token: Authorization token to use for PDS. + :param consumer_ods: Consumer organisation ODS code (from request headers). + :param nhs_number: NHS number + :returns: Provider ODS code (GP ODS code). + :raises RequestError: If the patient cannot be found or has no provider ODS code + """ + # PDS: find patient and extract GP ODS code (provider ODS) + pds = PdsClient( + auth_token=auth_token, + end_user_org_ods=consumer_ods, + base_url=self.pds_base_url, + nhsd_session_urid=self.nhsd_session_urid, + timeout=self.timeout, + ignore_dates=True, + ) + + pds_result: PdsSearchResults | None = pds.search_patient_by_nhs_number( + nhs_number + ) + + if pds_result is None: + raise RequestError( + status_code=404, + message=f"No PDS patient found for NHS number {nhs_number}", + ) + + if pds_result.gp_ods_code: + provider_ods_code = pds_result.gp_ods_code + else: + raise RequestError( + status_code=404, + message=( + f"PDS patient {nhs_number} did not contain a current " + "provider ODS code" + ), + ) + + return provider_ods_code + + def _get_sds_details( + self, auth_token: str, consumer_ods: str, provider_ods: str + ) -> tuple[str, str, str]: + """ + Call SDS to obtain consumer ASID, provider ASID, and provider endpoint. + + This method performs two SDS lookups: + - provider details (ASID + endpoint) + - consumer details (ASID) + + :param auth_token: Authorization token to use for SDS. + :param consumer_ods: Consumer organisation ODS code (from request headers). + :param provider_ods: Provider organisation ODS code (from PDS). + :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). + :raises RequestError: If SDS data is missing or incomplete for provider/consumer + """ + # SDS: Get provider details (ASID + endpoint) for provider ODS + sds = SdsClient( + auth_token=auth_token, + base_url=self.sds_base_url, + timeout=self.timeout, + ) + + provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) + if provider_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for provider ODS code {provider_ods}", + ) + + provider_asid = (provider_details.asid or "").strip() + if not provider_asid: + raise RequestError( + status_code=404, + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current ASID" + ), + ) + + provider_endpoint = (provider_details.endpoint or "").strip() + if not provider_endpoint: + raise RequestError( + status_code=404, + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current endpoint" + ), + ) + + # SDS: Get consumer details (ASID) for consumer ODS + consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) + if consumer_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for consumer ODS code {consumer_ods}", + ) + + consumer_asid = (consumer_details.asid or "").strip() + if not consumer_asid: + raise RequestError( + status_code=404, + message=( + f"SDS result for consumer ODS code {consumer_ods} did not contain " + "a current ASID" + ), + ) + + return consumer_asid, provider_asid, provider_endpoint diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py index c279cb73..56dd174d 100644 --- a/gateway-api/src/gateway_api/get_structured_record/__init__.py +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -1,6 +1,8 @@ """Get Structured Record module.""" -from gateway_api.get_structured_record.handler import GetStructuredRecordHandler -from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.get_structured_record.request import ( + GetStructuredRecordRequest, + RequestValidationError, +) -__all__ = ["GetStructuredRecordHandler", "GetStructuredRecordRequest"] +__all__ = ["RequestValidationError", "GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structured_record/handler.py b/gateway-api/src/gateway_api/get_structured_record/handler.py deleted file mode 100644 index 15479f28..00000000 --- a/gateway-api/src/gateway_api/get_structured_record/handler.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from fhir import Bundle - -from gateway_api.get_structured_record.request import GetStructuredRecordRequest - - -class GetStructuredRecordHandler: - @classmethod - def handle(cls, request: GetStructuredRecordRequest) -> None: - bundle: Bundle = { - "resourceType": "Bundle", - "id": "example-patient-bundle", - "type": "collection", - "timestamp": "2026-01-12T10:00:00Z", - "entry": [ - { - "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", - "resource": { - "resourceType": "Patient", - "id": "9999999999", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [ - {"use": "official", "family": "Doe", "given": ["John"]} - ], - "gender": "male", - "birthDate": "1985-04-12", - }, - } - ], - } - request.set_positive_response(bundle) diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 141c3cda..c4279272 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -1,10 +1,19 @@ import json +from typing import TYPE_CHECKING from fhir import OperationOutcome, Parameters -from fhir.bundle import Bundle from fhir.operation_outcome import OperationOutcomeIssue from flask.wrappers import Request, Response +from gateway_api.common.common import FlaskResponse + +if TYPE_CHECKING: + from fhir.bundle import Bundle + + +class RequestValidationError(Exception): + """Exception raised for errors in the request validation.""" + class GetStructuredRecordRequest: INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" @@ -18,6 +27,9 @@ def __init__(self, request: Request) -> None: self._response_body: Bundle | OperationOutcome | None = None self._status_code: int | None = None + # Validate required headers + self._validate_headers() + @property def trace_id(self) -> str: trace_id: str = self._headers["Ssp-TraceID"] @@ -33,6 +45,25 @@ def ods_from(self) -> str: ods_from: str = self._headers["ODS-from"] return ods_from + @property + def request_body(self) -> str: + return json.dumps(self._request_body) + + def _validate_headers(self) -> None: + """Validate required headers are present and non-empty. + + :raises RequestValidationError: If required headers are missing or empty. + """ + trace_id = self._headers.get("Ssp-TraceID", "").strip() + if not trace_id: + raise RequestValidationError( + 'Missing or empty required header "Ssp-TraceID"' + ) + + ods_from = self._headers.get("ODS-from", "").strip() + if not ods_from: + raise RequestValidationError('Missing or empty required header "ODS-from"') + def build_response(self) -> Response: return Response( response=json.dumps(self._response_body), @@ -40,12 +71,8 @@ def build_response(self) -> Response: mimetype="application/fhir+json", ) - def set_positive_response(self, bundle: Bundle) -> None: - self._status_code = 200 - self._response_body = bundle - - def set_negative_response(self, error: str) -> None: - self._status_code = 500 + def set_negative_response(self, error: str, status_code: int = 500) -> None: + self._status_code = status_code self._response_body = OperationOutcome( resourceType="OperationOutcome", issue=[ @@ -56,3 +83,20 @@ def set_negative_response(self, error: str) -> None: ) ], ) + + def set_response_from_flaskresponse(self, flask_response: FlaskResponse) -> None: + if flask_response.data: + self._status_code = flask_response.status_code + try: + self._response_body = json.loads(flask_response.data) + except json.JSONDecodeError as err: + self.set_negative_response(f"Failed to decode response body: {err}") + except Exception as err: + self.set_negative_response( + f"Unexpected error decoding response body: {err}" + ) + else: + self.set_negative_response( + error="No response body received", + status_code=flask_response.status_code, + ) diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 7ff082c5..6fa5f9a2 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -1,26 +1,39 @@ +import json +from typing import TYPE_CHECKING, cast + import pytest from fhir.parameters import Parameters from flask import Request +from werkzeug.test import EnvironBuilder +from gateway_api.common.common import FlaskResponse +from gateway_api.get_structured_record import RequestValidationError from gateway_api.get_structured_record.request import GetStructuredRecordRequest +if TYPE_CHECKING: + from fhir.bundle import Bundle -class MockRequest: - def __init__(self, headers: dict[str, str], body: Parameters) -> None: - self.headers = headers - self.body = body - def get_json(self) -> Parameters: - return self.body +def create_mock_request(headers: dict[str, str], body: Parameters) -> Request: + """Create a proper Flask Request object with headers and JSON body.""" + builder = EnvironBuilder( + method="POST", + path="/patient/$gpc.getstructuredrecord", + data=json.dumps(body), + content_type="application/fhir+json", + headers=headers, + ) + env = builder.get_environ() + return Request(env) @pytest.fixture -def mock_request_with_headers(valid_simple_request_payload: Parameters) -> MockRequest: +def mock_request_with_headers(valid_simple_request_payload: Parameters) -> Request: headers = { "Ssp-TraceID": "test-trace-id", "ODS-from": "test-ods", } - return MockRequest(headers, valid_simple_request_payload) + return create_mock_request(headers, valid_simple_request_payload) class TestGetStructuredRecordRequest: @@ -56,3 +69,215 @@ def test_nhs_number_is_pulled_from_request_body( actual = get_structured_record_request.nhs_number expected = "9999999999" assert actual == expected + + def test_raises_value_error_when_ods_from_header_is_missing( + self, valid_simple_request_payload: Parameters + ) -> None: + """Test that ValueError is raised when ODS-from header is missing.""" + headers = { + "Ssp-TraceID": "test-trace-id", + } + mock_request = create_mock_request(headers, valid_simple_request_payload) + + with pytest.raises( + RequestValidationError, match='Missing or empty required header "ODS-from"' + ): + GetStructuredRecordRequest(request=mock_request) + + def test_raises_value_error_when_ods_from_header_is_whitespace( + self, valid_simple_request_payload: Parameters + ) -> None: + """ + Test that ValueError is raised when ODS-from header contains only whitespace. + """ + headers = { + "Ssp-TraceID": "test-trace-id", + "ODS-from": " ", + } + mock_request = create_mock_request(headers, valid_simple_request_payload) + + with pytest.raises( + RequestValidationError, match='Missing or empty required header "ODS-from"' + ): + GetStructuredRecordRequest(request=mock_request) + + def test_raises_value_error_when_trace_id_header_is_missing( + self, valid_simple_request_payload: Parameters + ) -> None: + """Test that ValueError is raised when Ssp-TraceID header is missing.""" + headers = { + "ODS-from": "test-ods", + } + mock_request = create_mock_request(headers, valid_simple_request_payload) + + with pytest.raises( + RequestValidationError, + match='Missing or empty required header "Ssp-TraceID"', + ): + GetStructuredRecordRequest(request=mock_request) + + def test_raises_value_error_when_trace_id_header_is_whitespace( + self, valid_simple_request_payload: Parameters + ) -> None: + """ + Test that ValueError is raised when Ssp-TraceID header contains only whitespace. + """ + headers = { + "Ssp-TraceID": " ", + "ODS-from": "test-ods", + } + mock_request = create_mock_request(headers, valid_simple_request_payload) + + with pytest.raises( + RequestValidationError, + match='Missing or empty required header "Ssp-TraceID"', + ): + GetStructuredRecordRequest(request=mock_request) + + +class TestSetResponseFromFlaskResponse: + def test_sets_response_body_from_valid_json_data( + self, mock_request_with_headers: Request + ) -> None: + """Test that valid JSON data is correctly parsed and set.""" + + request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) + + bundle_data: Bundle = { + "resourceType": "Bundle", + "id": "test-bundle", + "type": "collection", + "timestamp": "2026-02-03T10:00:00Z", + "entry": [], + } + flask_response = FlaskResponse( + status_code=200, + data=json.dumps(bundle_data), + headers={"Content-Type": "application/fhir+json"}, + ) + + request_obj.set_response_from_flaskresponse(flask_response) + + resp = request_obj.build_response() + assert resp.status == "200 OK" + assert resp.response is not None + assert cast("list[bytes]", resp.response)[0].decode("utf-8") == json.dumps( + bundle_data + ) + + def test_handles_json_decode_error( + self, mock_request_with_headers: Request + ) -> None: + """Test that JSONDecodeError is handled and sets negative response.""" + request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) + + flask_response = FlaskResponse( + status_code=200, + data="invalid json {not valid}", + headers={"Content-Type": "application/fhir+json"}, + ) + + request_obj.set_response_from_flaskresponse(flask_response) + + resp = request_obj.build_response() + assert resp.status == "500 INTERNAL SERVER ERROR" + assert resp.response is not None + response_data = json.loads( + cast("list[bytes]", resp.response)[0].decode("utf-8") + ) + assert response_data["resourceType"] == "OperationOutcome" + assert len(response_data["issue"]) == 1 + assert ( + "Failed to decode response body:" + in response_data["issue"][0]["diagnostics"] + ) + + def test_handles_unexpected_exception_during_json_decode( + self, mock_request_with_headers: Request, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that unexpected exceptions during JSON parsing are handled.""" + request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) + + flask_response = FlaskResponse( + status_code=200, + data='{"valid": "json"}', + headers={"Content-Type": "application/fhir+json"}, + ) + + # Mock json.loads to raise an unexpected exception + original_json_loads = json.loads + + def mock_json_loads(data: str) -> None: # noqa: ARG001 + raise RuntimeError("Unexpected error during JSON parsing") + + monkeypatch.setattr(json, "loads", mock_json_loads) + + request_obj.set_response_from_flaskresponse(flask_response) + + # Restore json.loads before building response + monkeypatch.setattr(json, "loads", original_json_loads) + + resp = request_obj.build_response() + assert resp.status == "500 INTERNAL SERVER ERROR" + assert resp.response is not None + response_data = json.loads( + cast("list[bytes]", resp.response)[0].decode("utf-8") + ) + assert response_data["resourceType"] == "OperationOutcome" + assert len(response_data["issue"]) == 1 + assert ( + "Unexpected error decoding response body:" + in response_data["issue"][0]["diagnostics"] + ) + assert ( + "Unexpected error during JSON parsing" + in response_data["issue"][0]["diagnostics"] + ) + + def test_handles_empty_response_data( + self, mock_request_with_headers: Request + ) -> None: + """Test that empty/None response data is handled correctly.""" + request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) + + flask_response = FlaskResponse( + status_code=404, + data=None, + headers={"Content-Type": "application/fhir+json"}, + ) + + request_obj.set_response_from_flaskresponse(flask_response) + + resp = request_obj.build_response() + assert resp.status == "404 NOT FOUND" + assert resp.response is not None + response_data = json.loads( + cast("list[bytes]", resp.response)[0].decode("utf-8") + ) + assert response_data["resourceType"] == "OperationOutcome" + assert len(response_data["issue"]) == 1 + assert response_data["issue"][0]["diagnostics"] == "No response body received" + + def test_handles_empty_string_response_data( + self, mock_request_with_headers: Request + ) -> None: + """Test that empty string response data is handled as no data.""" + request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) + + flask_response = FlaskResponse( + status_code=500, + data="", + headers={"Content-Type": "application/fhir+json"}, + ) + + request_obj.set_response_from_flaskresponse(flask_response) + + resp = request_obj.build_response() + assert resp.status == "500 INTERNAL SERVER ERROR" + assert resp.response is not None + response_data = json.loads( + cast("list[bytes]", resp.response)[0].decode("utf-8") + ) + assert response_data["resourceType"] == "OperationOutcome" + assert len(response_data["issue"]) == 1 + assert response_data["issue"][0]["diagnostics"] == "No response body received" diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index cddcc056..b21b6ecf 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -21,17 +21,22 @@ from __future__ import annotations import uuid +from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime, timezone from typing import cast import requests +from stubs.stub_pds import PdsFhirApiStub # Recursive JSON-like structure typing used for parsed FHIR bodies. type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] type ResultStructureDict = dict[str, ResultStructure] type ResultList = list[ResultStructureDict] +# Type for stub get method +type GetCallable = Callable[..., requests.Response] + class ExternalServiceError(Exception): """ @@ -44,7 +49,7 @@ class ExternalServiceError(Exception): @dataclass -class SearchResults: +class PdsSearchResults: """ A single extracted patient record. @@ -74,7 +79,7 @@ class PdsClient: * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` - This method returns a :class:`SearchResults` instance when a patient can be + This method returns a :class:`PdsSearchResults` instance when a patient can be extracted, otherwise ``None``. **Usage example**:: @@ -123,6 +128,13 @@ def __init__( self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout self.ignore_dates = ignore_dates + self.stub = PdsFhirApiStub() + + # TODO: Put this back to using the environment variable + # if os.environ.get("STUB_PDS", None): + self.get_method: GetCallable = self.stub.get + # else: + # self.get_method: GetCallable = requests.get def _build_headers( self, @@ -160,16 +172,16 @@ def _build_headers( def search_patient_by_nhs_number( self, - nhs_number: int, + nhs_number: str, request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, - ) -> SearchResults | None: + ) -> PdsSearchResults | None: """ Retrieve a patient by NHS number. Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`SearchResults`. + resource on success, then extracts a single :class:`PdsSearchResults`. :param nhs_number: NHS number to search for. :param request_id: Optional request ID to reuse for retries; if not supplied a @@ -177,7 +189,7 @@ def search_patient_by_nhs_number( :param correlation_id: Optional correlation ID for tracing. :param timeout: Optional per-call timeout in seconds. If not provided, :attr:`timeout` is used. - :return: A :class:`SearchResults` instance if a patient can be extracted, + :return: A :class:`PdsSearchResults` instance if a patient can be extracted, otherwise ``None``. :raises ExternalServiceError: If the HTTP request returns an error status and ``raise_for_status()`` raises :class:`requests.HTTPError`. @@ -189,7 +201,8 @@ def search_patient_by_nhs_number( url = f"{self.base_url}/Patient/{nhs_number}" - response = requests.get( + # This normally calls requests.get, but if STUB_PDS is set it uses the stub. + response = self.get_method( url, headers=headers, params={}, @@ -241,9 +254,9 @@ def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: def _extract_single_search_result( self, body: ResultStructureDict - ) -> SearchResults | None: + ) -> PdsSearchResults | None: """ - Extract a single :class:`SearchResults` from a Patient response. + Extract a single :class:`PdsSearchResults` from a Patient response. This helper accepts either: * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or @@ -253,7 +266,7 @@ def _extract_single_search_result( single match; if multiple entries are present, the first entry is used. :param body: Parsed JSON body containing either a Patient resource or a Bundle whose first entry contains a Patient resource under ``resource``. - :return: A populated :class:`SearchResults` if extraction succeeds, otherwise + :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise ``None``. """ # Accept either: @@ -294,7 +307,7 @@ def _extract_single_search_result( gp_list = cast("ResultList", patient.get("generalPractitioner", [])) gp_ods_code = self._get_gp_ods_code(gp_list) - return SearchResults( + return PdsSearchResults( given_names=given_names_str, family_name=family_name, nhs_number=nhs_number, diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider_request.py index b43e4069..a628dbcf 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider_request.py @@ -25,8 +25,8 @@ from collections.abc import Callable from urllib.parse import urljoin -from requests import HTTPError, Response -from stubs.stub_provider import GpProviderStub +from requests import HTTPError, Response, post +from stubs.stub_provider import stub_post ARS_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:structured" @@ -37,18 +37,13 @@ ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" TIMEOUT: int | None = None # None used for quicker dev, adjust as needed -# Direct all requests to the stub provider for steel threading in dev. -# Replace with `from requests import post` for real requests. -PostCallable = Callable[..., Response] -_provider_stub = GpProviderStub() - - -def _stubbed_post(trace_id: str, body: str) -> Response: - """A stubbed requests.post function that routes to the GPProviderStub.""" - return _provider_stub.access_record_structured(trace_id, body) - - -post: PostCallable = _stubbed_post +# TODO: Put the environment variable check back in +# if os.environ.get("STUB_PROVIDER", None): +if True: # NOSONAR S5797 (Yes, I know it's always true, this is temporary) + # Direct all requests to the stub provider for steel threading in dev. + # Replace with `from requests import post` for real requests. + PostCallable = Callable[..., Response] + post: PostCallable = stub_post # type: ignore[no-redef] class ExternalServiceError(Exception): diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 18a4b0f2..fdf77815 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -1,5 +1,6 @@ """Unit tests for the Flask app endpoints.""" +import json import os from collections.abc import Generator from typing import TYPE_CHECKING @@ -9,6 +10,11 @@ from flask.testing import FlaskClient from gateway_api.app import app, get_app_host, get_app_port +from gateway_api.controller import Controller +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +if TYPE_CHECKING: + from fhir.parameters import Parameters if TYPE_CHECKING: from fhir.parameters import Parameters @@ -49,10 +55,64 @@ def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: class TestGetStructuredRecord: def test_get_structured_record_returns_200_with_bundle( - self, client: FlaskClient[Flask], valid_simple_request_payload: "Parameters" + self, + client: FlaskClient[Flask], + monkeypatch: pytest.MonkeyPatch, + valid_simple_request_payload: "Parameters", ) -> None: + """Test that successful controller response is returned correctly.""" + from datetime import datetime, timezone + from typing import Any + + from gateway_api.common.common import FlaskResponse + + # Mock the controller to return a successful FlaskResponse with a Bundle + mock_bundle_data: Any = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": datetime.now(timezone.utc).isoformat(), + "entry": [ + { + "fullUrl": "http://example.com/Patient/9999999999", + "resource": { + "name": [ + {"family": "Alice", "given": ["Johnson"], "use": "Ally"} + ], + "gender": "female", + "birthDate": "1990-05-15", + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + {"value": "9999999999", "system": "urn:nhs:numbers"} + ], + }, + } + ], + } + + def mock_run( + self: Controller, # noqa: ARG001 + request: GetStructuredRecordRequest, # noqa: ARG001 + ) -> FlaskResponse: + return FlaskResponse( + status_code=200, + data=json.dumps(mock_bundle_data), + headers={"Content-Type": "application/fhir+json"}, + ) + + monkeypatch.setattr( + "gateway_api.controller.Controller.run", + mock_run, + ) + response = client.post( - "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload + "/patient/$gpc.getstructuredrecord", + json=valid_simple_request_payload, + headers={ + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + }, ) assert response.status_code == 200 @@ -74,15 +134,72 @@ def test_get_structured_record_handles_exception( monkeypatch: pytest.MonkeyPatch, valid_simple_request_payload: "Parameters", ) -> None: + """ + Test that exceptions during controller execution are caught and return 500. + """ + + # This is mocking the run method of the Controller + # and therefore self is a Controller + def mock_run_with_exception( + self: Controller, # noqa: ARG001 + request: GetStructuredRecordRequest, # noqa: ARG001 + ) -> None: + raise ValueError("Test exception") + monkeypatch.setattr( - "gateway_api.get_structured_record.GetStructuredRecordHandler.handle", - Exception(), + "gateway_api.controller.Controller.run", + mock_run_with_exception, + ) + + response = client.post( + "/patient/$gpc.getstructuredrecord", + json=valid_simple_request_payload, + headers={ + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + }, + ) + assert response.status_code == 500 + + def test_get_structured_record_handles_request_validation_error( + self, + client: FlaskClient[Flask], + valid_simple_request_payload: "Parameters", + ) -> None: + """Test that RequestValidationError returns 400 with error message.""" + # Create a request missing the required ODS-from header + response = client.post( + "/patient/$gpc.getstructuredrecord", + json=valid_simple_request_payload, + headers={ + "Ssp-TraceID": "test-trace-id", + # Missing "ODS-from" header to trigger RequestValidationError + }, ) + assert response.status_code == 400 + assert "text/plain" in response.content_type + assert b'Missing or empty required header "ODS-from"' in response.data + + def test_get_structured_record_handles_unexpected_exception_during_init( + self, + client: FlaskClient[Flask], + ) -> None: + """Test that unexpected exceptions during request init return 500.""" + # Send invalid JSON to trigger an exception during request processing response = client.post( - "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload + "/patient/$gpc.getstructuredrecord", + data="invalid json data", + headers={ + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + "Content-Type": "application/fhir+json", + }, ) + assert response.status_code == 500 + assert "text/plain" in response.content_type + assert b"Internal Server Error:" in response.data class TestHealthCheck: diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py new file mode 100644 index 00000000..3fc3ded4 --- /dev/null +++ b/gateway-api/src/gateway_api/test_controller.py @@ -0,0 +1,666 @@ +""" +Unit tests for :mod:`gateway_api.controller`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest +from flask import request as flask_request +from requests import Response + +import gateway_api.controller as controller_module +from gateway_api.app import app +from gateway_api.controller import ( + Controller, + SdsSearchResults, +) +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +if TYPE_CHECKING: + from collections.abc import Generator + + from gateway_api.common.common import json_str + + +# ----------------------------- +# Fake downstream dependencies +# ----------------------------- +def _make_pds_result(gp_ods_code: str | None) -> Any: + """ + Construct a minimal PDS-result-like object for tests. + + The controller only relies on the ``gp_ods_code`` attribute. + + :param gp_ods_code: Provider ODS code to expose on the result. + :returns: An object with a ``gp_ods_code`` attribute. + """ + return SimpleNamespace(gp_ods_code=gp_ods_code) + + +class FakePdsClient: + """ + Test double for :class:`gateway_api.pds_search.PdsClient`. + + The controller instantiates this class and calls ``search_patient_by_nhs_number``. + Tests configure the returned patient details using ``set_patient_details``. + """ + + last_init: dict[str, Any] | None = None + + def __init__(self, **kwargs: Any) -> None: + FakePdsClient.last_init = dict(kwargs) + self._patient_details: Any | None = None + + def set_patient_details(self, value: Any) -> None: + self._patient_details = value + + def search_patient_by_nhs_number( + self, + nhs_number: int, # noqa: ARG002 (unused in fake) + ) -> Any | None: + return self._patient_details + + +class FakeSdsClient: + """ + Test double for :class:`gateway_api.controller.SdsClient`. + + Tests configure per-ODS results using ``set_org_details`` and the controller + retrieves them via ``get_org_details``. + """ + + last_init: dict[str, Any] | None = None + + def __init__( + self, + auth_token: str | None = None, + base_url: str = "test_url", + timeout: int = 10, + ) -> None: + FakeSdsClient.last_init = { + "auth_token": auth_token, + "base_url": base_url, + "timeout": timeout, + } + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + self._org_details_by_ods: dict[str, SdsSearchResults | None] = {} + + def set_org_details( + self, ods_code: str, org_details: SdsSearchResults | None + ) -> None: + self._org_details_by_ods[ods_code] = org_details + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + return self._org_details_by_ods.get(ods_code) + + +class FakeGpProviderClient: + """ + Test double for :class:`gateway_api.controller.GpProviderClient`. + + The controller instantiates this class and calls ``access_structured_record``. + Tests configure the returned HTTP response using class-level attributes. + """ + + last_init: dict[str, str] | None = None + last_call: dict[str, str] | None = None + + # Configure per-test. + return_none: bool = False + response_status_code: int = 200 + response_body: bytes = b"ok" + response_headers: dict[str, str] = {"Content-Type": "application/fhir+json"} + + def __init__( + self, provider_endpoint: str, provider_asid: str, consumer_asid: str + ) -> None: + FakeGpProviderClient.last_init = { + "provider_endpoint": provider_endpoint, + "provider_asid": provider_asid, + "consumer_asid": consumer_asid, + } + + def access_structured_record( + self, + trace_id: str, + body: json_str, + ) -> Response | None: + FakeGpProviderClient.last_call = {"trace_id": trace_id, "body": body} + + if FakeGpProviderClient.return_none: + return None + + resp = Response() + resp.status_code = FakeGpProviderClient.response_status_code + resp._content = FakeGpProviderClient.response_body # noqa: SLF001 + resp.encoding = "utf-8" + resp.headers.update(FakeGpProviderClient.response_headers) + resp.url = "https://example.invalid/fake" + return resp + + +@dataclass +class SdsSetup: + """ + Helper dataclass to hold SDS setup data for tests. + """ + + ods_code: str + search_results: SdsSearchResults + + +class sds_factory: + """ + Factory to create a :class:`FakeSdsClient` pre-configured with up to two + organisations. + """ + + def __init__( + self, + org1: SdsSetup | None = None, + org2: SdsSetup | None = None, + ) -> None: + self.org1 = org1 + self.org2 = org2 + + def __call__(self, **kwargs: Any) -> FakeSdsClient: + self.inst = FakeSdsClient(**kwargs) + if self.org1 is not None: + self.inst.set_org_details( + self.org1.ods_code, + SdsSearchResults( + asid=self.org1.search_results.asid, + endpoint=self.org1.search_results.endpoint, + ), + ) + + if self.org2 is not None: + self.inst.set_org_details( + self.org2.ods_code, + SdsSearchResults( + asid=self.org2.search_results.asid, + endpoint=self.org2.search_results.endpoint, + ), + ) + return self.inst + + +class pds_factory: + """ + Factory to create a :class:`FakePdsClient` pre-configured with patient details. + """ + + def __init__(self, ods_code: str | None) -> None: + self.ods_code = ods_code + + def __call__(self, **kwargs: Any) -> FakePdsClient: + self.inst = FakePdsClient(**kwargs) + self.inst.set_patient_details(_make_pds_result(self.ods_code)) + return self.inst + + +@pytest.fixture +def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Patch controller dependencies to use test fakes. + """ + monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) + monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) + monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) + + +@pytest.fixture +def controller() -> Controller: + """ + Construct a controller instance configured for unit tests. + """ + return Controller( + pds_base_url="https://pds.example", + sds_base_url="https://sds.example", + nhsd_session_urid="session-123", + timeout=3, + ) + + +@pytest.fixture +def gp_provider_returns_none() -> Generator[None, None, None]: + """ + Configure FakeGpProviderClient to return None and reset after the test. + """ + FakeGpProviderClient.return_none = True + yield + FakeGpProviderClient.return_none = False + + +@pytest.fixture +def get_structured_record_request( + request: pytest.FixtureRequest, +) -> GetStructuredRecordRequest: + # Pass two dicts to this fixture that give dicts to add to + # header and body respectively. + header_update, body_update = request.param + + headers = { + "Ssp-TraceID": "3d7f2a6e-0f4e-4af3-9b7b-2a3d5f6a7b8c", + "ODS-from": "CONSUMER", + } + + headers.update(header_update) + + body = { + "resourceType": "Parameters", + "parameter": [ + { + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + } + ], + } + + body.update(body_update) + + with app.test_request_context( + path="/patient/$gpc.getstructuredrecord", + method="POST", + headers=headers, + json=body, + ): + return GetStructuredRecordRequest(flask_request) + + +# ----------------------------- +# Unit tests +# ----------------------------- + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_200_on_success( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + On successful end-to-end call, the controller should return 200 with + expected body/headers. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + FakeGpProviderClient.response_status_code = 200 + FakeGpProviderClient.response_body = b'{"resourceType":"Bundle"}' + FakeGpProviderClient.response_headers = { + "Content-Type": "application/fhir+json", + "X-Downstream": "gp-provider", + } + + r = controller.run(get_structured_record_request) + + # Check that response from GP provider was passed through. + assert r.status_code == 200 + assert r.data == FakeGpProviderClient.response_body.decode("utf-8") + assert r.headers == FakeGpProviderClient.response_headers + + # Check that GP provider was initialised correctly + assert FakeGpProviderClient.last_init == { + "provider_endpoint": "https://provider.example/ep", + "provider_asid": "asid_PROV", + "consumer_asid": "asid_CONS", + } + + # Check that we passed the trace ID and body to the provider + assert FakeGpProviderClient.last_call == { + "trace_id": get_structured_record_request.trace_id, + "body": get_structured_record_request.request_body, + } + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_pds_patient_not_found( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If PDS returns no patient record, the controller should return 404. + """ + # FakePdsClient defaults to returning None => RequestError => 404 + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "No PDS patient found for NHS number" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_gp_ods_code_missing( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If PDS returns a patient without a provider (GP) ODS code, return 404. + """ + pds = pds_factory(ods_code="") + monkeypatch.setattr(controller_module, "PdsClient", pds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current provider ODS code" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If SDS returns no provider org details, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds = sds_factory() + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "No SDS org found for provider ODS code PROVIDER" + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If provider ASID is blank/whitespace, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid=" ", endpoint="https://provider.example/ep" + ), + ) + sds = sds_factory(org1=sds_org1) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_502_when_gp_provider_returns_none( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, + gp_provider_returns_none: None, # NOQA ARG001 (Fixture handling setup/teardown) +) -> None: + """ + If GP provider returns no response object, the controller should return 502. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 502 + assert r.data == "GP provider service error" + assert r.headers is None + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + Validate that the controller constructs the PDS client with expected kwargs. + """ + _ = controller.run(get_structured_record_request) # will stop at PDS None => 404 + + assert FakePdsClient.last_init is not None + assert FakePdsClient.last_init["auth_token"] == "PLACEHOLDER_AUTH_TOKEN" # noqa: S105 + assert FakePdsClient.last_init["end_user_org_ods"] == "CONSUMER" + assert FakePdsClient.last_init["base_url"] == "https://pds.example" + assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" + assert FakePdsClient.last_init["timeout"] == 3 + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {"parameter": [{"valueIdentifier": {"value": "1234567890"}}]})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_404_message_includes_nhs_number_from_request_body( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If PDS returns no patient record, error message should include NHS number parsed + from the FHIR Parameters request body. + """ + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "No PDS patient found for NHS number 1234567890" + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If provider endpoint is blank/whitespace, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults(asid="asid_PROV", endpoint=" "), + ) + sds = sds_factory(org1=sds_org1) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current endpoint" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If SDS returns no consumer org details, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds = sds_factory(org1=sds_org1) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "No SDS org found for consumer ODS code CONSUMER" + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If consumer ASID is blank/whitespace, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid=" ", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_passthroughs_non_200_gp_provider_response( + patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + Validate that non-200 responses from GP provider are passed through. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + FakeGpProviderClient.response_status_code = 404 + FakeGpProviderClient.response_body = b"Not Found" + FakeGpProviderClient.response_headers = { + "Content-Type": "text/plain", + "X-Downstream": "gp-provider", + } + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "Not Found" + assert r.headers is not None + assert r.headers.get("Content-Type") == "text/plain" + assert r.headers.get("X-Downstream") == "gp-provider" diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py index 78ed9e73..a433c9a1 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/test_pds_search.py @@ -4,16 +4,18 @@ from __future__ import annotations -import re from dataclasses import dataclass from datetime import date -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 import pytest import requests from stubs.stub_pds import PdsFhirApiStub +if TYPE_CHECKING: + from requests.structures import CaseInsensitiveDict + from gateway_api.pds_search import ( ExternalServiceError, PdsClient, @@ -30,12 +32,12 @@ class FakeResponse: implemented. :param status_code: HTTP status code. - :param headers: Response headers. + :param headers: Response headers (dict or CaseInsensitiveDict). :param _json: Parsed JSON body returned by :meth:`json`. """ status_code: int - headers: dict[str, str] + headers: dict[str, str] | CaseInsensitiveDict[str] _json: dict[str, Any] reason: str = "" @@ -78,7 +80,7 @@ def mock_requests_get( monkeypatch: pytest.MonkeyPatch, stub: PdsFhirApiStub ) -> dict[str, Any]: """ - Patch ``requests.get`` so calls are routed into :meth:`PdsFhirApiStub.get_patient`. + Patch ``PdsFhirApiStub`` so the PdsClient uses the test stub fixture. The fixture returns a "capture" dict recording the most recent request information. This is used by header-related tests. @@ -90,21 +92,23 @@ def mock_requests_get( """ capture: dict[str, Any] = {} - def _fake_get( + # Wrap the stub's get method to capture call parameters + original_stub_get = stub.get + + def _capturing_get( url: str, headers: dict[str, str] | None = None, params: Any = None, timeout: Any = None, - ) -> FakeResponse: + ) -> requests.Response: """ - Replacement function for :func:`requests.get`. + Wrapper around stub.get that captures parameters. :param url: URL passed by the client. :param headers: Headers passed by the client. - :param params: Query parameters (recorded, not interpreted for - GET /Patient/{id}). - :param timeout: Timeout (recorded). - :return: A :class:`FakeResponse` whose behaviour mimics ``requests.Response``. + :param params: Query parameters. + :param timeout: Timeout. + :return: Response from the stub. """ headers = headers or {} capture["url"] = url @@ -112,45 +116,19 @@ def _fake_get( capture["params"] = params capture["timeout"] = timeout - # The client under test is expected to call GET {base_url}/Patient/{id}. - m = re.match(r"^(?P.+)/Patient/(?P\d+)$", url) - if not m: - raise AssertionError(f"Unexpected URL called by client: {url}") - - nhs_number = m.group("nhs") - - # Route the "HTTP" request into the in-memory stub. - stub_resp = stub.get_patient( - nhs_number=nhs_number, - request_id=headers.get("X-Request-ID"), - correlation_id=headers.get("X-Correlation-ID"), - authorization=headers.get("Authorization"), - end_user_org_ods=headers.get("NHSD-End-User-Organisation-ODS"), - ) - - # GET /Patient/{id} returns a single Patient resource on success. - body = stub_resp.json - # Populate a reason phrase so PdsClient can surface it in ExternalServiceError. - reason = "" - if stub_resp.status_code != 200: - # Try to use OperationOutcome display text if present. - issue0 = (stub_resp.json.get("issue") or [{}])[0] - details = issue0.get("details") or {} - coding0 = (details.get("coding") or [{}])[0] - reason = str(coding0.get("display") or "") - if not reason: - reason = {400: "Bad Request", 404: "Not Found"}.get( - stub_resp.status_code, "" - ) - - return FakeResponse( - status_code=stub_resp.status_code, - headers=stub_resp.headers, - _json=body, - reason=reason, - ) - - monkeypatch.setattr(requests, "get", _fake_get) + return original_stub_get(url, headers, params, timeout) + + stub.get = _capturing_get # type: ignore[method-assign] + + # Monkeypatch PdsFhirApiStub so PdsClient uses our test stub + import gateway_api.pds_search as pds_module + + monkeypatch.setattr( + pds_module, + "PdsFhirApiStub", + lambda *args, **kwargs: stub, # NOQA ARG005 (maintain signature) + ) + return capture @@ -192,13 +170,13 @@ def _insert_basic_patient( def test_search_patient_by_nhs_number_get_patient_success( stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], + mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ Verify ``GET /Patient/{nhs_number}`` returns 200 and demographics are extracted. This test explicitly inserts the patient into the stub and asserts that the client - returns a populated :class:`gateway_api.pds_search.SearchResults`. + returns a populated :class:`gateway_api.pds_search.PdsSearchResults`. :param stub: Stub backend fixture. :param mock_requests_get: Patched ``requests.get`` fixture @@ -220,7 +198,7 @@ def test_search_patient_by_nhs_number_get_patient_success( nhsd_session_urid="test-urid", ) - result = client.search_patient_by_nhs_number(9000000009) + result = client.search_patient_by_nhs_number("9000000009") assert result is not None assert result.nhs_number == "9000000009" @@ -231,7 +209,7 @@ def test_search_patient_by_nhs_number_get_patient_success( def test_search_patient_by_nhs_number_no_current_gp_returns_gp_ods_code_none( stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], + mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ Verify that ``gp_ods_code`` is ``None`` when no GP record is current. @@ -272,7 +250,7 @@ def test_search_patient_by_nhs_number_no_current_gp_returns_gp_ods_code_none( base_url="https://example.test/personal-demographics/FHIR/R4", ) - result = client.search_patient_by_nhs_number(9000000018) + result = client.search_patient_by_nhs_number("9000000018") assert result is not None assert result.nhs_number == "9000000018" @@ -317,7 +295,7 @@ def test_search_patient_by_nhs_number_sends_expected_headers( corr_id = "corr-123" result = client.search_patient_by_nhs_number( - 9000000009, + "9000000009", request_id=req_id, correlation_id=corr_id, ) @@ -360,7 +338,7 @@ def test_search_patient_by_nhs_number_generates_request_id( base_url="https://example.test/personal-demographics/FHIR/R4", ) - result = client.search_patient_by_nhs_number(9000000009) + result = client.search_patient_by_nhs_number("9000000009") assert result is not None headers = mock_requests_get["headers"] @@ -370,8 +348,7 @@ def test_search_patient_by_nhs_number_generates_request_id( def test_search_patient_by_nhs_number_not_found_raises_error( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], + mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ Verify that a 404 response results in :class:`ExternalServiceError`. @@ -391,12 +368,12 @@ def test_search_patient_by_nhs_number_not_found_raises_error( ) with pytest.raises(ExternalServiceError): - pds.search_patient_by_nhs_number(9900000001) + pds.search_patient_by_nhs_number("9900000001") def test_search_patient_by_nhs_number_extracts_current_gp_ods_code( stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], + mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ Verify that a current GP record is selected and its ODS code returned. @@ -452,7 +429,7 @@ def test_search_patient_by_nhs_number_extracts_current_gp_ods_code( base_url="https://example.test/personal-demographics/FHIR/R4", ) - result = client.search_patient_by_nhs_number(9000000017) + result = client.search_patient_by_nhs_number("9000000017") assert result is not None assert result.nhs_number == "9000000017" assert result.family_name == "Taylor" diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/test_provider_request.py index f2c47965..6441490a 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/test_provider_request.py @@ -47,7 +47,7 @@ def _fake_post( url: str, headers: CaseInsensitiveDict[str], data: str, - timeout: int, + timeout: int, # NOQA ARG001 (unused in stub) ) -> Response: """A fake requests.post implementation.""" @@ -66,7 +66,6 @@ def _fake_post( def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], - stub: GpProviderStub, ) -> None: """ Test that the `access_structured_record` method constructs the correct URL @@ -99,7 +98,6 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], - stub: GpProviderStub, ) -> None: """ Test that the `access_structured_record` method includes the correct headers @@ -138,7 +136,6 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], - stub: GpProviderStub, ) -> None: """ Test that the `access_structured_record` method includes the correct body @@ -169,7 +166,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( def test_valid_gpprovider_access_structured_record_returns_stub_response_200( - mock_request_post: dict[str, Any], + mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, ) -> None: """ @@ -199,9 +196,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( def test_access_structured_record_raises_external_service_error( - mock_request_post: dict[str, Any], - stub: GpProviderStub, - monkeypatch: pytest.MonkeyPatch, + mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ Test that the `access_structured_record` method raises an `ExternalServiceError` @@ -223,18 +218,3 @@ def test_access_structured_record_raises_external_service_error( match="GPProvider FHIR API request failed:Bad Request", ): client.access_structured_record(trace_id, "body") - - -def test_stubbed_post_function(stub: GpProviderStub) -> None: - """ - Test the `_stubbed_post` function to ensure it routes to the stub provider. - """ - trace_id = "test-trace-id" - body = "test-body" - - # Call the `_stubbed_post` function - response = provider_request._stubbed_post(trace_id, body) # noqa: SLF001 this is testing the private method - - # Verify the response is as expected - assert response.status_code == 200 - assert response.json() == stub.access_record_structured(trace_id, body).json() diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/stub_pds.py index dba6c1b9..f8249295 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/stub_pds.py @@ -6,26 +6,38 @@ from __future__ import annotations +import json import re import uuid -from dataclasses import dataclass from datetime import datetime, timezone +from http.client import responses as http_responses from typing import Any +from requests import Response +from requests.structures import CaseInsensitiveDict -@dataclass(frozen=True) -class StubResponse: - """ - Minimal response object returned by :class:`PdsFhirApiStub`. - :param status_code: HTTP-like status code for the response. - :param headers: HTTP-like response headers. - :param json: Parsed JSON response body. +def _create_response( + status_code: int, + headers: dict[str, str], + json_data: dict[str, Any], +) -> Response: """ + Create a :class:`requests.Response` object for the stub. - status_code: int - headers: dict[str, str] - json: dict[str, Any] + :param status_code: HTTP status code. + :param headers: Response headers dictionary. + :param json_data: JSON body data. + :return: A :class:`requests.Response` instance. + """ + response = Response() + response.status_code = status_code + response.headers = CaseInsensitiveDict(headers) + response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 + response.encoding = "utf-8" + # Set a reason phrase for HTTP error handling + response.reason = http_responses.get(status_code, "Unknown") + return response class PdsFhirApiStub: @@ -87,6 +99,45 @@ def __init__(self, strict_headers: bool = True) -> None: version_id=1, ) + self.upsert_patient( + nhs_number="9999999999", + patient={ + "resourceType": "Patient", + "id": "9999999999", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z", + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + { + "use": "official", + "family": "Jones", + "given": ["Alice"], + "period": {"start": "1900-01-01", "end": "9999-12-31"}, + } + ], + "gender": "female", + "birthDate": "1980-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + }, + } + ], + }, + version_id=1, + ) + # --------------------------- # Public API for tests # --------------------------- @@ -136,10 +187,10 @@ def get_patient( nhs_number: str, request_id: str | None = None, correlation_id: str | None = None, - authorization: str | None = None, # noqa: F841 # NOSONAR S1172 (ignored in stub) - role_id: str | None = None, # noqa: F841 # NOSONAR S1172 (ignored in stub) - end_user_org_ods: str | None = None, # noqa: F841 # NOSONAR S1172 (ignored in stub) - ) -> StubResponse: + authorization: str | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + role_id: str | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + end_user_org_ods: str | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + ) -> Response: """ Implements ``GET /Patient/{id}``. @@ -150,7 +201,7 @@ def get_patient( :param authorization: Authorization header (ignored by the stub). :param role_id: Role header (ignored by the stub). :param end_user_org_ods: End-user ODS header (ignored by the stub). - :return: A :class:`StubResponse` representing either: + :return: A :class:`requests.Response` representing either: * ``200`` with Patient JSON * ``404`` with OperationOutcome JSON * ``400`` with OperationOutcome JSON (validation failures) @@ -202,7 +253,39 @@ def get_patient( # ETag mirrors the "W/\"\"" shape and aligns to meta.versionId. headers_out["ETag"] = f'W/"{version_id}"' - return StubResponse(status_code=200, headers=headers_out, json=patient) + return _create_response(status_code=200, headers=headers_out, json_data=patient) + + def get( + self, + url: str, + headers: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + timeout: int | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + ) -> Response: + nhs_number = url.split("/")[-1] + + # Extract headers for validation + request_id = None + correlation_id = None + authorization = None + role_id = None + end_user_org_ods = None + + if headers: + request_id = headers.get("X-Request-ID") + correlation_id = headers.get("X-Correlation-ID") + authorization = headers.get("Authorization") + role_id = headers.get("NHSD-Session-URID") + end_user_org_ods = headers.get("NHSD-End-User-Organisation-ODS") + + return self.get_patient( + nhs_number=nhs_number, + request_id=request_id, + correlation_id=correlation_id, + authorization=authorization, + role_id=role_id, + end_user_org_ods=end_user_org_ods, + ) # --------------------------- # Internal helpers @@ -237,43 +320,28 @@ def _is_uuid(value: str) -> bool: return False @staticmethod - def _is_valid_nhs_number(nhs_number: str) -> bool: + def _is_valid_nhs_number( + nhs_number: str, # NOQA: ARG004 We're just passing everything + ) -> bool: """ - Validate an NHS number. + Validate an NHS number. We don't actually care if NHS numbers are valid in the + stub for now, so just returns True. - The intended logic is check-digit validation (mod 11), rejecting cases where the - computed check digit is 10. - - :param nhs_number: NHS number string. - :return: ``True`` if considered valid. - - .. note:: - This stub currently returns ``True`` for all values to keep unit test data - setup lightweight. Uncomment the implementation below if stricter validation - is desired. + If you do decide that you want to validate them in future, use the validator + in common.common.validate_nhs_number. """ return True - # digits = [int(c) for c in nhs_number] # NOSONAR S125 (May be wanted later) - # total = sum(digits[i] * (10 - i) for i in range(9)) # weights 10..2 - # remainder = total % 11 - # check = 11 - remainder - # if check == 11: - # check = 0 - # if check == 10: - # return False - # return digits[9] == check - def _bad_request( self, message: str, *, request_id: str | None, correlation_id: str | None - ) -> StubResponse: + ) -> Response: """ Build a 400 OperationOutcome response. :param message: Human-readable error message. :param request_id: Optional request ID to echo back. :param correlation_id: Optional correlation ID to echo back. - :return: A 400 :class:`StubResponse` containing an OperationOutcome. + :return: A 400 :class:`requests.Response` containing an OperationOutcome. """ headers: dict[str, str] = {} if request_id: @@ -291,7 +359,7 @@ def _bad_request( @staticmethod def _operation_outcome( *, status_code: int, headers: dict[str, str], spine_code: str, display: str - ) -> StubResponse: + ) -> Response: """ Construct an OperationOutcome response body. @@ -299,7 +367,7 @@ def _operation_outcome( :param headers: Response headers. :param spine_code: Spine error/warning code. :param display: Human-readable display message. - :return: A :class:`StubResponse` containing an OperationOutcome JSON body. + :return: A :class:`requests.Response` containing an OperationOutcome JSON body. """ body = { "resourceType": "OperationOutcome", @@ -320,4 +388,6 @@ def _operation_outcome( } ], } - return StubResponse(status_code=status_code, headers=dict(headers), json=body) + return _create_response( + status_code=status_code, headers=dict(headers), json_data=body + ) diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py index d77bd4cd..2d0c96ba 100644 --- a/gateway-api/stubs/stubs/stub_provider.py +++ b/gateway-api/stubs/stubs/stub_provider.py @@ -22,28 +22,35 @@ """ import json +from typing import Any +from gateway_api.common.common import json_str from requests import Response from requests.structures import CaseInsensitiveDict -class StubResponse(Response): - """A stub response object representing a minimal FHIR + JSON response.""" +def _create_response( + status_code: int, + headers: dict[str, str] | CaseInsensitiveDict[str], + content: bytes, + reason: str = "", +) -> Response: + """ + Create a :class:`requests.Response` object for the stub. - def __init__( - self, - status_code: int, - _content: bytes, - headers: CaseInsensitiveDict[str], - reason: str, - ) -> None: - """Create a FakeResponse instance.""" - super().__init__() - self.status_code = status_code - self._content = _content - self.headers = CaseInsensitiveDict(headers) - self.reason = reason - self.encoding = "utf-8" + :param status_code: HTTP status code. + :param headers: Response headers dictionary. + :param content: Response body as bytes. + :param reason: HTTP reason phrase (e.g., "OK", "Bad Request"). + :return: A :class:`requests.Response` instance. + """ + response = Response() + response.status_code = status_code + response.headers = CaseInsensitiveDict(headers) + response._content = content # noqa: SLF001 + response.reason = reason + response.encoding = "utf-8" + return response class GpProviderStub: @@ -51,60 +58,60 @@ class GpProviderStub: A minimal in-memory stub for a Provider GP System FHIR API, implementing only accessRecordStructured to read basic demographic data for a single patient. + + Seeded with an example + FHIR/STU3 Patient resource with only administrative data based on Example 2 + # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 """ - def __init__(self) -> None: - """Create a GPProviderStub instance which is seeded with an example - FHIR/STU3 Patient resource with only administrative data based on Example 2 - # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 - """ - self.patient_bundle = { - "resourceType": "Bundle", - "type": "collection", - "meta": { - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" - ] - }, - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", - "meta": { - "versionId": "1469448000000", - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } + # Example patient resource + patient_bundle = { + "resourceType": "Bundle", + "type": "collection", + "meta": { + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" + ] + }, + "entry": [ + { + "resource": { + "resourceType": "Patient", + "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "meta": { + "versionId": "1469448000000", + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" ], - "active": True, - "name": [ - { - "use": "official", - "text": "JACKSON Jane (Miss)", - "family": "Jackson", - "given": ["Jane"], - "prefix": ["Miss"], - } - ], - "gender": "female", - "birthDate": "1952-05-31", - } + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "active": True, + "name": [ + { + "use": "official", + "text": "JACKSON Jane (Miss)", + "family": "Jackson", + "given": ["Jane"], + "prefix": ["Miss"], + } + ], + "gender": "female", + "birthDate": "1952-05-31", } - ], - } + } + ], + } def access_record_structured( self, trace_id: str, - body: str, # NOSONAR S1172: unused parameter maintains method signature in stub - ) -> StubResponse: + body: str, # NOQA ARG002 # NOSONAR S1172: unused parameter maintains method signature in stub + ) -> Response: """ Simulate accessRecordStructured operation of GPConnect FHIR API. @@ -112,23 +119,35 @@ def access_record_structured( Response: The stub patient bundle wrapped in a Response object. """ - stub_response = StubResponse( + stub_response = _create_response( status_code=200, headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), + content=json.dumps(self.patient_bundle).encode("utf-8"), reason="OK", - _content=json.dumps(self.patient_bundle).encode("utf-8"), ) if trace_id == "invalid for test": - return StubResponse( + return _create_response( status_code=400, headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - reason="Bad Request", - _content=( + content=( b'{"resourceType":"OperationOutcome","issue":[' b'{"severity":"error","code":"invalid",' b'"diagnostics":"Invalid for testing"}]}' ), + reason="Bad Request", ) return stub_response + + +def stub_post( + url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) + headers: dict[str, Any], + data: json_str, + timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) +) -> Response: + """A stubbed requests.post function that routes to the GPProviderStub.""" + _provider_stub = GpProviderStub() + trace_id = headers.get("Ssp-TraceID", "no-trace-id") + return _provider_stub.access_record_structured(trace_id, data) diff --git a/gateway-api/tests/acceptance/features/happy_path.feature b/gateway-api/tests/acceptance/features/happy_path.feature index a2afa5b5..0b51c0cd 100644 --- a/gateway-api/tests/acceptance/features/happy_path.feature +++ b/gateway-api/tests/acceptance/features/happy_path.feature @@ -9,7 +9,7 @@ Feature: Gateway API Hello World Scenario: Get structured record request When I send a valid Parameters resource to the endpoint Then the response status code should be 200 - And the response should contain a valid Bundle resource + And the response should contain the patient bundle from the provider Scenario: Accessing a non-existent endpoint returns a 404 When I send a valid Parameters resource to a nonexistent endpoint diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index e9c813c8..3485f224 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -4,9 +4,9 @@ from datetime import timedelta import requests -from fhir.bundle import Bundle from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when +from stubs.stub_provider import GpProviderStub from tests.acceptance.conftest import ResponseContext from tests.conftest import Client @@ -53,15 +53,13 @@ def check_status_code(response_context: ResponseContext, expected_status: int) - assert response_context.response is not None, "Response has not been set." assert response_context.response.status_code == expected_status, ( f"Expected status {expected_status}, " - f"got {response_context.response.status_code}" + f"got {response_context.response.status_code}: {response_context.response.text}" ) -@then("the response should contain a valid Bundle resource") -def check_response_contains( - response_context: ResponseContext, expected_response_payload: Bundle -) -> None: +@then("the response should contain the patient bundle from the provider") +def check_response_matches_provider(response_context: ResponseContext) -> None: assert response_context.response, "Response has not been set." - assert response_context.response.json() == expected_response_payload, ( + assert response_context.response.json() == GpProviderStub.patient_bundle, ( "Expected response payload does not match actual response payload." ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 5facb089..7fef2c54 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -7,7 +7,6 @@ import pytest import requests from dotenv import find_dotenv, load_dotenv -from fhir.bundle import Bundle from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root @@ -22,16 +21,24 @@ def __init__(self, base_url: str, timeout: timedelta = timedelta(seconds=1)): self.base_url = base_url self._timeout = timeout.total_seconds() - def send_to_get_structured_record_endpoint(self, payload: str) -> requests.Response: + def send_to_get_structured_record_endpoint( + self, payload: str, headers: dict[str, str] | None = None + ) -> requests.Response: """ Send a request to the get_structured_record endpoint with the given NHS number. """ url = f"{self.base_url}/patient/$gpc.getstructuredrecord" - headers = {"Content-Type": "application/fhir+json"} + default_headers = { + "Content-Type": "application/fhir+json", + "Ods-from": "test-ods-code", + "Ssp-TraceID": "test-trace-id", + } + if headers: + default_headers.update(headers) return requests.post( url=url, data=payload, - headers=headers, + headers=default_headers, timeout=self._timeout, ) @@ -61,34 +68,6 @@ def simple_request_payload() -> Parameters: } -@pytest.fixture -def expected_response_payload() -> Bundle: - return { - "resourceType": "Bundle", - "id": "example-patient-bundle", - "type": "collection", - "timestamp": "2026-01-12T10:00:00Z", - "entry": [ - { - "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", - "resource": { - "resourceType": "Patient", - "id": "9999999999", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [{"use": "official", "family": "Doe", "given": ["John"]}], - "gender": "male", - "birthDate": "1985-04-12", - }, - } - ], - } - - @pytest.fixture(scope="module") def client(base_url: str) -> Client: """Create a test client for the application.""" @@ -107,7 +86,10 @@ def hostname() -> str: return _fetch_env_variable("HOST", str) -def _fetch_env_variable[T](name: str, t: type[T]) -> T: +def _fetch_env_variable[T]( + name: str, + t: type[T], # NOQA ARG001 This is actually used for type hinting +) -> T: value = os.getenv(name) if not value: raise ValueError(f"{name} environment variable is not set.") diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 6d60fef5..12c8a5cf 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -38,6 +38,12 @@ "headers": { "Content-Type": [ "application/fhir+json" + ], + "ODS-from": [ + "A12345" + ], + "Ssp-TraceID": [ + "trace-1234" ] }, "method": "POST", @@ -48,23 +54,33 @@ "content": { "entry": [ { - "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", "resource": { - "birthDate": "1985-04-12", - "gender": "male", - "id": "9999999999", + "active": true, + "birthDate": "1952-05-31", + "gender": "female", + "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", "identifier": [ { "system": "https://fhir.nhs.uk/Id/nhs-number", "value": "9999999999" } ], + "meta": { + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" + ], + "versionId": "1469448000000" + }, "name": [ { - "family": "Doe", + "family": "Jackson", "given": [ - "John" + "Jane" ], + "prefix": [ + "Miss" + ], + "text": "JACKSON Jane (Miss)", "use": "official" } ], @@ -72,9 +88,12 @@ } } ], - "id": "example-patient-bundle", + "meta": { + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" + ] + }, "resourceType": "Bundle", - "timestamp": "2026-01-12T10:00:00Z", "type": "collection" }, "contentType": "application/fhir+json", diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index 2f828234..cf1998c3 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -24,27 +24,42 @@ def test_get_structured_record(self) -> None: expected_bundle = { "resourceType": "Bundle", - "id": "example-patient-bundle", "type": "collection", - "timestamp": "2026-01-12T10:00:00Z", + "meta": { + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" + ] + }, "entry": [ { - "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", "resource": { "resourceType": "Patient", - "id": "9999999999", + "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "meta": { + "versionId": "1469448000000", + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" + ], + }, "identifier": [ { "system": "https://fhir.nhs.uk/Id/nhs-number", "value": "9999999999", } ], + "active": True, "name": [ - {"use": "official", "family": "Doe", "given": ["John"]} + { + "use": "official", + "text": "JACKSON Jane (Miss)", + "family": "Jackson", + "given": ["Jane"], + "prefix": ["Miss"], + } ], - "gender": "male", - "birthDate": "1985-04-12", - }, + "gender": "female", + "birthDate": "1952-05-31", + } } ], } @@ -52,6 +67,13 @@ def test_get_structured_record(self) -> None: # Define the expected interaction ( pact.upon_receiving("a request for structured record") + .with_request( + method="POST", + path="/patient/$gpc.getstructuredrecord", + ) + .with_header("Content-Type", "application/fhir+json") + .with_header("ODS-from", "A12345") + .with_header("Ssp-TraceID", "trace-1234") .with_body( { "resourceType": "Parameters", @@ -67,14 +89,9 @@ def test_get_structured_record(self) -> None: }, content_type="application/fhir+json", ) - .with_header("Content-Type", "application/fhir+json") - .with_request( - method="POST", - path="/patient/$gpc.getstructuredrecord", - ) .will_respond_with(status=200) - .with_body(expected_bundle, content_type="application/fhir+json") .with_header("Content-Type", "application/fhir+json") + .with_body(expected_bundle, content_type="application/fhir+json") ) # Start the mock server and execute the test @@ -96,7 +113,11 @@ def test_get_structured_record(self) -> None: ], } ), - headers={"Content-Type": "application/fhir+json"}, + headers={ + "Content-Type": "application/fhir+json", + "ODS-from": "A12345", + "Ssp-TraceID": "trace-1234", + }, timeout=10, ) @@ -104,11 +125,16 @@ def test_get_structured_record(self) -> None: assert response.status_code == 200 body = response.json() assert body["resourceType"] == "Bundle" - assert body["id"] == "example-patient-bundle" assert body["type"] == "collection" assert len(body["entry"]) == 1 assert body["entry"][0]["resource"]["resourceType"] == "Patient" - assert body["entry"][0]["resource"]["id"] == "9999999999" + assert ( + body["entry"][0]["resource"]["id"] + == "04603d77-1a4e-4d63-b246-d7504f8bd833" + ) + assert ( + body["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" + ) # Write the pact file after the test pact.write_file("tests/contract/pacts") diff --git a/gateway-api/tests/contract/test_provider_contract.py b/gateway-api/tests/contract/test_provider_contract.py index 1388a844..8604d2bf 100644 --- a/gateway-api/tests/contract/test_provider_contract.py +++ b/gateway-api/tests/contract/test_provider_contract.py @@ -18,6 +18,7 @@ def test_provider_honors_consumer_contract( This test verifies the Flask API against the pact files generated by consumer tests. """ + # Create a verifier for the provider verifier = Verifier(name="GatewayAPIProvider", host=hostname) diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 0215d840..32151f2d 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -2,8 +2,8 @@ import json -from fhir.bundle import Bundle from fhir.parameters import Parameters +from stubs.stub_provider import GpProviderStub from tests.conftest import Client @@ -22,13 +22,12 @@ def test_happy_path_returns_correct_message( self, client: Client, simple_request_payload: Parameters, - expected_response_payload: Bundle, ) -> None: """Test that the root endpoint returns the correct message.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) - assert response.json() == expected_response_payload + assert response.json() == GpProviderStub.patient_bundle def test_happy_path_content_type( self, client: Client, simple_request_payload: Parameters diff --git a/gateway-api/tests/schema/test_openapi_schema.py b/gateway-api/tests/schema/test_openapi_schema.py index 17c951de..407f5de4 100644 --- a/gateway-api/tests/schema/test_openapi_schema.py +++ b/gateway-api/tests/schema/test_openapi_schema.py @@ -6,6 +6,7 @@ from pathlib import Path +import schemathesis import yaml from schemathesis.generation.case import Case from schemathesis.openapi import from_dict @@ -32,6 +33,14 @@ def test_api_schema_compliance(case: Case, base_url: str) -> None: - Handles edge cases correctly - Validates inputs properly - Returns appropriate status codes + + Note: Server error checks are disabled because the API may return 500 errors + when testing with randomly generated NHS numbers that don't exist in the PDS. """ # Call the API and validate the response against the schema - case.call_and_validate(base_url=base_url) + # Exclude not_a_server_error check as 500 responses are expected for + # non-existent patients + case.call_and_validate( + base_url=base_url, + excluded_checks=[schemathesis.checks.not_a_server_error], + ) diff --git a/ruff.toml b/ruff.toml index fc178686..db28865d 100644 --- a/ruff.toml +++ b/ruff.toml @@ -41,7 +41,9 @@ select = [ # Flake8-pytest-style "PT", # Flake8-type-checking - "TC" + "TC", + # Flake8-unused-arguments + "ARG" ] # Ignore Flake8-commas trailing commas as this can conflict with the Ruff standard format. ignore =["COM812"] From 9a87efd2bc02c02f174e773efdef7f4409403d10 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:23:33 +0000 Subject: [PATCH 5/7] [GPCAPIM-278]: Build a preview proxy instance By * concatingating the openapi and x-nhsd-apim yamls to for a template specification, * inserting the API variables/secrets into that template, * and using the Proxygen CLI tool within the preview-env workflow to deploy an instance defined by that specification. --- .../proxy/configure-proxygen/action.yaml | 41 ++++++++++++++++ .../actions/proxy/deploy-proxy/action.yaml | 48 +++++++++++++++++++ .../actions/proxy/tear-down-proxy/action.yaml | 35 ++++++++++++++ .github/workflows/preview-env.yml | 33 ++++++++++++- bruno/README.md | 32 +++++++++++++ .../collections/preview-env/.env.template | 5 ++ .../preview-env/Access_Structured_Record.bru | 31 ++++++++++++ .../collections/preview-env/bruno.json | 9 ++++ .../collections/preview-env/collection.bru | 32 +++++++++++++ bruno/gateway-api/workspace.yml | 14 ++++++ gateway-api/openapi.yaml | 25 +++------- proxygen/README.md | 33 +++++++++++++ proxygen/credentials.template.yaml | 7 +++ proxygen/settings.template.yaml | 3 ++ proxygen/x-nhsd-apim.template.yaml | 13 +++++ .../config/vocabularies/words/accept.txt | 2 + 16 files changed, 343 insertions(+), 20 deletions(-) create mode 100644 .github/actions/proxy/configure-proxygen/action.yaml create mode 100644 .github/actions/proxy/deploy-proxy/action.yaml create mode 100644 .github/actions/proxy/tear-down-proxy/action.yaml create mode 100644 bruno/README.md create mode 100644 bruno/gateway-api/collections/preview-env/.env.template create mode 100644 bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru create mode 100644 bruno/gateway-api/collections/preview-env/bruno.json create mode 100644 bruno/gateway-api/collections/preview-env/collection.bru create mode 100644 bruno/gateway-api/workspace.yml create mode 100644 proxygen/README.md create mode 100644 proxygen/credentials.template.yaml create mode 100644 proxygen/settings.template.yaml create mode 100644 proxygen/x-nhsd-apim.template.yaml diff --git a/.github/actions/proxy/configure-proxygen/action.yaml b/.github/actions/proxy/configure-proxygen/action.yaml new file mode 100644 index 00000000..bbde6aaa --- /dev/null +++ b/.github/actions/proxy/configure-proxygen/action.yaml @@ -0,0 +1,41 @@ +name: Configure Proxygen +description: Install yq for yaml, install proxygen-cli and configure the account + +inputs: + proxygen-key-secret: + description: 'Proxygen private key secret' + required: true + proxygen-key-id: + description: 'Proxygen key ID' + required: true + proxygen-api-name: + description: 'Proxygen API name' + required: true + proxygen-client-id: + description: 'Proxygen client ID' + required: true + +runs: + using: composite + steps: + - name: Install yq for YAML template processing + uses: mikefarah/yq@2be0094729a1006f61e8339ce9934bfb3cbb549f # v4.52.2 + + - name: Install Proxygen CLI + shell: bash + run: | + pip install proxygen-cli + proxygen --version + + - name: Configure proxygen account details + shell: bash + working-directory: proxygen + run: | + cp settings.template.yaml $HOME/.proxygen/settings.yaml + yq eval '.api = "${{ inputs.proxygen-api-name }}"' -i $HOME/.proxygen/settings.yaml + + printf "%s" "${{ inputs.proxygen-key-secret }}" > /tmp/proxygen_private_key.pem + cp credentials.template.yaml $HOME/.proxygen/credentials.yaml + yq eval '.private_key_path = "/tmp/proxygen_private_key.pem"' -i $HOME/.proxygen/credentials.yaml + yq eval '.key_id = "${{ inputs.proxygen-key-id }}"' -i $HOME/.proxygen/credentials.yaml + yq eval '.client_id = "${{ inputs.proxygen-client-id }}"' -i $HOME/.proxygen/credentials.yaml diff --git a/.github/actions/proxy/deploy-proxy/action.yaml b/.github/actions/proxy/deploy-proxy/action.yaml new file mode 100644 index 00000000..1a0e3968 --- /dev/null +++ b/.github/actions/proxy/deploy-proxy/action.yaml @@ -0,0 +1,48 @@ +name: Deploy API Proxy +description: Deploy the API proxy instance using Proxygen + +inputs: + mtls-secret-name: + description: 'mTLS secret name for the proxy' + required: true + target-url: + description: 'Target URL to which the proxy will forward requests' + required: true + proxy-base-path: + description: 'A unique base path for the proxy instance' + required: true + proxygen-key-secret: + description: 'Proxygen private key secret' + required: true + proxygen-key-id: + description: 'Proxygen key ID' + required: true + proxygen-api-name: + description: 'Proxygen API name' + required: true + proxygen-client-id: + description: 'Proxygen client ID' + required: true + +runs: + using: composite + steps: + - name: Configure Proxygen + uses: ./.github/actions/proxy/configure-proxygen + with: + proxygen-key-secret: ${{ inputs.proxygen-key-secret }} + proxygen-key-id: ${{ inputs.proxygen-key-id }} + proxygen-api-name: ${{ inputs.proxygen-api-name }} + proxygen-client-id: ${{ inputs.proxygen-client-id }} + + - name: Inject secrets into openapi.yaml for deploying proxy + shell: bash + run: | + cat gateway-api/openapi.yaml proxygen/x-nhsd-apim.template.yaml > /tmp/proxy-specification.yaml + + yq eval '.x-nhsd-apim.target.url = "${{ inputs.target-url }}" | .x-nhsd-apim.target.security.secret = "${{ inputs.mtls-secret-name }}"' -i /tmp/proxy-specification.yaml + + - name: Deploy API proxy + shell: bash + run: | + proxygen instance deploy internal-dev ${{ inputs.proxy-base-path }} /tmp/proxy-specification.yaml --no-confirm diff --git a/.github/actions/proxy/tear-down-proxy/action.yaml b/.github/actions/proxy/tear-down-proxy/action.yaml new file mode 100644 index 00000000..97f0536b --- /dev/null +++ b/.github/actions/proxy/tear-down-proxy/action.yaml @@ -0,0 +1,35 @@ +name: Tear Down API Proxy +description: Delete the API proxy instance using Proxygen + +inputs: + proxy-base-path: + description: 'A unique base path for the proxy instance' + required: true + proxygen-key-secret: + description: 'Proxygen private key secret' + required: true + proxygen-key-id: + description: 'Proxygen key ID' + required: true + proxygen-api-name: + description: 'Proxygen API name' + required: true + proxygen-client-id: + description: 'Proxygen client ID' + required: true + +runs: + using: composite + steps: + - name: Configure Proxygen + uses: ./.github/actions/proxy/configure-proxygen + with: + proxygen-key-secret: ${{ inputs.proxygen-key-secret }} + proxygen-key-id: ${{ inputs.proxygen-key-id }} + proxygen-api-name: ${{ inputs.proxygen-api-name }} + proxygen-client-id: ${{ inputs.proxygen-client-id }} + + - name: Tear down preview API proxy + shell: bash + run: | + proxygen instance delete internal-dev ${{ inputs.proxy-base-path }} --no-confirm diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index a1a6750e..2c3b11cd 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -155,6 +155,35 @@ jobs: ECS_CLUSTER=$(jq -r '.ecs_cluster_name.value' tf-output.json) echo "ecs_cluster=$ECS_CLUSTER" >> $GITHUB_OUTPUT + - name: Get proxygen machine user details + id: proxygen-machine-user + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + secret-ids: /cds/gateway/dev/proxygen/proxygen-key-secret + name-transformation: lowercase + + - name: Deploy preview API proxy + if: github.event.action != 'closed' + uses: ./.github/actions/proxy/deploy-proxy + with: + mtls-secret-name: ${{ vars.PREVIEW_ENV_MTLS_SECRET_NAME}} + target-url: ${{ steps.tf-output.outputs.preview_url }} + proxy-base-path: 'clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}' + proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} + proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} + proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} + proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} + + - name: Tear down preview API proxy + if: github.event.action == 'closed' + uses: ./.github/actions/proxy/tear-down-proxy + with: + proxy-base-path: 'clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}' + proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} + proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} + proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} + proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} + # ---------- Ensure re-deployment (PR updated) ---------- - name: Force ECS service redeployment if: github.event.action == 'synchronize' @@ -263,6 +292,7 @@ jobs: script: | const alb = '${{ steps.tf-output.outputs.target_group }}'; const url = '${{ steps.tf-output.outputs.preview_url }}'; + const proxy_url = 'https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}'; const cluster = '${{ steps.tf-output.outputs.ecs_cluster }}'; const service = '${{ steps.tf-output.outputs.ecs_service }}'; const owner = context.repo.owner; @@ -303,7 +333,8 @@ jobs: const lines = [ '**Deployment Complete**', `- Preview URL: [${url}](${url}) — [Health endpoint](${url}/health)`, - `- Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, + ` - Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, + `- Proxy URL: [${proxy_url}](${proxy_url})`, `- ECS Cluster: \`${cluster}\``, `- ECS Service: \`${service}\``, `- ALB Target: \`${alb}\``, diff --git a/bruno/README.md b/bruno/README.md new file mode 100644 index 00000000..fd49fd8b --- /dev/null +++ b/bruno/README.md @@ -0,0 +1,32 @@ +# Bruno + +## `gateway-api` Workspace + +### Preview Environment + +#### Environment Setup + +The collection pulls in secrets from a `.env` file from the top level of the collection, `bruno/gateway-api/preview-env`. To reference these variables within the collection you use `{{process.env.}}`, where `` is the environment variable name in `.env`. + +There is a template `.env` file, `bruno/gateway-api/collections/preview-env/.env.template`, to fill in as described below. + +##### Test application + +The proxy for Gateway API is hosted in Apigee. In order to call an Apigee proxy, a consumer of the API needs an Apigee application. As such, we need an Apigee application through which we can test our API. A static test application has been created for this purpose. You can view its details by going through In order to view its details, go to [the Clinical Data Sharing APIs applications](https://dos-internal.ptl.api.platform.nhs.uk/). when making a call to the API through the proxy, the test applications API key and secret are fed in to the OAuth 2.0 journey as the `CLIENT_KEY` and `CLIENT_SECRET` respectively. As such, you will need a `bruno/gateway-api/preview-preview-env/.env` file containing + +```plaintext +CLIENT_ID= +CLIENT_SECRET= +``` + +Bruno then uses these values when making an auth journey for you. + +Given the API is currently set up with CIS2 user-restricted access, and with the above set, when a HTTP request is sent, you will be prompted for username. [Here is a list of available test users](https://digital.nhs.uk/developer/guides-and-documentation/security-and-authorisation/testing-apis-with-our-mock-authorisation-service#test-users-for-cis2-authentication). + +##### Proxy instance + +The proxy base path defines to which proxy instance your request will be directed. For preview environments, the proxy base path has the GitHub PR number appended to it. As such you will need to add this to your `.env` file so that Bruno can correctly build the URL. + +```plaintext +PR_NNUMBER= +``` diff --git a/bruno/gateway-api/collections/preview-env/.env.template b/bruno/gateway-api/collections/preview-env/.env.template new file mode 100644 index 00000000..b6bcdce9 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/.env.template @@ -0,0 +1,5 @@ +# See README.md +PR_NUMBER= + +CLIENT_ID= +CLIENT_SECRET= diff --git a/bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru b/bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru new file mode 100644 index 00000000..97321089 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/Access_Structured_Record.bru @@ -0,0 +1,31 @@ +meta { + name: Access Record Structured + type: http + seq: 1 +} + +post { + url: https://{{apigee_env}}.api.service.nhs.uk/{{proxy_base_path}}/patient/$gpc.getstructuredrecord + body: json + auth: inherit +} + +body:json { + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ] + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/gateway-api/collections/preview-env/bruno.json b/bruno/gateway-api/collections/preview-env/bruno.json new file mode 100644 index 00000000..7070d436 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "preview-env", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/bruno/gateway-api/collections/preview-env/collection.bru b/bruno/gateway-api/collections/preview-env/collection.bru new file mode 100644 index 00000000..b7acd019 --- /dev/null +++ b/bruno/gateway-api/collections/preview-env/collection.bru @@ -0,0 +1,32 @@ +headers { + Ssp-TraceID: test-teace-id + ODS-from: test-ods-code +} + +auth { + mode: oauth2 +} + +auth:oauth2 { + grant_type: authorization_code + callback_url: https://www.example.com/callback + authorization_url: https://internal-dev.api.service.nhs.uk/oauth2-mock/authorize + access_token_url: https://internal-dev.api.service.nhs.uk/oauth2-mock/token + refresh_token_url: + client_id: {{process.env.CLIENT_ID}} + client_secret: {{process.env.CLIENT_SECRET}} + scope: + state: {{$guid}} + pkce: false + credentials_placement: body + credentials_id: Mock Auth Token + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} + +vars:pre-request { + apigee_env: internal-dev + proxy_base_path: clinical-data-gateway-api-poc-pr-{{process.env.PR_NUMBER}} +} diff --git a/bruno/gateway-api/workspace.yml b/bruno/gateway-api/workspace.yml new file mode 100644 index 00000000..b9f82f33 --- /dev/null +++ b/bruno/gateway-api/workspace.yml @@ -0,0 +1,14 @@ +opencollection: 1.0.0 +info: + name: "gateway" + type: workspace + +collections: + - name: "steel_thread" + path: "collections\\steel_thread" + - name: "steel_thread" + path: "collections\\preview-env" + +specs: + +docs: '' diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index 1d03ded7..b9c73434 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -8,11 +8,17 @@ info: servers: - url: http://localhost:5000 description: Local development server +components: + securitySchemes: + nhs-cis2-aal3: + $ref: https://proxygen.ptl.api.platform.nhs.uk/components/securitySchemes/nhs-cis2-aal3 paths: /patient/$gpc.getstructuredrecord: post: summary: Get structured record description: Returns a FHIR Bundle containing patient structured record + security: + - nhs-cis2-aal3: [] operationId: getStructuredRecord parameters: - in: header @@ -241,22 +247,3 @@ paths: diagnostics: type: string example: "Internal server error" - /health: - get: - summary: Health check - description: Returns the health status of the API - operationId: healthCheck - responses: - '200': - description: Service is healthy - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: "healthy" - required: - - status - diff --git a/proxygen/README.md b/proxygen/README.md new file mode 100644 index 00000000..825ed0ab --- /dev/null +++ b/proxygen/README.md @@ -0,0 +1,33 @@ +# Proxygen + +Proxygen is the tool created by the API Platform team to support the deployment of NHS APIs. + +We use this tool in the pipelines (and manually) to create, destroy and interact more generally with the proxy instances. + +For more information on Proxygen, [read the docs](https://nhsd-confluence.digital.nhs.uk/spaces/APM/pages/375329782/Proxygen). + +Proxygen needs: + +* a settings file stating which API we are attempting to update; +* a credentials file to authenticate us as the owner/maintainer of the API; +* a specification file that outlines the behaviour of the proxy. + +## Settings File + +This is stored at `proxygen/settings.yaml` and is read by the Proxygen command line tool when it has been requested to make updates to an API. + +## Credentials File + +A template is stored at `proxygen/credentials.template.yaml` where the `` needs to be inserted. This is a path to a file that holds the secret that identifies us as the owner/maintainer of the API. + +During the GitHub workflows, the secret is pulled from AWS secrets manager, written to a file and the path to that file is inserted in to `credentials.template.yaml`. + +## Specification file + +Proxygen deploys an instance of a proxy using a specification file. This is of the OpenAPI format with a custom extension, `x-nhsd-apim` which provide Proxygen with information as to how the proxy should behave. This includes: + +* the target endpoint, to which it will forward traffic; +* the scopes that a user needs in order to access the proxy's endpoint; +* a key that the points to the mTLS certificate which the targeted backend expects to be used. + +A template, `proxygen/x-nhsd-api.tempalte.yaml`, is concatenated with the general OpenAPI specification for the API, `gateway-api/openapi.yaml`, and the key to the mTLS certificate to be used for that proxy is written in. All of which is then written to a file and the path to that file is passed to Proxygen to deploy the proxy in the stated environment. diff --git a/proxygen/credentials.template.yaml b/proxygen/credentials.template.yaml new file mode 100644 index 00000000..2e686880 --- /dev/null +++ b/proxygen/credentials.template.yaml @@ -0,0 +1,7 @@ +base_url: https://identity.prod.api.platform.nhs.uk/realms/api-producers +client_id: +client_secret: '' +key_id: +password: '' +private_key_path: +username: '' diff --git a/proxygen/settings.template.yaml b/proxygen/settings.template.yaml new file mode 100644 index 00000000..bca412ac --- /dev/null +++ b/proxygen/settings.template.yaml @@ -0,0 +1,3 @@ +api: +endpoint_url: https://proxygen.prod.api.platform.nhs.uk +spec_output_format: yaml diff --git a/proxygen/x-nhsd-apim.template.yaml b/proxygen/x-nhsd-apim.template.yaml new file mode 100644 index 00000000..e8cba5a1 --- /dev/null +++ b/proxygen/x-nhsd-apim.template.yaml @@ -0,0 +1,13 @@ +x-nhsd-apim: + monitoring: true + access: + - title: User Restricted + grants: + nhs-cis2-aal3: [] + target: + type: external + healthcheck: /health + url: + security: + type: mtls + secret: diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 031d70f7..86d5c9f9 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,4 +1,5 @@ anonymised +Apigee APIs base_url binutils @@ -23,6 +24,7 @@ OAuth Octokit onboarding Podman +Proxygen [Pp]ytest Python repos From fbb62f150d8718341aba7fd4eb8a512f78bd7a3a Mon Sep 17 00:00:00 2001 From: neil-sproston Date: Tue, 10 Feb 2026 14:56:55 +0000 Subject: [PATCH 6/7] [CDAPIM-273] Enable trivy IaC scanning --- .github/workflows/preview-env.yml | 11 +++- infrastructure/environments/preview/main.tf | 55 +++++++++++++++++++ .../environments/preview/variables.tf | 6 ++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 2c3b11cd..6cb7147e 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -168,7 +168,7 @@ jobs: with: mtls-secret-name: ${{ vars.PREVIEW_ENV_MTLS_SECRET_NAME}} target-url: ${{ steps.tf-output.outputs.preview_url }} - proxy-base-path: 'clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}' + proxy-base-path: "clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}" proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} @@ -178,7 +178,7 @@ jobs: if: github.event.action == 'closed' uses: ./.github/actions/proxy/tear-down-proxy with: - proxy-base-path: 'clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}' + proxy-base-path: "clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}" proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} @@ -348,6 +348,13 @@ jobs: }); # ---------- Security scanning ---------- + - name: Trivy IaC scan + if: github.event.action != 'closed' + uses: nhs-england-tools/trivy-action/iac-scan@3456c1657a37d500027fd782e6b08911725392da + with: + scan-ref: infrastructure/environments/preview + artifact-name: trivy-iac-scan-${{ steps.meta.outputs.branch_name }} + - name: Trivy filesystem scan if: github.event.action != 'closed' uses: nhs-england-tools/trivy-action/image-scan@3456c1657a37d500027fd782e6b08911725392da diff --git a/infrastructure/environments/preview/main.tf b/infrastructure/environments/preview/main.tf index faab12f0..1e269dc1 100644 --- a/infrastructure/environments/preview/main.tf +++ b/infrastructure/environments/preview/main.tf @@ -4,6 +4,8 @@ data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + data "terraform_remote_state" "core" { backend = "s3" config = { @@ -38,6 +40,8 @@ locals { alb_listener_arn = data.terraform_remote_state.core.outputs.alb_listener_arn ecs_cluster_name = data.terraform_remote_state.core.outputs.ecs_cluster_name ecr_repository_url = data.terraform_remote_state.core.outputs.ecr_repository_url + + log_kms_key_id = var.log_kms_key_id != null ? var.log_kms_key_id : aws_kms_key.log_group[0].arn } ############################ @@ -141,6 +145,57 @@ resource "aws_iam_role_policy" "task_exec_command" { resource "aws_cloudwatch_log_group" "branch" { name = local.log_group_name retention_in_days = var.log_retention_days + kms_key_id = local.log_kms_key_id +} + +resource "aws_kms_key" "log_group" { + count = var.log_kms_key_id == null ? 1 : 0 + + description = "KMS key for preview CloudWatch Logs" + deletion_window_in_days = 30 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableRootPermissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowCloudWatchLogs" + Effect = "Allow" + Principal = { + Service = "logs.${data.aws_region.current.name}.amazonaws.com" + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + Condition = { + ArnLike = { + "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:${local.log_group_name}*" + } + } + } + ] + }) +} + +resource "aws_kms_alias" "log_group" { + count = var.log_kms_key_id == null ? 1 : 0 + + name = "alias/preview-logs-${local.branch_role_suffix}" + target_key_id = aws_kms_key.log_group[0].key_id } ############################ diff --git a/infrastructure/environments/preview/variables.tf b/infrastructure/environments/preview/variables.tf index 15ddaedb..4cbd1918 100644 --- a/infrastructure/environments/preview/variables.tf +++ b/infrastructure/environments/preview/variables.tf @@ -49,3 +49,9 @@ variable "log_retention_days" { type = number default = 14 } + +variable "log_kms_key_id" { + description = "KMS CMK ARN or ID used to encrypt the CloudWatch log group." + type = string + default = null +} From 7ea6cd9dee933111ef8106ccebb05396153eef9d Mon Sep 17 00:00:00 2001 From: russellpollock Date: Thu, 5 Feb 2026 12:07:36 +0000 Subject: [PATCH 7/7] [GPCAPIM-260]-[Steel Thread integration testing]-[RP]-4 --- .github/workflows/cicd-1-pull-request.yaml | 23 +- .github/workflows/preview-env.yml | 271 +++++++++++++++--- .github/workflows/stage-2-test.yaml | 236 --------------- .github/workflows/stage-4-acceptance.yaml | 126 -------- .../acceptance/features/hello_world.feature | 16 -- .../tests/acceptance/steps/happy_path.py | 1 + gateway-api/tests/conftest.py | 10 +- .../tests/contract/test_provider_contract.py | 115 ++++++-- .../tests/schema/test_openapi_schema.py | 18 +- 9 files changed, 351 insertions(+), 465 deletions(-) delete mode 100644 .github/workflows/stage-2-test.yaml delete mode 100644 .github/workflows/stage-4-acceptance.yaml delete mode 100644 gateway-api/tests/acceptance/features/hello_world.feature diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index e5c39b6e..b0117b1b 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -83,17 +83,9 @@ jobs: IDP_AWS_REPORT_UPLOAD_REGION: ${{ secrets.IDP_AWS_REPORT_UPLOAD_REGION }} IDP_AWS_REPORT_UPLOAD_ROLE_NAME: ${{ secrets.IDP_AWS_REPORT_UPLOAD_ROLE_NAME }} IDP_AWS_REPORT_UPLOAD_BUCKET_ENDPOINT: ${{ secrets.IDP_AWS_REPORT_UPLOAD_BUCKET_ENDPOINT }} - test-stage: # Recommended maximum execution time is 5 minutes - name: "Test stage" - needs: [metadata, commit-stage] - uses: ./.github/workflows/stage-2-test.yaml - with: - python_version: "${{ needs.metadata.outputs.python_version }}" - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} build-stage: # Recommended maximum execution time is 3 minutes name: "Build stage" - needs: [metadata, test-stage] + needs: [metadata] uses: ./.github/workflows/stage-3-build.yaml if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) with: @@ -104,16 +96,3 @@ jobs: python_version: "${{ needs.metadata.outputs.python_version }}" terraform_version: "${{ needs.metadata.outputs.terraform_version }}" version: "${{ needs.metadata.outputs.version }}" - acceptance-stage: # Recommended maximum execution time is 10 minutes - name: "Acceptance stage" - needs: [metadata, build-stage] - uses: ./.github/workflows/stage-4-acceptance.yaml - if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) - with: - build_datetime: "${{ needs.metadata.outputs.build_datetime }}" - build_timestamp: "${{ needs.metadata.outputs.build_timestamp }}" - build_epoch: "${{ needs.metadata.outputs.build_epoch }}" - nodejs_version: "${{ needs.metadata.outputs.nodejs_version }}" - python_version: "${{ needs.metadata.outputs.python_version }}" - terraform_version: "${{ needs.metadata.outputs.terraform_version }}" - version: "${{ needs.metadata.outputs.version }}" diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 6cb7147e..6a716cd7 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -17,13 +17,11 @@ jobs: name: Manage preview environment runs-on: ubuntu-latest - # Needed for OIDC → AWS (recommended) permissions: id-token: write contents: read pull-requests: write - # One job per branch at a time concurrency: group: preview-${{ github.head_ref || github.ref_name }} cancel-in-progress: true @@ -35,7 +33,6 @@ jobs: - name: Checkout repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - # Configure AWS credentials (OIDC recommended) - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@4c2b9cc816c86555b61460789ac95da17d7e829b with: @@ -49,10 +46,7 @@ jobs: - name: Compute branch metadata id: meta run: | - # For PRs, head_ref is the source branch name RAW_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - - # Sanitize branch name for tags / hostnames (lowercase, only allowed chars) SANITIZED_BRANCH=$( printf '%s' "$RAW_BRANCH" \ | tr '[:upper:]' '[:lower:]' \ @@ -60,24 +54,15 @@ jobs: | tr -c 'a-z0-9-' '-' \ | sed -E 's/-{2,}/-/g; s/^-+//; s/-+$//' ) - - # Last resort fallback if everything got stripped if [ -z "$SANITIZED_BRANCH" ]; then SANITIZED_BRANCH="invalid-branch-name" fi - echo "raw_branch=$RAW_BRANCH" >> $GITHUB_OUTPUT echo "branch_name=$SANITIZED_BRANCH" >> $GITHUB_OUTPUT - - # ECR repo URL (must match core stack's ECR repo) ECR_URL="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}" echo "ecr_url=$ECR_URL" >> $GITHUB_OUTPUT - - # Terraform state key for this preview env TF_STATE_KEY="${PREVIEW_STATE_PREFIX}${SANITIZED_BRANCH}.tfstate" echo "tf_state_key=$TF_STATE_KEY" >> $GITHUB_OUTPUT - - # ALB listener rule priority - derive from PR number (must be unique per listener) if [ -n "${{ github.event.number }}" ]; then PRIORITY=$(( 1000 + ${{ github.event.number }} )) else @@ -98,7 +83,6 @@ jobs: run: | IMAGE_TAG="${{ steps.meta.outputs.branch_name }}" ECR_URL="${{ steps.meta.outputs.ecr_url }}" - make build IMAGE_TAG="${IMAGE_TAG}" ECR_URL="${ECR_URL}" - name: Push Docker image to ECR @@ -106,7 +90,6 @@ jobs: run: | IMAGE_TAG="${{ steps.meta.outputs.branch_name }}" ECR_URL="${{ steps.meta.outputs.ecr_url }}" - docker push "${ECR_URL}:${IMAGE_TAG}" - name: Setup Terraform @@ -114,8 +97,6 @@ jobs: with: terraform_version: 1.14.0 - # ---------- APPLY (PR opened / updated) ---------- - - name: Terraform init (apply) if: github.event.action != 'closed' working-directory: infrastructure/environments/preview @@ -133,8 +114,7 @@ jobs: TF_VAR_image_tag: ${{ steps.meta.outputs.branch_name }} TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }} run: | - terraform apply \ - -auto-approve + terraform apply -auto-approve - name: Capture preview TF outputs if: github.event.action != 'closed' @@ -142,19 +122,27 @@ jobs: working-directory: infrastructure/environments/preview run: | terraform output -json > tf-output.json - URL=$(jq -r '.url.value' tf-output.json) echo "preview_url=$URL" >> $GITHUB_OUTPUT - TG=$(jq -r '.target_group_arn.value' tf-output.json) echo "target_group=$TG" >> $GITHUB_OUTPUT - ECS_SERVICE=$(jq -r '.ecs_service_name.value' tf-output.json) echo "ecs_service=$ECS_SERVICE" >> $GITHUB_OUTPUT - ECS_CLUSTER=$(jq -r '.ecs_cluster_name.value' tf-output.json) echo "ecs_cluster=$ECS_CLUSTER" >> $GITHUB_OUTPUT + - name: Compute preview host + id: set-host + if: github.event.action != 'closed' + run: | + PREVIEW_URL='${{ steps.tf-output.outputs.preview_url }}' + if [ -z "$PREVIEW_URL" ] || [ "$PREVIEW_URL" = "null" ]; then + echo "host=missing" >> "$GITHUB_OUTPUT" + exit 0 + fi + HOST=$(printf '%s' "$PREVIEW_URL" | sed -E 's#^https?://##' | sed -E 's#/.*$##') + echo "host=${HOST}" >> "$GITHUB_OUTPUT" + - name: Get proxygen machine user details id: proxygen-machine-user uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 @@ -184,7 +172,6 @@ jobs: proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} - # ---------- Ensure re-deployment (PR updated) ---------- - name: Force ECS service redeployment if: github.event.action == 'synchronize' id: await-redeployment @@ -195,7 +182,6 @@ jobs: --force-new-deployment \ --region ${{ env.AWS_REGION }} - # ---------- DESTROY (PR closed) ---------- - name: Terraform init (destroy) if: github.event.action == 'closed' working-directory: infrastructure/environments/preview @@ -215,14 +201,13 @@ jobs: run: | terraform destroy -auto-approve - # ---------- Wait on AWS tasks and notify ---------- - name: Await deployment completion if: github.event.action != 'closed' run: | aws ecs wait services-stable \ - --cluster ${{ steps.tf-output.outputs.ecs_cluster }} \ - --services ${{ steps.tf-output.outputs.ecs_service }} \ - --region ${{ env.AWS_REGION }} + --cluster ${{ steps.tf-output.outputs.ecs_cluster }} \ + --services ${{ steps.tf-output.outputs.ecs_service }} \ + --region ${{ env.AWS_REGION }} - name: Get mTLS certs for testing if: github.event.action != 'closed' @@ -246,8 +231,6 @@ jobs: echo "http_result=missing-url" >> "$GITHUB_OUTPUT" exit 0 fi - - # Reachability check: allow 404 (app routes might not exist yet) but fail otherwise printf '%s' "$_cds_gateway_dev_mtls_client1_key_secret" > /tmp/client1-key.pem printf '%s' "$_cds_gateway_dev_mtls_client1_key_public" > /tmp/client1-cert.pem STATUS=$(curl \ @@ -285,6 +268,218 @@ jobs: echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT" exit 0 + - name: Prepare mTLS cert files for tests + if: github.event.action != 'closed' + run: | + printf '%s' "$_cds_gateway_dev_mtls_client1_key_secret" > /tmp/client1-key.pem + printf '%s' "$_cds_gateway_dev_mtls_client1_key_public" > /tmp/client1-cert.pem + chmod 600 /tmp/client1-key.pem /tmp/client1-cert.pem + + # UNIT TESTS + - name: Run unit tests against preview + if: github.event.action != 'closed' + env: + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: | + make test-unit + + - name: Upload unit test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: unit-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check unit-tests.xml exists + id: check-unit + if: always() + run: | + TARGET="gateway-api/test-artefacts/unit-tests.xml" + echo "Checking for $TARGET" + if [ -f "$TARGET" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Found $TARGET" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Missing $TARGET" >&2 + echo "Listing gateway-api/test-artefacts for debugging:" + ls -la gateway-api/test-artefacts || true + fi + + - name: Publish unit test results to summary + if: ${{ always() && steps.check-unit.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/unit-tests.xml + + # CONTRACT TESTS + - name: Run contract tests + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: | + make test-contract + + - name: Upload contract test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: contract-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check contract-tests.xml exists + id: check-contract + if: always() + run: | + TARGET="gateway-api/test-artefacts/contract-tests.xml" + echo "Checking for $TARGET" + if [ -f "$TARGET" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Found $TARGET" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Missing $TARGET" >&2 + echo "Listing gateway-api/test-artefacts for debugging:" + ls -la gateway-api/test-artefacts || true + fi + + - name: Publish contract test results to summary + if: ${{ always() && steps.check-contract.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/contract-tests.xml + + # SCHEMA TESTS + - name: Run schema validation tests against preview + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: | + make test-schema + + - name: Upload schema test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: schema-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check schema-tests.xml exists + id: check-schema + if: always() + run: | + TARGET="gateway-api/test-artefacts/schema-tests.xml" + echo "Checking for $TARGET" + if [ -f "$TARGET" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Found $TARGET" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Missing $TARGET" >&2 + echo "Listing gateway-api/test-artefacts for debugging:" + ls -la gateway-api/test-artefacts || true + fi + + - name: Publish schema test results to summary + if: ${{ always() && steps.check-schema.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/schema-tests.xml + + # INTEGRATION TESTS + - name: Run integration tests against preview + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: | + make test-integration + + - name: Upload integration test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: integration-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check integration-tests.xml exists + id: check-integration + if: always() + run: | + TARGET="gateway-api/test-artefacts/integration-tests.xml" + echo "Checking for $TARGET" + if [ -f "$TARGET" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Found $TARGET" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Missing $TARGET" >&2 + echo "Listing gateway-api/test-artefacts for debugging:" + ls -la gateway-api/test-artefacts || true + fi + + - name: Publish integration test results to summary + if: ${{ always() && steps.check-integration.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/integration-tests.xml + + # ACCEPTANCE TESTS + - name: Run acceptance tests against preview + if: github.event.action != 'closed' + env: + BASE_URL: ${{ steps.tf-output.outputs.preview_url }} + HOST: ${{ steps.set-host.outputs.host }} + MTLS_CERT: /tmp/client1-cert.pem + MTLS_KEY: /tmp/client1-key.pem + run: | + make test-acceptance + + - name: Upload acceptance test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: acceptance-test-results + path: gateway-api/test-artefacts/ + retention-days: 30 + + - name: Check acceptance-tests.xml exists + id: check-acceptance + if: always() + run: | + TARGET="gateway-api/test-artefacts/acceptance-tests.xml" + echo "Checking for $TARGET" + if [ -f "$TARGET" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Found $TARGET" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Missing $TARGET" >&2 + echo "Listing gateway-api/test-artefacts for debugging:" + ls -la gateway-api/test-artefacts || true + fi + + - name: Publish acceptance test results to summary + if: ${{ always() && steps.check-acceptance.outputs.exists == 'true' }} + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 + with: + paths: gateway-api/test-artefacts/acceptance-tests.xml + + - name: Remove mTLS temp files + if: github.event.action != 'closed' + run: | + rm -f /tmp/client1-key.pem /tmp/client1-cert.pem || true + - name: Comment function name on PR if: github.event_name == 'pull_request' && github.event.action != 'closed' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd @@ -300,27 +495,22 @@ jobs: const issueNumber = context.issue.number; const smokeStatus = '${{ steps.smoke-test.outputs.http_status }}' || 'n/a'; const smokeResult = '${{ steps.smoke-test.outputs.http_result }}' || 'not-run'; - const smokeLabels = { success: ':white_check_mark: Passed', 'allowed-404': ':white_check_mark: Allowed 404', 'unexpected-status': ':x: Unexpected status', 'missing-url': ':x: Missing URL', }; - const smokeReadable = smokeLabels[smokeResult] ?? smokeResult; - const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page: 100, }); - for (const comment of comments) { const isBot = comment.user?.login === 'github-actions[bot]'; const isPreviewUpdate = comment.body?.includes('Deployment Complete'); - if (isBot && isPreviewUpdate) { await github.rest.issues.deleteComment({ owner, @@ -329,7 +519,6 @@ jobs: }); } } - const lines = [ '**Deployment Complete**', `- Preview URL: [${url}](${url}) — [Health endpoint](${url}/health)`, @@ -339,7 +528,6 @@ jobs: `- ECS Service: \`${service}\``, `- ALB Target: \`${alb}\``, ]; - await github.rest.issues.createComment({ owner, repo, @@ -347,7 +535,6 @@ jobs: body: lines.join('\n'), }); - # ---------- Security scanning ---------- - name: Trivy IaC scan if: github.event.action != 'closed' uses: nhs-england-tools/trivy-action/iac-scan@3456c1657a37d500027fd782e6b08911725392da @@ -363,8 +550,8 @@ jobs: artifact-name: trivy-scan-${{ steps.meta.outputs.branch_name }} - name: Generate SBOM - uses: nhs-england-tools/trivy-action/sbom-scan@3456c1657a37d500027fd782e6b08911725392da if: github.event.action != 'closed' + uses: nhs-england-tools/trivy-action/sbom-scan@3456c1657a37d500027fd782e6b08911725392da with: image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} artifact-name: trivy-sbom-${{ steps.meta.outputs.branch_name }} diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml deleted file mode 100644 index 32a5fd2b..00000000 --- a/.github/workflows/stage-2-test.yaml +++ /dev/null @@ -1,236 +0,0 @@ -name: "Test stage" - -env: - BASE_URL: "http://localhost:5000" - HOST: "localhost" - -on: - workflow_call: - inputs: - python_version: - description: "Python version, set by the CI/CD pipeline workflow" - required: true - type: string - secrets: - SONAR_TOKEN: - description: "SonarCloud token for authentication" - required: true - -jobs: - create-coverage-name: - name: "Create coverage artefact name" - runs-on: ubuntu-latest - outputs: - coverage-name: ${{ steps.create_name.outputs.artefact-name }} - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - id: create_name - name: "Generate unique coverage artefact name" - uses: ./.github/actions/create-artefact-name - with: - prefix: coverage - - test-unit: - name: "Unit tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Run unit test suite" - run: make test-unit - - name: "Upload unit test results" - if: always() - uses: actions/upload-artifact@v5 - with: - name: unit-test-results - path: gateway-api/test-artefacts/ - retention-days: 30 - - name: "Publish unit test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/unit-tests.xml - - test-contract: - name: "Contract tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - - name: "Run contract tests" - run: make test-contract - - name: "Upload contract test results" - if: always() - uses: actions/upload-artifact@v5 - with: - name: contract-test-results - path: gateway-api/test-artefacts/ - retention-days: 30 - - name: "Publish contract test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/contract-tests.xml - - test-schema: - name: "Schema validation tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - - name: "Run schema validation tests" - run: make test-schema - - name: "Upload schema test results" - if: always() - uses: actions/upload-artifact@v5 - with: - name: schema-test-results - path: gateway-api/test-artefacts/ - retention-days: 30 - - name: "Publish schema test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/schema-tests.xml - - test-integration: - name: "Integration tests" - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - - name: "Run integration test" - run: make test-integration - - name: "Upload integration test results" - if: always() - uses: actions/upload-artifact@v5 - with: - name: integration-test-results - path: gateway-api/test-artefacts/ - retention-days: 30 - - name: "Publish integration test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/integration-tests.xml - - test-acceptance: - name: "Acceptance tests" - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start app" - uses: ./.github/actions/start-app - with: - python-version: ${{ inputs.python_version }} - max-seconds: 90 - - name: "Run acceptance test" - run: make test-acceptance - - name: "Upload acceptance test results" - if: always() - uses: actions/upload-artifact@v5 - with: - name: acceptance-test-results - path: gateway-api/test-artefacts/ - retention-days: 30 - - name: "Publish acceptance test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: gateway-api/test-artefacts/acceptance-tests.xml - - merge-test-coverage: - name: "Merge test coverage" - needs: [create-coverage-name, test-unit, test-contract, test-schema, test-integration, test-acceptance] - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Download all test coverage artefacts" - uses: actions/download-artifact@v6 - with: - path: gateway-api/test-artefacts/ - merge-multiple: false - - name: "Merge coverage data" - run: make test-coverage - - name: "Rename coverage XML with unique name" - run: | - cd gateway-api/test-artefacts - mv coverage-merged.xml ${{ needs.create-coverage-name.outputs.coverage-name }}.xml - - name: "Upload combined coverage report" - if: always() - uses: actions/upload-artifact@v5 - with: - name: ${{ needs.create-coverage-name.outputs.coverage-name }} - path: gateway-api/test-artefacts - retention-days: 30 - - sonarcloud-analysis: - name: "SonarCloud Analysis" - needs: [create-coverage-name, merge-test-coverage] - if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - with: - fetch-depth: 0 # Full history is needed for better analysis - - name: "Download merged coverage report" - uses: actions/download-artifact@v6 - with: - name: ${{ needs.create-coverage-name.outputs.coverage-name }} - path: coverage-reports/ - - name: "SonarCloud Scan" - uses: SonarSource/sonarqube-scan-action@a31c9398be7ace6bbfaf30c0bd5d415f843d45e9 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.organization=${{ vars.SONAR_ORGANISATION_KEY }} - -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} - -Dsonar.python.coverage.reportPaths=coverage-reports/${{ needs.create-coverage-name.outputs.coverage-name }}.xml diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml deleted file mode 100644 index b9d1a157..00000000 --- a/.github/workflows/stage-4-acceptance.yaml +++ /dev/null @@ -1,126 +0,0 @@ -name: "Acceptance stage" - -on: - workflow_call: - inputs: - build_datetime: - description: "Build datetime, set by the CI/CD pipeline workflow" - required: true - type: string - build_timestamp: - description: "Build timestamp, set by the CI/CD pipeline workflow" - required: true - type: string - build_epoch: - description: "Build epoch, set by the CI/CD pipeline workflow" - required: true - type: string - nodejs_version: - description: "Node.js version, set by the CI/CD pipeline workflow" - required: true - type: string - python_version: - description: "Python version, set by the CI/CD pipeline workflow" - required: true - type: string - terraform_version: - description: "Terraform version, set by the CI/CD pipeline workflow" - required: true - type: string - version: - description: "Version of the software, set by the CI/CD pipeline workflow" - required: true - type: string - -jobs: - environment-set-up: - name: "Environment set up" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Create infractructure" - run: | - echo "Creating infractructure..." - - name: "Update database" - run: | - echo "Updating database..." - - name: "Deploy application" - run: | - echo "Deploying application..." - test-security: - name: "Security test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run security test" - run: | - make test-security - - name: "Save result" - run: | - echo "Nothing to save" - test-ui: - name: "UI test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run UI test" - run: | - make test-ui - - name: "Save result" - run: | - echo "Nothing to save" - test-ui-performance: - name: "UI performance test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run UI performance test" - run: | - make test-ui-performance - - name: "Save result" - run: | - echo "Nothing to save" - - test-load: - name: "Load test" - runs-on: ubuntu-latest - needs: environment-set-up - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Run load tests" - run: | - make test-load - - name: "Save result" - run: | - echo "Nothing to save" - environment-tear-down: - name: "Environment tear down" - runs-on: ubuntu-latest - needs: - [ - test-load, - test-security, - test-ui-performance, - test-ui, - ] - if: always() - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Tear down environment" - run: | - echo "Tearing down environment..." diff --git a/gateway-api/tests/acceptance/features/hello_world.feature b/gateway-api/tests/acceptance/features/hello_world.feature deleted file mode 100644 index a5375d50..00000000 --- a/gateway-api/tests/acceptance/features/hello_world.feature +++ /dev/null @@ -1,16 +0,0 @@ -Feature: Gateway API Hello World - As an API consumer - I want to interact with the Gateway API - So that I can verify it responds correctly to valid and invalid requests - - Background: The API is running - Given the API is running - - Scenario: Get hello world message - When I send "World" to the endpoint - Then the response status code should be 200 - And the response should contain "Hello, World!" - - Scenario: Accessing a non-existent endpoint returns a 404 - When I send "nonexistent" to the endpoint - Then the response status code should be 404 diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index 3485f224..f87001cf 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -40,6 +40,7 @@ def send_to_nonexistent_endpoint( url=nonexistent_endpoint, data=json.dumps(simple_request_payload), timeout=timedelta(seconds=1).total_seconds(), + cert=client.cert, ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 7fef2c54..826060b9 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -21,6 +21,13 @@ def __init__(self, base_url: str, timeout: timedelta = timedelta(seconds=1)): self.base_url = base_url self._timeout = timeout.total_seconds() + cert = None + cert_path = os.getenv("MTLS_CERT") + key_path = os.getenv("MTLS_KEY") + if cert_path and key_path: + cert = (cert_path, key_path) + self.cert = cert + def send_to_get_structured_record_endpoint( self, payload: str, headers: dict[str, str] | None = None ) -> requests.Response: @@ -40,6 +47,7 @@ def send_to_get_structured_record_endpoint( data=payload, headers=default_headers, timeout=self._timeout, + cert=self.cert, ) def send_health_check(self) -> requests.Response: @@ -49,7 +57,7 @@ def send_health_check(self) -> requests.Response: Response object from the request """ url = f"{self.base_url}/health" - return requests.get(url=url, timeout=self._timeout) + return requests.get(url=url, timeout=self._timeout, cert=self.cert) @pytest.fixture diff --git a/gateway-api/tests/contract/test_provider_contract.py b/gateway-api/tests/contract/test_provider_contract.py index 8604d2bf..4c8f28e3 100644 --- a/gateway-api/tests/contract/test_provider_contract.py +++ b/gateway-api/tests/contract/test_provider_contract.py @@ -4,31 +4,108 @@ satisfies the contracts defined by consumers. """ +import os +import threading +from collections.abc import Generator +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import ClassVar + +import pytest +import requests from pact import Verifier -class TestProviderContract: - """Provider contract tests to verify the API implementation.""" +def get_mtls_cert() -> tuple[str, str] | None: + cert_path = os.getenv("MTLS_CERT") + key_path = os.getenv("MTLS_KEY") + if not cert_path or not key_path: + return None + return (cert_path, key_path) + + +class MtlsProxyHandler(BaseHTTPRequestHandler): + """ + A simple proxy that forwards requests to the target HTTPS URL + attaching the mTLS client certificates. + """ + + target_base: ClassVar[str] = "" + cert: ClassVar[tuple[str, str] | None] = None + + def do_proxy(self, method: str) -> None: + if not self.target_base: + self.send_error(500, "Target base URL not set") + return + + url = f"{self.target_base}{self.path}" + + content_length_header = self.headers.get("Content-Length") + content_length = int(content_length_header) if content_length_header else 0 + body = self.rfile.read(content_length) if content_length > 0 else None + + headers = {k: v for k, v in self.headers.items() if k.lower() != "host"} + + try: + response = requests.request( + method=method, + url=url, + headers=headers, + data=body, + cert=self.cert, + verify=False, + timeout=30, + ) + + self.send_response(response.status_code) + for k, v in response.headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(response.content) + + except Exception as e: + self.send_error(500, f"Proxy Error: {str(e)}") + + def do_GET(self) -> None: + self.do_proxy("GET") + + def do_POST(self) -> None: + self.do_proxy("POST") + + def do_PUT(self) -> None: + self.do_proxy("PUT") + + +@pytest.fixture(scope="module") +def mtls_proxy(base_url: str) -> Generator[str, None, None]: + """ + Spins up a local HTTP server in a separate thread. + Returns the URL of this local proxy. + """ + MtlsProxyHandler.target_base = base_url + MtlsProxyHandler.cert = get_mtls_cert() + + server = HTTPServer(("localhost", 0), MtlsProxyHandler) + port = server.server_port + proxy_url = f"http://localhost:{port}" + + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + yield proxy_url - def test_provider_honors_consumer_contract( - self, base_url: str, hostname: str - ) -> None: - """Verify that the provider satisfies all consumer contracts. + server.shutdown() - This test verifies the Flask API against the pact files - generated by consumer tests. - """ - # Create a verifier for the provider - verifier = Verifier(name="GatewayAPIProvider", host=hostname) +def test_provider_honors_consumer_contract(mtls_proxy: str) -> None: + verifier = Verifier( + name="GatewayAPIProvider", + ) - # Add the transport (how to connect to the provider) - verifier.add_transport(url=base_url) + verifier.add_transport(url=mtls_proxy) - # Add the pact file as a source - verifier.add_source( - "tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json" - ) + verifier.add_source( + "tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json" + ) - # Verify the provider against the pact - verifier.verify() + verifier.verify() diff --git a/gateway-api/tests/schema/test_openapi_schema.py b/gateway-api/tests/schema/test_openapi_schema.py index 407f5de4..0d7c6791 100644 --- a/gateway-api/tests/schema/test_openapi_schema.py +++ b/gateway-api/tests/schema/test_openapi_schema.py @@ -4,6 +4,7 @@ from the OpenAPI specification and validate the API implementation. """ +import os from pathlib import Path import schemathesis @@ -37,10 +38,21 @@ def test_api_schema_compliance(case: Case, base_url: str) -> None: Note: Server error checks are disabled because the API may return 500 errors when testing with randomly generated NHS numbers that don't exist in the PDS. """ - # Call the API and validate the response against the schema - # Exclude not_a_server_error check as 500 responses are expected for - # non-existent patients + + cert = None + cert_path = os.getenv("MTLS_CERT") + key_path = os.getenv("MTLS_KEY") + if cert_path and key_path: + cert = (cert_path, key_path) + + if case.headers is not None: + case.headers["Ods-from"] = "test-ods-code" + case.headers["Ssp-TraceID"] = "test-trace-id" + case.call_and_validate( base_url=base_url, excluded_checks=[schemathesis.checks.not_a_server_error], + cert=cert, + verify=False, + timeout=30, )