diff --git a/.github/agents/unit_test.agent.md b/.github/agents/unit_test.agent.md index 819885a0..01bdb1fc 100644 --- a/.github/agents/unit_test.agent.md +++ b/.github/agents/unit_test.agent.md @@ -19,6 +19,9 @@ You are an expert unit test writer for this project. - **File Structure:** - `gateway-api/src/**/*.py` – Files and folders that require unit tests (you READ from here) - `gateway-api/src/**/test_*.py` – All unit tests (you WRITE to here) +- **Running tests:** + - `make test-unit` to run all unit tests + - `poetry run pytest path/to/test` from `gateway-api/` to runs specific tests. ## Unit test practices diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md index 8cbbbf4f..9d2ebc7e 100644 --- a/.github/instructions/copilot-instructions.md +++ b/.github/instructions/copilot-instructions.md @@ -14,11 +14,11 @@ After deploying the container locally, `make test` will run all tests and captur Individual test suites can be run with: -- Unit tests: `make unit` -- Acceptance tests: `make acceptance` -- Integration tests: `make integration` -- Schema tests: `make schema` -- Contract tests: `make contract` +- Unit tests: `make test-unit` +- Acceptance tests: `make test-acceptance` +- Integration tests: `make test-integration` +- Schema tests: `make test-schema` +- Contract tests: `make test-contract` The container must be running in order to successfully run any of the test suites other than the unit tests. diff --git a/.vscode/cspell-dictionary.txt b/.vscode/cspell-dictionary.txt index 4ae090fc..0b80556b 100644 --- a/.vscode/cspell-dictionary.txt +++ b/.vscode/cspell-dictionary.txt @@ -2,5 +2,6 @@ asid fhir getstructuredrecord gpconnect +searchset proxygen usefixtures diff --git a/Makefile b/Makefile index c3b41936..7d93321a 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ 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_2_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 --platform musllinux_1_1_x86_64 --only-binary=:all: # Copy main file separately as it is not included within the package. @rm -rf ../infrastructure/images/gateway-api/resources/build/ @mkdir ../infrastructure/images/gateway-api/resources/build/ diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index 0a987ffc..9449ebe2 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -55,6 +55,16 @@ paths: type: string enum: ["Parameters"] example: "Parameters" + meta: + type: object + properties: + lastUpdated: + type: string + format: date-time + example: "2026-01-12T10:00:00Z" + versionId: + type: string + example: "1" parameter: type: array minItems: 1 @@ -76,7 +86,7 @@ paths: properties: system: type: string - minLength: 1 + enum: ["https://fhir.nhs.uk/Id/nhs-number"] example: "https://fhir.nhs.uk/Id/nhs-number" value: type: string diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 3ca46689..5c03d22d 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -6,7 +6,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -711,14 +711,14 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "hypothesis" -version = "6.151.9" +version = "6.151.10" description = "The property-based testing library for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9"}, - {file = "hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca"}, + {file = "hypothesis-6.151.10-py3-none-any.whl", hash = "sha256:b0d7728f0c8c2be009f89fcdd6066f70c5439aa0f94adbb06e98261d05f49b05"}, + {file = "hypothesis-6.151.10.tar.gz", hash = "sha256:6c9565af8b4aa3a080b508f66ce9c2a77dd613c7e9073e27fc7e4ef9f45f8a27"}, ] [package.dependencies] @@ -1528,56 +1528,62 @@ files = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.0" description = "Optional static typing for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, - {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, - {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, - {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, - {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, - {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, - {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, - {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, - {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, - {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, - {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, - {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, - {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, - {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, - {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, - {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, + {file = "mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8"}, + {file = "mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a"}, + {file = "mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865"}, + {file = "mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca"}, + {file = "mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018"}, + {file = "mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13"}, + {file = "mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281"}, + {file = "mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b"}, + {file = "mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367"}, + {file = "mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62"}, + {file = "mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0"}, + {file = "mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f"}, + {file = "mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e"}, + {file = "mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442"}, + {file = "mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214"}, + {file = "mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e"}, + {file = "mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651"}, + {file = "mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5"}, + {file = "mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78"}, + {file = "mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489"}, + {file = "mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33"}, + {file = "mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134"}, + {file = "mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c"}, + {file = "mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe"}, + {file = "mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f"}, + {file = "mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726"}, + {file = "mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69"}, + {file = "mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e"}, + {file = "mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948"}, + {file = "mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5"}, + {file = "mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188"}, + {file = "mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83"}, + {file = "mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2"}, + {file = "mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732"}, + {file = "mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef"}, + {file = "mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1"}, + {file = "mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436"}, + {file = "mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6"}, + {file = "mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526"}, + {file = "mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787"}, + {file = "mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb"}, + {file = "mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd"}, + {file = "mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e"}, + {file = "mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3"}, ] [package.dependencies] -librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" +pathspec = ">=1.0.0" typing_extensions = ">=4.6.0" [package.extras] @@ -1585,6 +1591,7 @@ dmypy = ["psutil (>=4.0)"] faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] +native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] reports = ["lxml"] [[package]] @@ -1921,7 +1928,7 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -1943,7 +1950,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -2097,14 +2104,14 @@ yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] @@ -2455,14 +2462,14 @@ rpds-py = ">=0.7.0" [[package]] name = "requests" -version = "2.33.0" +version = "2.33.1" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, - {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, + {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, + {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, ] [package.dependencies] @@ -2473,7 +2480,6 @@ urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] @@ -2865,14 +2871,14 @@ files = [ [[package]] name = "types-requests" -version = "2.32.4.20260324" +version = "2.33.0.20260327" description = "Typing stubs for requests" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "types_requests-2.32.4.20260324-py3-none-any.whl", hash = "sha256:f83ef2deb284fe99a249b8b0b0a3e4b9809e01ff456063c4df0aac7670c07ab9"}, - {file = "types_requests-2.32.4.20260324.tar.gz", hash = "sha256:33a2a9ccb1de7d4e4da36e347622c35418f6761269014cc32857acabd5df739e"}, + {file = "types_requests-2.33.0.20260327-py3-none-any.whl", hash = "sha256:fde0712be6d7c9a4d490042d6323115baf872d9a71a22900809d0432de15776e"}, + {file = "types_requests-2.33.0.20260327.tar.gz", hash = "sha256:f4f74f0b44f059e3db420ff17bd1966e3587cdd34062fe38a23cda97868f8dd8"}, ] [package.dependencies] @@ -2896,7 +2902,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -2908,7 +2914,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -3156,4 +3162,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.14,<4.0.0" -content-hash = "43716cdff716f002646659361e605e2697969f59cb2db1740743f022140065ab" +content-hash = "c7aa28002e5008ca94fc5657e2c78833c662881d5b86dc7eb9b75560492dc531" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index f0f6dd17..cf3cac15 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -14,6 +14,7 @@ flask = "^3.1.3" types-flask = "^1.1.6" requests = "^2.33.0" pyjwt = "^2.12.0" +pydantic = "^2.0" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, diff --git a/gateway-api/src/fhir/README.md b/gateway-api/src/fhir/README.md new file mode 100644 index 00000000..a234c482 --- /dev/null +++ b/gateway-api/src/fhir/README.md @@ -0,0 +1,76 @@ +# FHIR Types in Gateway API + +## What is FHIR? + +FHIR (Fast Healthcare Interoperability Resources) is the HL7 standard for exchanging healthcare information as structured resources over HTTP APIs. + +Read more on the standards: [R4](https://hl7.org/fhir/R4/overview.html) and [STU3](https://hl7.org/fhir/STU3/overview.html). + +In this codebase, the FHIR package provides strongly typed Python models for request validation, response parsing, and safe serialization. + +## FHIR versions in Clinical Data Sharing APIs + +Two FHIR versions are used: + +- STU3: used only for inbound Gateway API operation messages with `resourceType` Parameters (the Access Record Structured request payload). +- R4: used for all other typed resources in this module, including PDS FHIR resources such as Patient. + +Version behaviour in the current flow: + +- Inbound request body is validated as STU3 Parameters. +- Outbound provider response body is returned without transformation (mirrored payload). +- PDS, SDS, and internal typed handling use R4 resource models. + +## How Pydantic is used + +This package uses Pydantic to make FHIR payload handling explicit and safe: + +- Model validation: model_validate(...) is used to parse inbound JSON into typed models. +- Field aliasing: FHIR JSON names like `resourceType`, `fullUrl`, `lastUpdated` are mapped with `Field(alias=...)`. +- Type constraints: `Annotated`, `Literal`, and `min_length` constraints enforce schema-like rules. +- Runtime guards: validators check that `resourceType` and identifier system values match expected FHIR semantics. +- Polymorphism: the Resource base type dispatches to the correct subclass from `resourceType`. +- Serialization: `model_dump()`/`model_dump_json()` default to exclude_none=True to avoid emitting empty FHIR fields. + +Typical patterns in this code: + +- Parse JSON from API input or upstream systems into typed models. +- Access domain properties (for example, `Patient.nhs_number`) instead of raw dictionary traversal. +- Serialize models back to canonical FHIR JSON with aliases preserved. + +## Example usage + +The example below shows how to load a simple FHIR R4 Patient payload and obtain the GP ODS code. + +```python +from fhir.r4 import Patient + +payload = { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + } + ], + "generalPractitioner": [ + { + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345", + }, + } + ], +} + +patient = Patient.model_validate(payload) + +nhs_number = patient.nhs_number +gp_ods_code = patient.gp_ods_code + +print(nhs_number) # 9000000009 +print(gp_ods_code) # A12345 +``` + +If `generalPractitioner` is missing, `patient.gp_ods_code` returns `None`. diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index f7b15f5c..2c79ab27 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,22 +1,3 @@ -"""FHIR data types and resources.""" +from .resources.resource import Resource -from fhir.bundle import Bundle, BundleEntry -from fhir.general_practitioner import GeneralPractitioner -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", - "GeneralPractitioner", -] +__all__ = ["Resource"] diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py deleted file mode 100644 index 5fbc9a3b..00000000 --- a/gateway-api/src/fhir/bundle.py +++ /dev/null @@ -1,18 +0,0 @@ -"""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/elements/__init__.py b/gateway-api/src/fhir/elements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/elements/identifier.py b/gateway-api/src/fhir/elements/identifier.py new file mode 100644 index 00000000..4b2238aa --- /dev/null +++ b/gateway-api/src/fhir/elements/identifier.py @@ -0,0 +1,33 @@ +from abc import ABC +from dataclasses import dataclass +from typing import ClassVar + +from pydantic import model_validator + + +@dataclass(frozen=True) +class Identifier(ABC): + """ + A FHIR Identifier element. See https://hl7.org/fhir/R4/datatypes.html#Identifier. + Attributes: + system: The namespace for the identifier value. + value: The value that is unique within the system. + """ + + _expected_system: ClassVar[str] = "__unknown__" + + value: str + system: str + + @model_validator(mode="after") + def validate_system(self) -> "Identifier": + if self.system != self._expected_system: + raise ValueError( + f"Identifier system '{self.system}' does not match expected " + f"system '{self._expected_system}'." + ) + return self + + @classmethod + def __init_subclass__(cls, expected_system: str) -> None: + cls._expected_system = expected_system diff --git a/gateway-api/src/fhir/elements/test_elements.py b/gateway-api/src/fhir/elements/test_elements.py new file mode 100644 index 00000000..967b3a52 --- /dev/null +++ b/gateway-api/src/fhir/elements/test_elements.py @@ -0,0 +1,88 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from fhir.elements.identifier import Identifier + + +class TestIdentifierInitSubclass: + def test_subclass_sets_expected_system(self) -> None: + class _Custom(Identifier, expected_system="https://example.com"): + pass + + assert _Custom._expected_system == "https://example.com", ( + "_expected_system should be set by __init_subclass__" + ) + + def test_multiple_subclasses_have_independent_expected_system(self) -> None: + class _A(Identifier, expected_system="system-a"): + pass + + class _B(Identifier, expected_system="system-b"): + pass + + assert _A._expected_system == "system-a", ( + "_A._expected_system should be 'system-a'" + ) + assert _B._expected_system == "system-b", ( + "_B._expected_system should be 'system-b'" + ) + + def test_subclass_without_expected_system_raises(self) -> None: + with pytest.raises(TypeError): + + class _Bad(Identifier): # type: ignore[call-arg] + pass + + +class TestIdentifierModelValidate: + def test_valid_system_passes_validation(self) -> None: + class _TestId(Identifier, expected_system="https://example.com"): + pass + + class _Container(BaseModel): + identifier: _TestId + + result = _Container.model_validate( + {"identifier": {"system": "https://example.com", "value": "abc-123"}} + ) + + assert result.identifier.system == "https://example.com", ( + "system should match the expected system" + ) + assert result.identifier.value == "abc-123", "value should be 'abc-123'" + + def test_invalid_system_fails_validation(self) -> None: + class _TestId(Identifier, expected_system="expected-system"): + pass + + class _Container(BaseModel): + identifier: _TestId + + with pytest.raises( + ValidationError, + match="Identifier system 'invalid-system' does not match expected " + "system 'expected-system'.", + ): + _Container.model_validate( + {"identifier": {"system": "invalid-system", "value": "some-value"}} + ) + + def test_missing_value_fails_validation(self) -> None: + class _TestId(Identifier, expected_system="sys"): + pass + + class _Container(BaseModel): + identifier: _TestId + + with pytest.raises(ValidationError): + _Container.model_validate({"identifier": {"system": "sys"}}) + + def test_missing_system_fails_validation(self) -> None: + class _TestId(Identifier, expected_system="sys"): + pass + + class _Container(BaseModel): + identifier: _TestId + + with pytest.raises(ValidationError): + _Container.model_validate({"identifier": {"value": "v"}}) diff --git a/gateway-api/src/fhir/general_practitioner.py b/gateway-api/src/fhir/general_practitioner.py deleted file mode 100644 index 4e20d932..00000000 --- a/gateway-api/src/fhir/general_practitioner.py +++ /dev/null @@ -1,21 +0,0 @@ -"""FHIR GeneralPractitioner type.""" - -from typing import TypedDict - -from fhir.period import Period - - -class GeneralPractitionerIdentifier(TypedDict): - """Identifier for GeneralPractitioner""" - - system: str - value: str - period: Period - - -class GeneralPractitioner(TypedDict): - """FHIR GeneralPractitioner reference.""" - - id: str - type: str - identifier: GeneralPractitionerIdentifier diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py deleted file mode 100644 index 6b284c88..00000000 --- a/gateway-api/src/fhir/human_name.py +++ /dev/null @@ -1,12 +0,0 @@ -"""FHIR HumanName type.""" - -from typing import TypedDict - -from fhir.period import Period - - -class HumanName(TypedDict): - use: str - family: str - given: list[str] - period: Period diff --git a/gateway-api/src/fhir/identifier.py b/gateway-api/src/fhir/identifier.py deleted file mode 100644 index 4e59908d..00000000 --- a/gateway-api/src/fhir/identifier.py +++ /dev/null @@ -1,8 +0,0 @@ -"""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 deleted file mode 100644 index d25765f5..00000000 --- a/gateway-api/src/fhir/operation_outcome.py +++ /dev/null @@ -1,14 +0,0 @@ -"""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 deleted file mode 100644 index 30b7cce8..00000000 --- a/gateway-api/src/fhir/parameters.py +++ /dev/null @@ -1,15 +0,0 @@ -"""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 deleted file mode 100644 index 453a6f2a..00000000 --- a/gateway-api/src/fhir/patient.py +++ /dev/null @@ -1,17 +0,0 @@ -"""FHIR Patient resource.""" - -from typing import NotRequired, TypedDict - -from fhir.general_practitioner import GeneralPractitioner -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 - generalPractitioner: NotRequired[list[GeneralPractitioner]] diff --git a/gateway-api/src/fhir/period.py b/gateway-api/src/fhir/period.py deleted file mode 100644 index 6ac40b4f..00000000 --- a/gateway-api/src/fhir/period.py +++ /dev/null @@ -1,10 +0,0 @@ -"""FHIR Period type.""" - -from typing import NotRequired, TypedDict - - -class Period(TypedDict, total=False): - """FHIR Period type.""" - - start: str - end: NotRequired[str] diff --git a/gateway-api/src/fhir/r4/__init__.py b/gateway-api/src/fhir/r4/__init__.py new file mode 100644 index 00000000..d8e81c51 --- /dev/null +++ b/gateway-api/src/fhir/r4/__init__.py @@ -0,0 +1,34 @@ +"""FHIR data types and resources.""" + +from .elements.entry import Entry +from .elements.identifier import ( + ASIDIdentifier, + OrganizationIdentifier, + PartyKeyIdentifier, + PatientIdentifier, + UUIDIdentifier, +) +from .elements.reference import GeneralPractitioner, Reference +from .resources.bundle import Bundle +from .resources.device import Device +from .resources.endpoint import Endpoint +from .resources.organization import Organization +from .resources.patient import Patient +from .resources.practitioner import Practitioner + +__all__ = [ + "ASIDIdentifier", + "Bundle", + "Device", + "Endpoint", + "Entry", + "GeneralPractitioner", + "OrganizationIdentifier", + "Organization", + "PartyKeyIdentifier", + "Patient", + "PatientIdentifier", + "Practitioner", + "Reference", + "UUIDIdentifier", +] diff --git a/gateway-api/src/fhir/r4/elements/__init__.py b/gateway-api/src/fhir/r4/elements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/r4/elements/entry.py b/gateway-api/src/fhir/r4/elements/entry.py new file mode 100644 index 00000000..04093353 --- /dev/null +++ b/gateway-api/src/fhir/r4/elements/entry.py @@ -0,0 +1,10 @@ +from typing import Annotated + +from pydantic import BaseModel, Field, SerializeAsAny + +from fhir import Resource + + +class Entry(BaseModel): + full_url: str = Field(..., alias="fullUrl", frozen=True) + resource: Annotated[SerializeAsAny[Resource], Field(frozen=True)] diff --git a/gateway-api/src/fhir/r4/elements/identifier.py b/gateway-api/src/fhir/r4/elements/identifier.py new file mode 100644 index 00000000..d9b8dc80 --- /dev/null +++ b/gateway-api/src/fhir/r4/elements/identifier.py @@ -0,0 +1,84 @@ +import uuid + +from pydantic import model_validator + +from fhir.elements.identifier import Identifier + + +class UUIDIdentifier(Identifier, expected_system="https://tools.ietf.org/html/rfc4122"): + """A UUID identifier utilising the standard RFC 4122 system.""" + + def __init__(self, value: uuid.UUID | None = None): + super().__init__( + value=str(value or uuid.uuid4()), + system=self._expected_system, + ) + + +class PatientIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" +): + """A FHIR R4 Patient Identifier utilising the NHS Number system.""" + + def __init__(self, value: str): + super().__init__(value=value, system=self._expected_system) + + @classmethod + def from_nhs_number(cls, nhs_number: str) -> "PatientIdentifier": + """Create a PatientIdentifier from an NHS number.""" + return cls(value=nhs_number) + + +class ASIDIdentifier(Identifier, expected_system="https://fhir.nhs.uk/Id/nhsSpineASID"): + """A FHIR R4 ASID Identifier.""" + + +class PartyKeyIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhsMhsPartyKey" +): + """A FHIR R4 Party Key Identifier.""" + + +class AgnosticDeviceIdentifier(Identifier, expected_system="__unknown__"): + """TODO: define system once JWT Device details are understood.""" + + @model_validator(mode="after") + def validate_system(self) -> "AgnosticDeviceIdentifier": + return self + + +class SDSUserIDIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/sds-user-id" +): + """A FHIR R4 User ID Identifier utilising the sds-user-id system.""" + + +class SDSRoleProfileIDIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/sds-role-profile-id" +): + """A FHIR R4 Role Profile ID Identifier utilising the sds-role-profile-id system.""" + + +class AgnosticUserRoleIdentifier(Identifier, expected_system="__unknown__"): + """TODO: define system once JWT Device details are understood.""" + + @model_validator(mode="after") + def validate_system(self) -> "AgnosticUserRoleIdentifier": + return self + + +class OrganizationIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/ods-organization-code" +): + """ + A FHIR R4 Organization Identifier utilising the ODS Organization Code + system. + """ + + def __init__(self, value: str): + super().__init__(value=value, system=self._expected_system) + + @classmethod + def from_ods_code(cls, ods_code: str) -> "OrganizationIdentifier": + """Create an OrganizationIdentifier from an ODS code.""" + return cls(value=ods_code) diff --git a/gateway-api/src/fhir/r4/elements/py.typed b/gateway-api/src/fhir/r4/elements/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/r4/elements/reference.py b/gateway-api/src/fhir/r4/elements/reference.py new file mode 100644 index 00000000..eb294c3a --- /dev/null +++ b/gateway-api/src/fhir/r4/elements/reference.py @@ -0,0 +1,36 @@ +from typing import Annotated, ClassVar + +from pydantic import BaseModel, Field, model_validator + +from fhir.elements.identifier import Identifier + +from .identifier import OrganizationIdentifier + + +class Reference(BaseModel): + """A FHIR R4 Reference base class.""" + + _expected_reference_type: ClassVar[str] = "__unknown__" + + identifier: Identifier + reference_type: str = Field(alias="type") + + reference: str | None = None + display: str | None = None + + def __init_subclass__(cls, reference_type: str) -> None: + cls._expected_reference_type = reference_type + super().__init_subclass__() + + @model_validator(mode="after") + def validate_reference_type(self) -> "Reference": + if self.reference_type != self._expected_reference_type: + raise ValueError( + f"Reference type '{self.reference_type}' does not match expected " + f"type '{self._expected_reference_type}'." + ) + return self + + +class GeneralPractitioner(Reference, reference_type="Organization"): + identifier: Annotated[OrganizationIdentifier, Field(frozen=True)] diff --git a/gateway-api/src/fhir/r4/elements/test_elements.py b/gateway-api/src/fhir/r4/elements/test_elements.py new file mode 100644 index 00000000..fe734e8f --- /dev/null +++ b/gateway-api/src/fhir/r4/elements/test_elements.py @@ -0,0 +1,194 @@ +import uuid + +import pytest +from pydantic import ValidationError + +from fhir.elements.identifier import Identifier +from fhir.r4 import ( + Reference, + UUIDIdentifier, +) + + +class TestUUIDIdentifier: + def test_create_with_value(self) -> None: + expected_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") + identifier = UUIDIdentifier(value=expected_uuid) + + assert identifier.system == "https://tools.ietf.org/html/rfc4122", ( + "system should be the RFC 4122 URI" + ) + assert identifier.value == str(expected_uuid), ( + "value should match the provided UUID string" + ) + + def test_create_without_value(self) -> None: + identifier = UUIDIdentifier() + + assert identifier.system == "https://tools.ietf.org/html/rfc4122", ( + "system should be the RFC 4122 URI" + ) + parsed_uuid = uuid.UUID(identifier.value) + assert parsed_uuid.version == 4, "auto-generated value should be a UUID v4" + + def test_each_call_generates_unique_uuid(self) -> None: + a = UUIDIdentifier() + b = UUIDIdentifier() + + assert a.value != b.value, "two UUIDIdentifiers should have different values" + + def test_expected_system_class_var(self) -> None: + assert ( + UUIDIdentifier._expected_system == "https://tools.ietf.org/html/rfc4122" + ), "_expected_system should be set to RFC 4122 URI" + + +class TestReferenceInitSubclass: + def test_subclass_sets_expected_reference_type(self) -> None: + class _TestId(Identifier, expected_system="sys"): + pass + + class _TestRef(Reference, reference_type="Patient"): + identifier: _TestId + + assert _TestRef._expected_reference_type == "Patient", ( + "_expected_reference_type should be 'Patient'" + ) + + def test_multiple_subclasses_have_independent_reference_types(self) -> None: + class _IdA(Identifier, expected_system="sys-a"): + pass + + class _IdB(Identifier, expected_system="sys-b"): + pass + + class _RefA(Reference, reference_type="Patient"): + identifier: _IdA + + class _RefB(Reference, reference_type="Organization"): + identifier: _IdB + + assert _RefA._expected_reference_type == "Patient", ( + "_RefA should have reference_type 'Patient'" + ) + assert _RefB._expected_reference_type == "Organization", ( + "_RefB should have reference_type 'Organization'" + ) + + def test_subclass_without_reference_type_raises(self) -> None: + with pytest.raises(TypeError): + + class _BadRef(Reference): + pass + + +class TestReferenceModelValidate: + @pytest.fixture + def id_and_ref_classes(self) -> tuple[type[Identifier], type[Reference]]: + class _TestId(Identifier, expected_system="https://example.com/id"): + pass + + class _TestRef(Reference, reference_type="Patient"): + identifier: _TestId + + return _TestId, _TestRef + + def test_valid_reference( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + result = ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + "type": "Patient", + } + ) + + assert result.reference is None, "reference should default to None" + assert result.display is None, "display should default to None" + + def test_valid_reference_with_optional_fields( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + result = ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + "type": "Patient", + "reference": "Patient/12345", + "display": "Jane Doe", + } + ) + + assert result.reference == "Patient/12345", ( + "reference should be 'Patient/12345'" + ) + assert result.display == "Jane Doe", "display should be 'Jane Doe'" + + def test_invalid_reference_type_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + with pytest.raises( + ValidationError, + match="Reference type 'Organization' does not match expected " + "type 'Patient'.", + ): + ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + "type": "Organization", + } + ) + + def test_invalid_identifier_system_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + with pytest.raises( + ValidationError, + match="Identifier system 'wrong-sys' does not match expected " + "system 'https://example.com/id'.", + ): + ref_cls.model_validate( + { + "identifier": {"system": "wrong-sys", "value": "12345"}, + "type": "Patient", + } + ) + + def test_missing_type_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + with pytest.raises(ValidationError): + ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + } + ) + + def test_missing_identifier_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + with pytest.raises(ValidationError): + ref_cls.model_validate({"type": "Patient"}) diff --git a/gateway-api/src/fhir/r4/resources/__init__.py b/gateway-api/src/fhir/r4/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/r4/resources/bundle.py b/gateway-api/src/fhir/r4/resources/bundle.py new file mode 100644 index 00000000..dc7635b2 --- /dev/null +++ b/gateway-api/src/fhir/r4/resources/bundle.py @@ -0,0 +1,38 @@ +from typing import Annotated, Literal + +from pydantic import Field + +from fhir import Resource + +from ..elements.entry import Entry +from ..elements.identifier import UUIDIdentifier + +type BundleType = Literal["document", "transaction", "searchset", "collection"] + + +class Bundle(Resource, resource_type="Bundle"): + """A FHIR R4 Bundle resource.""" + + bundle_type: BundleType = Field(alias="type", frozen=True) + identifier: Annotated[UUIDIdentifier | None, Field(frozen=True)] = None + entries: list[Entry] | None = Field(None, frozen=True, alias="entry") + + def find_resources[T: Resource](self, t: type[T]) -> list[T]: + """ + Find all resources of a given type in the bundle entries. If the bundle has no + entries, an empty list is returned. + Args: + t: The resource type to search for. + Returns: + A list of resources of the specified type. + """ + return [ + entry.resource + for entry in self.entries or [] + if isinstance(entry.resource, t) + ] + + @classmethod + def empty(cls, bundle_type: BundleType) -> "Bundle": + """Create an empty Bundle of the specified type.""" + return cls.create(type=bundle_type, entry=None) diff --git a/gateway-api/src/fhir/r4/resources/device.py b/gateway-api/src/fhir/r4/resources/device.py new file mode 100644 index 00000000..f135ced6 --- /dev/null +++ b/gateway-api/src/fhir/r4/resources/device.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from pydantic import ConfigDict, Field + +from fhir import Resource + +from ..elements.identifier import ( + AgnosticDeviceIdentifier, + ASIDIdentifier, + PartyKeyIdentifier, +) + + +class Device(Resource, resource_type="Device"): + """A FHIR R4 Device resource.""" + + model_config = ConfigDict(extra="allow") + + identifier: Annotated[ + list[ASIDIdentifier | PartyKeyIdentifier | AgnosticDeviceIdentifier], + Field(frozen=True, min_length=1), + ] diff --git a/gateway-api/src/fhir/r4/resources/endpoint.py b/gateway-api/src/fhir/r4/resources/endpoint.py new file mode 100644 index 00000000..8f64c43e --- /dev/null +++ b/gateway-api/src/fhir/r4/resources/endpoint.py @@ -0,0 +1,9 @@ +from pydantic import Field + +from fhir import Resource + + +class Endpoint(Resource, resource_type="Endpoint"): + """A FHIR R4 Endpoint resource.""" + + address: str | None = Field(None, frozen=True) diff --git a/gateway-api/src/fhir/r4/resources/organization.py b/gateway-api/src/fhir/r4/resources/organization.py new file mode 100644 index 00000000..70fb8a6c --- /dev/null +++ b/gateway-api/src/fhir/r4/resources/organization.py @@ -0,0 +1,27 @@ +from typing import Annotated + +from pydantic import Field + +from fhir import Resource + +from ..elements.identifier import OrganizationIdentifier + + +class Organization(Resource, resource_type="Organization"): + """A FHIR R4 Organization resource.""" + + name: str + identifier: Annotated[ + list[OrganizationIdentifier], Field(frozen=True, min_length=1) + ] + + @classmethod + def from_ods_code(cls, ods_code: str, name: str) -> "Organization": + return cls.create( + name=name, + identifier=[ + OrganizationIdentifier( + value=ods_code, + ) + ], + ) diff --git a/gateway-api/src/fhir/r4/resources/patient.py b/gateway-api/src/fhir/r4/resources/patient.py new file mode 100644 index 00000000..b28a7bd3 --- /dev/null +++ b/gateway-api/src/fhir/r4/resources/patient.py @@ -0,0 +1,29 @@ +from typing import Annotated + +from pydantic import Field + +from fhir import Resource + +from ..elements.identifier import PatientIdentifier +from ..elements.reference import GeneralPractitioner + + +class Patient(Resource, resource_type="Patient"): + """A FHIR R4 Patient resource.""" + + identifier: Annotated[list[PatientIdentifier], Field(frozen=True, min_length=1)] + + @property + def nhs_number(self) -> str: + return self.identifier[0].value + + generalPractitioner: Annotated[ + list[GeneralPractitioner] | None, Field(frozen=True) + ] = None + + @property + def gp_ods_code(self) -> str | None: + if not self.generalPractitioner: + return None + + return self.generalPractitioner[0].identifier.value diff --git a/gateway-api/src/fhir/r4/resources/practitioner.py b/gateway-api/src/fhir/r4/resources/practitioner.py new file mode 100644 index 00000000..d528a861 --- /dev/null +++ b/gateway-api/src/fhir/r4/resources/practitioner.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Annotated + +from pydantic import Field + +from fhir import Resource +from fhir.r4.elements.identifier import ( + AgnosticUserRoleIdentifier, + SDSRoleProfileIDIdentifier, + SDSUserIDIdentifier, +) + + +@dataclass(frozen=True) +class HumanName: + family: str + given: list[str] | None = None + prefix: list[str] | None = None + + +class Practitioner(Resource, resource_type="Practitioner"): + """A FHIR R4 Practitioner resource.""" + + name: Annotated[list[HumanName], Field(frozen=True, min_length=1)] + identifier: Annotated[ + list[ + SDSUserIDIdentifier + | SDSRoleProfileIDIdentifier + | AgnosticUserRoleIdentifier + ], + Field(frozen=True, min_length=1), + ] + + id: str diff --git a/gateway-api/src/fhir/r4/resources/py.typed b/gateway-api/src/fhir/r4/resources/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/r4/resources/test_resources.py b/gateway-api/src/fhir/r4/resources/test_resources.py new file mode 100644 index 00000000..6d4b3d35 --- /dev/null +++ b/gateway-api/src/fhir/r4/resources/test_resources.py @@ -0,0 +1,837 @@ +import json + +import pytest +from pydantic import ValidationError + +from fhir import Resource +from fhir.r4 import ( + ASIDIdentifier, + Bundle, + Device, + Endpoint, + Entry, + GeneralPractitioner, + OrganizationIdentifier, + PartyKeyIdentifier, + Patient, + PatientIdentifier, + Practitioner, +) +from fhir.r4.elements.identifier import ( + AgnosticUserRoleIdentifier, + SDSRoleProfileIDIdentifier, + SDSUserIDIdentifier, +) +from fhir.r4.resources.organization import Organization +from fhir.r4.resources.practitioner import HumanName + + +class TestBundle: + def test_create(self) -> None: + """Test creating a Bundle resource.""" + expected_entry = Entry( + fullUrl="full", + resource=Patient.create( + identifier=[PatientIdentifier.from_nhs_number("nhs_number")] + ), + ) + + bundle = Bundle.create( + type="document", + entry=[expected_entry], + ) + + assert bundle.bundle_type == "document" + assert bundle.identifier is None + assert bundle.entries == [expected_entry] + + def test_create_without_entries(self) -> None: + """Test creating a Bundle resource without entries.""" + bundle = Bundle.empty("document") + + assert bundle.bundle_type == "document" + assert bundle.identifier is None + assert bundle.entries is None + + expected_resource = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("nhs_number")] + ) + + @pytest.mark.parametrize( + ("entries", "expected_results"), + [ + pytest.param( + [ + Entry( + fullUrl="fullUrl", + resource=expected_resource, + ), + Entry( + fullUrl="fullUrl", + resource=expected_resource, + ), + ], + [expected_resource, expected_resource], + id="Duplicate resources", + ), + pytest.param( + [ + Entry( + fullUrl="fullUrl", + resource=expected_resource, + ), + ], + [expected_resource], + id="Single resource", + ), + ], + ) + def test_find_resources( + self, entries: list[Entry], expected_results: list[Resource] + ) -> None: + bundle = Bundle.create(type="document", entry=entries) + + result = bundle.find_resources(Patient) + assert result == expected_results + + @pytest.mark.parametrize( + "bundle", + [ + pytest.param(Bundle.empty("document"), id="Bundle has no entries at all"), + pytest.param( + Bundle.create(type="document", entry=[]), + id="Bundle has an empty entries list", + ), + pytest.param( + Bundle.create( + type="document", + entry=[ + Entry( + fullUrl="fullUrl", + resource=Bundle.empty("document"), + ), + ], + ), + id="different_resource_type", + ), + ], + ) + def test_find_resources_returns_empty_list(self, bundle: Bundle) -> None: + """ + Test that find_resources returns an empty list when no matching resources exist. + """ + result = bundle.find_resources(Patient) + assert result == [] + + +class TestPatient: + def test_create(self) -> None: + """Test creating a Patient resource.""" + nhs_number = "1234567890" + + expected_identifier = PatientIdentifier.from_nhs_number(nhs_number) + patient = Patient.create(identifier=[expected_identifier]) + + assert patient.identifier[0] == expected_identifier + + def test_create_with_general_practitioner_identifier(self) -> None: + """Test creating a Patient resource with an ODS-coded practitioner org.""" + nhs_number = "1234567890" + ods_code = "A12345" + + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number(nhs_number)], + generalPractitioner=[ + GeneralPractitioner( + type="Organization", + identifier=OrganizationIdentifier( + value=ods_code, + ), + ) + ], + ) + + assert patient.generalPractitioner is not None + assert patient.generalPractitioner[0].reference_type == "Organization" + assert patient.generalPractitioner[0].identifier is not None + assert ( + patient.generalPractitioner[0].identifier.system + == "https://fhir.nhs.uk/Id/ods-organization-code" + ) + assert patient.generalPractitioner[0].identifier.value == ods_code + + def test_create_with_invalid_patient_identifier_system_raises_error(self) -> None: + """Test invalid patient identifier systems are rejected.""" + with pytest.raises( + ValueError, + match=( + "Identifier system 'https://example.org/invalid' does not match " + "expected system 'https://fhir.nhs.uk/Id/nhs-number'." + ), + ): + Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://example.org/invalid", + "value": "1234567890", + } + ], + } + ) + + def test_model_dump_json_excludes_none_general_practitioner(self) -> None: + """Test JSON output omits optional fields when they are None.""" + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("1234567890")] + ) + + payload = json.loads(patient.model_dump_json()) + + assert payload["resourceType"] == "Patient" + assert "generalPractitioner" not in payload + + +class TestPatientIdentifier: + def test_create_from_nhs_number(self) -> None: + """Test creating a PatientIdentifier from an NHS number.""" + nhs_number = "1234567890" + identifier = PatientIdentifier.from_nhs_number(nhs_number) + + assert identifier.system == "https://fhir.nhs.uk/Id/nhs-number", ( + "system should be the NHS number URI" + ) + assert identifier.value == nhs_number, "value should match the NHS number" + + def test_create_with_constructor(self) -> None: + identifier = PatientIdentifier(value="0000000000") + + assert identifier.system == "https://fhir.nhs.uk/Id/nhs-number", ( + "system should be populated from _expected_system" + ) + assert identifier.value == "0000000000", "value should be '0000000000'" + + def test_expected_system_class_var(self) -> None: + assert PatientIdentifier._expected_system == ( + "https://fhir.nhs.uk/Id/nhs-number" + ), "_expected_system should be the NHS number URI" + + +class TestPatientNhsNumber: + def test_nhs_number_property(self) -> None: + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("9876543210")] + ) + + assert patient.nhs_number == "9876543210", ( + "nhs_number property should return the first identifier value" + ) + + +class TestPatientGpOdsCode: + def test_gp_ods_code_with_practitioner(self) -> None: + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("1234567890")], + generalPractitioner=[ + GeneralPractitioner( + type="Organization", + identifier=OrganizationIdentifier( + value="B81001", + ), + ) + ], + ) + + assert patient.gp_ods_code == "B81001", ( + "gp_ods_code should return the ODS code from the first generalPractitioner" + ) + + def test_gp_ods_code_without_practitioner(self) -> None: + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("1234567890")] + ) + + assert patient.gp_ods_code is None, ( + "gp_ods_code should be None when generalPractitioner is absent" + ) + + def test_gp_ods_code_with_empty_practitioner_list(self) -> None: + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("1234567890")], + generalPractitioner=[], + ) + + assert patient.gp_ods_code is None, ( + "gp_ods_code should be None when generalPractitioner list is empty" + ) + + +class TestPatientModelValidate: + def test_valid_patient(self) -> None: + patient = Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + } + ) + + assert patient.nhs_number == "1234567890", ( + "nhs_number should be parsed from JSON" + ) + + def test_valid_patient_with_general_practitioner(self) -> None: + patient = Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + "generalPractitioner": [ + { + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345", + }, + } + ], + } + ) + + assert patient.gp_ods_code == "A12345", "gp_ods_code should be parsed from JSON" + + def test_missing_identifier_fails(self) -> None: + with pytest.raises(ValidationError, match="identifier"): + Patient.model_validate({"resourceType": "Patient"}) + + def test_empty_identifier_list_fails(self) -> None: + with pytest.raises(ValidationError, match="too_short"): + Patient.model_validate({"resourceType": "Patient", "identifier": []}) + + def test_invalid_gp_reference_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Reference type 'Device' does not match expected type 'Organization'." + ), + ): + Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + "generalPractitioner": [ + { + "type": "Device", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345", + }, + } + ], + } + ) + + +class TestGeneralPractitioner: + def test_expected_reference_type(self) -> None: + assert GeneralPractitioner._expected_reference_type == "Organization", ( + "_expected_reference_type should be 'Organization'" + ) + + def test_organization_identifier_expected_system(self) -> None: + assert ( + OrganizationIdentifier._expected_system + == "https://fhir.nhs.uk/Id/ods-organization-code" + ), "_expected_system should be the ODS organization code URI" + + +class TestDevice: + def test_create_with_asid_identifier(self) -> None: + device = Device.create( + identifier=[ + ASIDIdentifier( + system="https://fhir.nhs.uk/Id/nhsSpineASID", + value="123456789012", + ) + ], + ) + + assert device.resource_type == "Device", "resource_type should be 'Device'" + assert device.identifier[0].value == "123456789012", ( + "identifier value should match" + ) + + def test_create_with_party_key_identifier(self) -> None: + device = Device.create( + identifier=[ + PartyKeyIdentifier( + system="https://fhir.nhs.uk/Id/nhsMhsPartyKey", + value="P12345-000001", + ) + ], + ) + + assert device.identifier[0].system == "https://fhir.nhs.uk/Id/nhsMhsPartyKey", ( + "system should match the party key URI" + ) + + def test_create_with_mixed_identifiers(self) -> None: + device = Device.create( + identifier=[ + ASIDIdentifier( + system="https://fhir.nhs.uk/Id/nhsSpineASID", + value="123", + ), + PartyKeyIdentifier( + system="https://fhir.nhs.uk/Id/nhsMhsPartyKey", + value="PK-1", + ), + ], + ) + + assert len(device.identifier) == 2, "should have two identifiers" + + def test_asid_identifier_expected_system(self) -> None: + assert ASIDIdentifier._expected_system == ( + "https://fhir.nhs.uk/Id/nhsSpineASID" + ), "_expected_system should be the ASID URI" + + def test_party_key_identifier_expected_system(self) -> None: + assert PartyKeyIdentifier._expected_system == ( + "https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ), "_expected_system should be the party key URI" + + +class TestDeviceModelValidate: + def test_valid_device(self) -> None: + device = Device.model_validate( + { + "resourceType": "Device", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "123456789012", + } + ], + } + ) + + assert device.identifier[0].value == "123456789012", ( + "identifier value should be parsed" + ) + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Patient' does not match expected resource type " + "'Device'." + ), + ): + Device.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "123", + } + ], + } + ) + + def test_empty_identifier_list_fails(self) -> None: + with pytest.raises(ValidationError, match="too_short"): + Device.model_validate({"resourceType": "Device", "identifier": []}) + + def test_missing_identifier_fails(self) -> None: + with pytest.raises(ValidationError, match="identifier"): + Device.model_validate({"resourceType": "Device"}) + + @pytest.mark.xfail( + reason=( + "The system for the JWT device is not yet defined. Validation should be " + "added once this is known." + ) + ) + def test_invalid_identifier_system_fails(self) -> None: + with pytest.raises(ValidationError, match="does not match expected system"): + Device.model_validate( + { + "resourceType": "Device", + "identifier": [{"system": "https://bad.system", "value": "123"}], + } + ) + + +class TestEndpoint: + def test_create_with_address(self) -> None: + endpoint = Endpoint.create(address="https://example.com/fhir") + + assert endpoint.resource_type == "Endpoint", ( + "resource_type should be 'Endpoint'" + ) + assert endpoint.address == "https://example.com/fhir", ( + "address should match the provided URL" + ) + + def test_create_without_address(self) -> None: + endpoint = Endpoint.create() + + assert endpoint.address is None, "address should default to None" + + +class TestPractitioner: + def test_create(self) -> None: + practitioner = Practitioner.create( + id="practitioner-1", + name=[HumanName(family="Smith", given=["Alex"], prefix=["Dr"])], + identifier=[ + SDSUserIDIdentifier( + system="https://fhir.nhs.uk/Id/sds-user-id", + value="1234567890", + ), + SDSRoleProfileIDIdentifier( + system="https://fhir.nhs.uk/Id/sds-role-profile-id", + value="R8010:G8000:1001", + ), + AgnosticUserRoleIdentifier(system="https://custom.system", value="UR1"), + ], + ) + + assert practitioner.resource_type == "Practitioner", ( + "resource_type should be 'Practitioner'" + ) + assert practitioner.id == "practitioner-1", "id should be set" + assert practitioner.name[0].family == "Smith", "family name should be set" + assert practitioner.name[0].given == ["Alex"], "given names should be set" + assert practitioner.identifier[0].system == ( + "https://fhir.nhs.uk/Id/sds-user-id" + ), "first identifier should be SDS user ID" + assert practitioner.identifier[1].system == ( + "https://fhir.nhs.uk/Id/sds-role-profile-id" + ), "second identifier should be SDS role profile ID" + assert practitioner.identifier[2].system == "https://custom.system", ( + "agnostic identifier should keep provided system" + ) + + +class TestPractitionerModelValidate: + def test_valid_practitioner(self) -> None: + practitioner = Practitioner.model_validate( + { + "resourceType": "Practitioner", + "id": "practitioner-2", + "name": [{"family": "Jones", "given": ["Sam"], "prefix": ["Mx"]}], + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/sds-user-id", + "value": "1234567890", + }, + { + "system": "https://fhir.nhs.uk/Id/sds-role-profile-id", + "value": "R8010:G8000:1001", + }, + {"system": "https://another.system", "value": "UR-22"}, + ], + } + ) + + assert practitioner.name[0].family == "Jones", "family should be parsed" + assert isinstance(practitioner.identifier[0], SDSUserIDIdentifier), ( + "first identifier should be parsed as SDSUserIDIdentifier" + ) + assert isinstance(practitioner.identifier[1], SDSRoleProfileIDIdentifier), ( + "second identifier should be parsed as SDSRoleProfileIDIdentifier" + ) + assert isinstance(practitioner.identifier[2], AgnosticUserRoleIdentifier), ( + "third identifier should be parsed as AgnosticUserRoleIdentifier" + ) + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Patient' does not match expected resource type " + "'Practitioner'." + ), + ): + Practitioner.model_validate( + { + "resourceType": "Patient", + "id": "practitioner-3", + "name": [{"family": "Jones"}], + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/sds-user-id", + "value": "123", + } + ], + } + ) + + def test_missing_name_fails(self) -> None: + with pytest.raises(ValidationError, match="name"): + Practitioner.model_validate( + { + "resourceType": "Practitioner", + "id": "practitioner-4", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/sds-user-id", + "value": "123", + } + ], + } + ) + + def test_empty_name_list_fails(self) -> None: + with pytest.raises(ValidationError, match="too_short"): + Practitioner.model_validate( + { + "resourceType": "Practitioner", + "id": "practitioner-5", + "name": [], + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/sds-user-id", + "value": "123", + } + ], + } + ) + + def test_missing_identifier_fails(self) -> None: + with pytest.raises(ValidationError, match="identifier"): + Practitioner.model_validate( + { + "resourceType": "Practitioner", + "id": "practitioner-6", + "name": [{"family": "Jones"}], + } + ) + + def test_empty_identifier_list_fails(self) -> None: + with pytest.raises(ValidationError, match="too_short"): + Practitioner.model_validate( + { + "resourceType": "Practitioner", + "id": "practitioner-7", + "name": [{"family": "Jones"}], + "identifier": [], + } + ) + + def test_unknown_identifier_system_is_parsed_as_agnostic_identifier(self) -> None: + practitioner = Practitioner.model_validate( + { + "resourceType": "Practitioner", + "id": "practitioner-8", + "name": [{"family": "Jones"}], + "identifier": [ + { + "system": "https://example.org/invalid", + "value": "123", + } + ], + } + ) + + assert isinstance(practitioner.identifier[0], AgnosticUserRoleIdentifier), ( + "unknown systems should be parsed as AgnosticUserRoleIdentifier" + ) + + +class TestEndpointModelValidate: + def test_valid_endpoint(self) -> None: + endpoint = Endpoint.model_validate( + {"resourceType": "Endpoint", "address": "https://example.com/fhir"} + ) + + assert endpoint.address == "https://example.com/fhir", ( + "address should be parsed from dict" + ) + + def test_valid_endpoint_without_address(self) -> None: + endpoint = Endpoint.model_validate({"resourceType": "Endpoint"}) + + assert endpoint.address is None, "address should default to None" + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Bundle' does not match expected resource type " + "'Endpoint'." + ), + ): + Endpoint.model_validate({"resourceType": "Bundle"}) + + +class TestOrganization: + def test_create(self) -> None: + organization = Organization.create( + name="Leeds Teaching Hospitals NHS Trust", + identifier=[ + OrganizationIdentifier( + value="RR8", + ) + ], + ) + + assert organization.resource_type == "Organization", ( + "resource_type should be 'Organization'" + ) + assert organization.name == "Leeds Teaching Hospitals NHS Trust", ( + "name should match the provided organization name" + ) + assert len(organization.identifier) == 1, "should have one identifier" + assert organization.identifier[0].system == ( + "https://fhir.nhs.uk/Id/ods-organization-code" + ), "identifier system should be the ODS organization code URI" + assert organization.identifier[0].value == "RR8", ( + "identifier value should match the provided ODS code" + ) + + def test_model_validate_with_no_name_raises_error(self) -> None: + with pytest.raises(ValidationError): + Organization.model_validate( + { + "resourceType": "Organization", + "identifier": [ + { + "system": "https://example.org/invalid", + "value": "RR8", + } + ], + } + ) + + def test_model_validate_with_invalid_identifier_system_raises_error(self) -> None: + with pytest.raises(ValidationError, match="does not match expected system"): + Organization.model_validate( + { + "resourceType": "Organization", + "name": "Leeds Teaching Hospitals NHS Trust", + "identifier": [ + { + "system": "https://example.org/invalid", + "value": "RR8", + } + ], + } + ) + + +class TestBundleModelValidate: + def test_valid_bundle(self) -> None: + bundle = Bundle.model_validate( + { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { + "fullUrl": "https://example.com/Patient/1", + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + }, + } + ], + } + ) + + assert bundle.bundle_type == "searchset", ( + "bundle_type should be parsed from JSON" + ) + assert bundle.entries is not None, "entries should not be None" + assert len(bundle.entries) == 1, "should have one entry" + assert isinstance(bundle.entries[0].resource, Patient), ( + "entry resource should be deserialized as Patient" + ) + + def test_valid_bundle_without_entries(self) -> None: + bundle = Bundle.model_validate({"resourceType": "Bundle", "type": "collection"}) + + assert bundle.bundle_type == "collection", "bundle_type should be 'collection'" + assert bundle.entries is None, "entries should default to None" + + def test_missing_type_fails(self) -> None: + with pytest.raises(ValidationError, match="type"): + Bundle.model_validate({"resourceType": "Bundle"}) + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Endpoint' does not match expected resource type " + "'Bundle'." + ), + ): + Bundle.model_validate({"resourceType": "Endpoint", "type": "document"}) + + def test_entry_missing_full_url_fails(self) -> None: + with pytest.raises(ValidationError, match="fullUrl"): + Bundle.model_validate( + { + "resourceType": "Bundle", + "type": "document", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "123", + } + ], + } + } + ], + } + ) + + def test_entry_missing_resource_fails(self) -> None: + with pytest.raises(ValidationError, match="resource"): + Bundle.model_validate( + { + "resourceType": "Bundle", + "type": "document", + "entry": [{"fullUrl": "https://example.com"}], + } + ) + + +class TestBundleEmpty: + @pytest.mark.parametrize( + "bundle_type", + ["document", "transaction", "searchset", "collection"], + ) + def test_empty_bundle_types(self, bundle_type: str) -> None: + bundle = Bundle.empty(bundle_type) # type: ignore[arg-type] + + assert bundle.bundle_type == bundle_type, ( + f"bundle_type should be '{bundle_type}'" + ) + assert bundle.entries is None, "entries should be None for empty bundles" + assert bundle.identifier is None, "identifier should be None for empty bundles" diff --git a/gateway-api/src/fhir/resources/__init__.py b/gateway-api/src/fhir/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/resources/resource.py b/gateway-api/src/fhir/resources/resource.py new file mode 100644 index 00000000..8aa3fc9f --- /dev/null +++ b/gateway-api/src/fhir/resources/resource.py @@ -0,0 +1,112 @@ +import datetime +from dataclasses import dataclass +from typing import Annotated, Any, ClassVar, Self + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidatorFunctionWrapHandler, + field_validator, + model_validator, +) + + +@dataclass(frozen=True) +class Meta: + """ + A FHIR Meta element. See https://hl7.org/fhir/STU3/datatypes.html#Meta. + Attributes: + version_id: The version id of the resource. + last_updated: The last updated timestamp of the resource. + """ + + last_updated: Annotated[datetime.datetime | None, Field(alias="lastUpdated")] = None + version_id: Annotated[str | None, Field(alias="versionId")] = None + + @classmethod + def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Meta": + """ + Create a Meta instance with the provided last_updated timestamp. + Args: + last_updated: The last updated timestamp. + Returns: + A Meta instance with the specified last_updated. + """ + return cls( + last_updated=last_updated or datetime.datetime.now(tz=datetime.timezone.utc) + ) + + +class Resource(BaseModel): + """A FHIR Resource base class.""" + + # class variable to hold class mappings per resource_type + __resource_types: ClassVar[dict[str, type["Resource"]]] = {} + __expected_resource_type: ClassVar[dict[type["Resource"], str]] = {} + + meta: Annotated[Meta | None, Field(alias="meta", frozen=True)] = None + resource_type: str = Field(alias="resourceType", frozen=True) + + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + def __init_subclass__(cls, resource_type: str, **kwargs: Any) -> None: + cls.__resource_types[resource_type] = cls + cls.__expected_resource_type[cls] = resource_type + + super().__init_subclass__(**kwargs) + + def model_dump_json(self, *args: Any, **kwargs: Any) -> str: + # FHIR resources should not return empty fields + kwargs.setdefault("exclude_none", True) + return super().model_dump_json(*args, **kwargs) + + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + # FHIR resources should not return empty fields + kwargs.setdefault("exclude_none", True) + return super().model_dump(*args, **kwargs) + + @model_validator(mode="wrap") + @classmethod + def validate_with_subtype( + cls, value: dict[str, Any], handler: ValidatorFunctionWrapHandler + ) -> Any: + """ + Provides a model validator that instantiates the correct Resource subclass + based on its defined resource_type. + """ + # If we're not currently acting on a top level Resource, and we've not been + # provided a generic dictionary object, delegate to the normal handler. + if cls != Resource or not isinstance(value, dict): + return handler(value) + + if "resourceType" not in value or value["resourceType"] is None: + raise TypeError("resourceType is required for Resource validation.") + + resource_type = value["resourceType"] + + subclass = cls.__resource_types.get(resource_type) + if subclass is None: + raise TypeError(f"Unknown resource type: {resource_type}") + + # Instantiate the subclass using the dictionary values. + return subclass.model_validate(value) + + @classmethod + def create(cls, **kwargs: Any) -> Self: + """ + Create a Resource instance with the correct resourceType. + Note any unknown arguments provided via this method will only error at runtime. + """ + return cls(resourceType=cls.__expected_resource_type[cls], **kwargs) + + @field_validator("resource_type", mode="after") + @classmethod + def _validate_resource_type(cls, value: str) -> str: + expected_resource_type = cls.__expected_resource_type[cls] + if value != expected_resource_type: + raise ValueError( + f"Resource type '{value}' does not match expected " + f"resource type '{expected_resource_type}'." + ) + return value diff --git a/gateway-api/src/fhir/resources/test_resource.py b/gateway-api/src/fhir/resources/test_resource.py new file mode 100644 index 00000000..9243a98e --- /dev/null +++ b/gateway-api/src/fhir/resources/test_resource.py @@ -0,0 +1,217 @@ +import datetime +import json +from typing import Any + +import pytest +from pydantic import BaseModel + +from fhir.r4 import Bundle, Patient, PatientIdentifier +from fhir.resources.resource import Meta, Resource + + +class TestMeta: + def test_create(self) -> None: + meta = Meta( + version_id="1", + last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z"), + ) + assert meta.version_id == "1", "version_id should be set to '1'" + assert meta.last_updated == datetime.datetime.fromisoformat( + "2023-10-01T12:00:00Z" + ), "last_updated should match the provided datetime" + + def test_create_without_last_updated(self) -> None: + meta = Meta(version_id="2") + + assert meta.version_id == "2", "version_id should be set to '2'" + assert meta.last_updated is None, "last_updated should default to None" + + def test_create_without_version(self) -> None: + meta = Meta( + last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") + ) + + assert meta.version_id is None, "version_id should default to None" + assert meta.last_updated == datetime.datetime.fromisoformat( + "2023-10-01T12:00:00Z" + ), "last_updated should match the provided datetime" + + def test_create_with_defaults(self) -> None: + meta = Meta() + + assert meta.version_id is None, "version_id should default to None" + assert meta.last_updated is None, "last_updated should default to None" + + def test_with_last_updated(self) -> None: + last_updated = datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") + meta = Meta.with_last_updated(last_updated) + + assert meta.last_updated == last_updated, ( + "last_updated should match the provided datetime" + ) + assert meta.version_id is None, "version_id should default to None" + + def test_with_last_updated_defaults_to_now(self) -> None: + before_create = datetime.datetime.now(tz=datetime.timezone.utc) + meta = Meta.with_last_updated(None) + after_create = datetime.datetime.now(tz=datetime.timezone.utc) + + assert meta.last_updated is not None, "last_updated should not be None" + assert meta.version_id is None, "version_id should default to None" + + assert before_create <= meta.last_updated, ( + "last_updated should be >= the time before creation" + ) + assert meta.last_updated <= after_create, ( + "last_updated should be <= the time after creation" + ) + + def test_is_frozen(self) -> None: + meta = Meta(version_id="1") + + with pytest.raises(AttributeError): + meta.version_id = "2" # type: ignore[misc] + + +class TestResource: + class _TestContainer(BaseModel): + resource: Resource + + def test_resource_deserialisation(self) -> None: + expected_system = "https://fhir.nhs.uk/Id/nhs-number" + expected_nhs_number = "nhs_number" + example_json = json.dumps( + { + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "system": expected_system, + "value": expected_nhs_number, + } + ], + } + } + ) + + created_object = self._TestContainer.model_validate_json(example_json) + assert isinstance(created_object.resource, Patient) + + created_patient = created_object.resource + assert created_patient.identifier is not None + assert created_patient.identifier[0].system == expected_system + assert created_patient.identifier[0].value == expected_nhs_number + + def test_resource_deserialisation_unknown_resource(self) -> None: + expected_resource_type = "UnknownResourceType" + example_json = json.dumps( + { + "resource": { + "resourceType": expected_resource_type, + } + } + ) + + with pytest.raises( + TypeError, + match=f"Unknown resource type: {expected_resource_type}", + ): + self._TestContainer.model_validate_json(example_json) + + @pytest.mark.parametrize( + "value", + [ + pytest.param({"resource": {}}, id="No resourceType key"), + pytest.param( + {"resource": {"resourceType": None}}, + id="resourceType is defined as None", + ), + ], + ) + def test_resource_deserialisation_without_resource_type( + self, value: dict[str, Any] + ) -> None: + example_json = json.dumps(value) + + with pytest.raises( + TypeError, + match="resourceType is required for Resource validation.", + ): + self._TestContainer.model_validate_json(example_json) + + @pytest.mark.parametrize( + ("json", "expected_error_message"), + [ + pytest.param( + json.dumps({"resourceType": "invalid", "type": "document"}), + "Value error, Resource type 'invalid' does not match expected " + "resource type 'Bundle'.", + id="Invalid resource type", + ), + pytest.param( + json.dumps({"resourceType": None, "type": "document"}), + "1 validation error for Bundle\nresourceType\n " + "Input should be a valid string", + id="Input should be a valid string", + ), + pytest.param( + json.dumps({"type": "document"}), + "1 validation error for Bundle\nresourceType\n Field required", + id="Missing resource type", + ), + ], + ) + def test_deserialise_wrong_resource_type( + self, json: str, expected_error_message: str + ) -> None: + with pytest.raises( + ValueError, + match=expected_error_message, + ): + Bundle.model_validate_json(json, strict=True) + + +class TestResourceInitSubclass: + def test_subclass_without_resource_type_raises(self) -> None: + with pytest.raises(TypeError): + + class _Bad(Resource): + pass + + +class TestResourceCreate: + def test_create_sets_resource_type(self) -> None: + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("1234567890")] + ) + + assert patient.resource_type == "Patient", "resource_type should be 'Patient'" + + def test_create_on_bundle(self) -> None: + bundle = Bundle.create(type="document", entry=None) + + assert bundle.resource_type == "Bundle", "resource_type should be 'Bundle'" + + +class TestResourceModelDump: + def test_model_dump_excludes_none(self) -> None: + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("1234567890")] + ) + dumped = patient.model_dump() + + assert "generalPractitioner" not in dumped, ( + "None fields should be excluded from model_dump" + ) + assert "meta" not in dumped, "None meta should be excluded from model_dump" + + def test_model_dump_json_excludes_none(self) -> None: + patient = Patient.create( + identifier=[PatientIdentifier.from_nhs_number("1234567890")] + ) + payload = json.loads(patient.model_dump_json()) + + assert "generalPractitioner" not in payload, ( + "None fields should be excluded from model_dump_json" + ) + assert "meta" not in payload, "None meta should be excluded" diff --git a/gateway-api/src/fhir/stu3/__init__.py b/gateway-api/src/fhir/stu3/__init__.py new file mode 100644 index 00000000..8981abd8 --- /dev/null +++ b/gateway-api/src/fhir/stu3/__init__.py @@ -0,0 +1,13 @@ +from .elements.identifier import PatientIdentifier +from .elements.issue import Issue, IssueCode, IssueSeverity +from .elements.parameters import Parameters +from .resources.operation_outcome import OperationOutcome + +__all__ = [ + "OperationOutcome", + "Parameters", + "Issue", + "IssueCode", + "IssueSeverity", + "PatientIdentifier", +] diff --git a/gateway-api/src/fhir/stu3/elements/__init__.py b/gateway-api/src/fhir/stu3/elements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/stu3/elements/identifier.py b/gateway-api/src/fhir/stu3/elements/identifier.py new file mode 100644 index 00000000..aad5a00c --- /dev/null +++ b/gateway-api/src/fhir/stu3/elements/identifier.py @@ -0,0 +1,15 @@ +from fhir.elements.identifier import Identifier + + +class PatientIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" +): + """A FHIR STU3 Patient Identifier utilising the NHS Number system.""" + + def __init__(self, value: str): + super().__init__(value=value, system=self._expected_system) + + @classmethod + def from_nhs_number(cls, nhs_number: str) -> "PatientIdentifier": + """Create a PatientIdentifier from an NHS number.""" + return cls(value=nhs_number) diff --git a/gateway-api/src/fhir/stu3/elements/issue.py b/gateway-api/src/fhir/stu3/elements/issue.py new file mode 100644 index 00000000..a24d1ded --- /dev/null +++ b/gateway-api/src/fhir/stu3/elements/issue.py @@ -0,0 +1,26 @@ +from abc import ABC +from dataclasses import dataclass +from enum import StrEnum + + +class IssueSeverity(StrEnum): + FATAL = "fatal" + ERROR = "error" + WARNING = "warning" + INFORMATION = "information" + + +class IssueCode(StrEnum): + INVALID = "invalid" + EXCEPTION = "exception" + + +@dataclass(frozen=True) +class Issue(ABC): + """ + A FHIR STU3 OperationOutcome Issue element. See https://hl7.org/fhir/STU3/datatypes.html#OperationOutcome. + """ + + severity: IssueSeverity + code: IssueCode + diagnostics: str | None = None diff --git a/gateway-api/src/fhir/stu3/elements/parameters.py b/gateway-api/src/fhir/stu3/elements/parameters.py new file mode 100644 index 00000000..f04a72e3 --- /dev/null +++ b/gateway-api/src/fhir/stu3/elements/parameters.py @@ -0,0 +1,20 @@ +from abc import ABC +from dataclasses import dataclass +from typing import Annotated + +from pydantic import Field + +from fhir import Resource +from fhir.stu3 import PatientIdentifier + + +class Parameters(Resource, resource_type="Parameters"): + """A FHIR STU3 Parameters resource.""" + + @dataclass(frozen=True) + class Parameter(ABC): + """A FHIR STU3 Parameter resource.""" + + valueIdentifier: Annotated[PatientIdentifier, Field(frozen=True)] + + parameter: Annotated[list[Parameter], Field(frozen=True, min_length=1)] diff --git a/gateway-api/src/fhir/stu3/elements/test_elements.py b/gateway-api/src/fhir/stu3/elements/test_elements.py new file mode 100644 index 00000000..fcb25dfd --- /dev/null +++ b/gateway-api/src/fhir/stu3/elements/test_elements.py @@ -0,0 +1,226 @@ +import pytest +from pydantic import ValidationError + +from fhir.stu3 import Issue, IssueCode, IssueSeverity, Parameters, PatientIdentifier + + +class TestParameters: + def test_create(self) -> None: + """Test creating a Parameters resource.""" + parameter = Parameters.Parameter( + valueIdentifier=PatientIdentifier( + value="9000000009", + ), + ) + + params = Parameters.create(parameter=[parameter]) + + assert params.resource_type == "Parameters", ( + "resourceType should be 'Parameters'" + ) + assert len(params.parameter) == 1, "parameter list should contain one entry" + assert params.parameter[0] == parameter, ( + "first parameter should match the provided Parameter" + ) + + def test_create_with_multiple_parameters(self) -> None: + """Test creating a Parameters resource with multiple parameters.""" + param_a = Parameters.Parameter( + valueIdentifier=PatientIdentifier( + value="9000000009", + ), + ) + param_b = Parameters.Parameter( + valueIdentifier=PatientIdentifier( + value="9000000017", + ), + ) + + params = Parameters.create(parameter=[param_a, param_b]) + + assert len(params.parameter) == 2, "parameter list should contain two entries" + assert params.parameter[0].valueIdentifier.value == "9000000009", ( + "first parameter NHS number should be '9000000009'" + ) + assert params.parameter[1].valueIdentifier.value == "9000000017", ( + "second parameter NHS number should be '9000000017'" + ) + + def test_model_validate_valid(self) -> None: + """Test model_validate with valid Parameters JSON.""" + params = Parameters.model_validate( + { + "resourceType": "Parameters", + "parameter": [ + { + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + }, + } + ], + } + ) + + assert params.resource_type == "Parameters", ( + "resourceType should be 'Parameters'" + ) + assert len(params.parameter) == 1, "parameter list should contain one entry" + assert params.parameter[0].valueIdentifier.value == "9000000009", ( + "valueIdentifier value should be '9000000009'" + ) + assert params.parameter[0].valueIdentifier.system == ( + "https://fhir.nhs.uk/Id/nhs-number" + ), "valueIdentifier system should be the NHS number URI" + + def test_model_validate_with_wrong_resource_type_raises_error(self) -> None: + """Test that an incorrect resourceType is rejected.""" + with pytest.raises( + ValidationError, + match=( + "Resource type 'Patient' does not match expected resource type " + "'Parameters'." + ), + ): + Parameters.model_validate( + { + "resourceType": "Patient", + "parameter": [ + { + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + }, + } + ], + } + ) + + def test_model_validate_with_invalid_identifier_system_raises_error(self) -> None: + """Test that an invalid identifier system is rejected.""" + with pytest.raises( + ValidationError, + match=( + "Identifier system 'https://example.org/invalid' does not match " + "expected system 'https://fhir.nhs.uk/Id/nhs-number'." + ), + ): + Parameters.model_validate( + { + "resourceType": "Parameters", + "parameter": [ + { + "valueIdentifier": { + "system": "https://example.org/invalid", + "value": "9000000009", + }, + } + ], + } + ) + + def test_model_validate_missing_parameter_raises_error(self) -> None: + """Test that missing parameter field is rejected.""" + with pytest.raises(ValidationError): + Parameters.model_validate( + { + "resourceType": "Parameters", + } + ) + + def test_model_validate_empty_parameter_list(self) -> None: + """Test creating Parameters with an empty parameter list.""" + with pytest.raises(ValidationError): + Parameters.model_validate( + { + "resourceType": "Parameters", + "parameter": [], + } + ) + + def test_model_dump_json_roundtrip(self) -> None: + """Test JSON serialization roundtrip preserves data.""" + params = Parameters.create( + parameter=[ + Parameters.Parameter( + valueIdentifier=PatientIdentifier( + value="9000000009", + ), + ) + ], + ) + + json_str = params.model_dump_json() + + assert '"resourceType":"Parameters"' in json_str.replace(" ", ""), ( + "JSON output should contain the resourceType" + ) + assert "9000000009" in json_str, ( + "JSON output should contain the NHS number value" + ) + + def test_is_frozen(self) -> None: + """Test that Parameters fields are frozen (immutable).""" + params = Parameters.create( + parameter=[ + Parameters.Parameter( + valueIdentifier=PatientIdentifier( + value="9000000009", + ), + ) + ], + ) + + with pytest.raises((ValidationError, AttributeError)): + params.parameter = [] + + +class TestParameter: + def test_create(self) -> None: + """Test creating a Parameter element.""" + identifier = PatientIdentifier( + value="9000000009", + ) + parameter = Parameters.Parameter(valueIdentifier=identifier) + + assert parameter.valueIdentifier == identifier, ( + "valueIdentifier should match the provided identifier" + ) + assert parameter.valueIdentifier.value == "9000000009", ( + "valueIdentifier value should be '9000000009'" + ) + assert parameter.valueIdentifier.system == ( + "https://fhir.nhs.uk/Id/nhs-number" + ), "valueIdentifier system should be the NHS number URI" + + def test_is_frozen(self) -> None: + """Test that Parameter fields are frozen (immutable).""" + parameter = Parameters.Parameter( + valueIdentifier=PatientIdentifier( + value="9000000009", + ), + ) + + with pytest.raises(AttributeError): + parameter.valueIdentifier = PatientIdentifier( # type: ignore[misc] + value="0000000000", + ) + + +class TestIssue: + def test_diagnostics_defaults_to_none(self) -> None: + class _ConcreteIssue(Issue): + pass + + issue = _ConcreteIssue(severity=IssueSeverity.WARNING, code=IssueCode.EXCEPTION) + + assert issue.diagnostics is None, "diagnostics should default to None" + + def test_is_frozen(self) -> None: + class _ConcreteIssue(Issue): + pass + + issue = _ConcreteIssue(severity=IssueSeverity.FATAL, code=IssueCode.EXCEPTION) + + with pytest.raises(AttributeError): + issue.severity = IssueSeverity.WARNING # type: ignore[misc] diff --git a/gateway-api/src/fhir/stu3/resources/__init__.py b/gateway-api/src/fhir/stu3/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/stu3/resources/operation_outcome.py b/gateway-api/src/fhir/stu3/resources/operation_outcome.py new file mode 100644 index 00000000..c1ab9ad0 --- /dev/null +++ b/gateway-api/src/fhir/stu3/resources/operation_outcome.py @@ -0,0 +1,12 @@ +from typing import Annotated + +from pydantic import Field + +from fhir import Resource +from fhir.stu3.elements.issue import Issue + + +class OperationOutcome(Resource, resource_type="OperationOutcome"): + """A FHIR STU3 OperationOutcome resource.""" + + issue: Annotated[list[Issue], Field(frozen=True)] diff --git a/gateway-api/src/fhir/stu3/resources/test_resources.py b/gateway-api/src/fhir/stu3/resources/test_resources.py new file mode 100644 index 00000000..1d746e19 --- /dev/null +++ b/gateway-api/src/fhir/stu3/resources/test_resources.py @@ -0,0 +1,77 @@ +import pytest +from pydantic import ValidationError + +from fhir.stu3 import Issue, IssueCode, IssueSeverity, OperationOutcome + + +class TestOperationOutcome: + def test_create(self) -> None: + class _TestIssue(Issue): + pass + + outcome = OperationOutcome.create( + issue=[ + _TestIssue( + severity=IssueSeverity.ERROR, + code=IssueCode.INVALID, + diagnostics="Something failed", + ) + ], + ) + + assert outcome.resource_type == "OperationOutcome", ( + "resource_type should be 'OperationOutcome'" + ) + assert len(outcome.issue) == 1, "should have one issue" + assert outcome.issue[0].severity == IssueSeverity.ERROR, ( + "issue severity should be ERROR" + ) + assert outcome.issue[0].code == IssueCode.INVALID, ( + "issue code should be INVALID" + ) + assert outcome.issue[0].diagnostics == "Something failed", ( + "diagnostics should match" + ) + + +class TestOperationOutcomeModelValidate: + def test_valid_operation_outcome(self) -> None: + outcome = OperationOutcome.model_validate( + { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Bad request", + } + ], + } + ) + + assert outcome.issue[0].severity == IssueSeverity.ERROR, ( + "severity should be parsed" + ) + assert outcome.issue[0].code == IssueCode.INVALID, "code should be parsed" + assert outcome.issue[0].diagnostics == "Bad request", ( + "diagnostics should be parsed" + ) + + def test_missing_issue_fails(self) -> None: + with pytest.raises(ValidationError, match="issue"): + OperationOutcome.model_validate({"resourceType": "OperationOutcome"}) + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Patient' does not match expected resource type " + "'OperationOutcome'." + ), + ): + OperationOutcome.model_validate( + { + "resourceType": "Patient", + "issue": [{"severity": "error", "code": "invalid"}], + } + ) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 3b7cd5c5..1ee168b6 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -8,6 +8,7 @@ from gateway_api.controller import Controller from gateway_api.get_structured_record import ( GetStructuredRecordRequest, + GetStructuredRecordResponse, ) app = Flask(__name__) @@ -31,20 +32,22 @@ def get_app_port() -> int: @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Response: + response = GetStructuredRecordResponse() + response.mirror_headers(request) try: get_structured_record_request = GetStructuredRecordRequest(request) controller = Controller() - flask_response = controller.run(request=get_structured_record_request) - get_structured_record_request.set_response_from_flaskresponse(flask_response) + provider_response = controller.run(request=get_structured_record_request) + response.add_provider_response(provider_response) except AbstractCDGError as e: e.log() - return e.build_response() + response.add_error_response(e) except Exception: error = UnexpectedError(traceback=traceback.format_exc()) error.log() - return error.build_response() + response.add_error_response(error) - return get_structured_record_request.build_response() + return response.build() @app.route("/health", methods=["GET"]) diff --git a/gateway-api/src/gateway_api/clinical_jwt/__init__.py b/gateway-api/src/gateway_api/clinical_jwt/__init__.py index 38d0dafb..9fcefa84 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/__init__.py +++ b/gateway-api/src/gateway_api/clinical_jwt/__init__.py @@ -1,7 +1,4 @@ -from .device import Device from .jwt import JWT -from .organization import Organization -from .practitioner import Practitioner from .validator import JWTValidator -__all__ = ["JWT", "Device", "Organization", "Practitioner", "JWTValidator"] +__all__ = ["JWT", "JWTValidator"] diff --git a/gateway-api/src/gateway_api/clinical_jwt/device.py b/gateway-api/src/gateway_api/clinical_jwt/device.py deleted file mode 100644 index 3d829887..00000000 --- a/gateway-api/src/gateway_api/clinical_jwt/device.py +++ /dev/null @@ -1,21 +0,0 @@ -from dataclasses import dataclass -from typing import Any - - -@dataclass(frozen=True, kw_only=True) -class Device: - system: str - value: str - model: str - version: str - - def to_dict(self) -> dict[str, Any]: - """ - Return the Device as a dictionary suitable for JWT payload. - """ - return { - "resourceType": "Device", - "identifier": [{"system": self.system, "value": self.value}], - "model": self.model, - "version": self.version, - } diff --git a/gateway-api/src/gateway_api/clinical_jwt/organization.py b/gateway-api/src/gateway_api/clinical_jwt/organization.py deleted file mode 100644 index 8beb367f..00000000 --- a/gateway-api/src/gateway_api/clinical_jwt/organization.py +++ /dev/null @@ -1,25 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from fhir.constants import FHIRSystem - - -@dataclass(frozen=True, kw_only=True) -class Organization: - ods_code: str - name: str - - def to_dict(self) -> dict[str, Any]: - """ - Return the Organization as a dictionary suitable for JWT payload. - """ - return { - "resourceType": "Organization", - "identifier": [ - { - "system": FHIRSystem.ODS_CODE, - "value": self.ods_code, - } - ], - "name": self.name, - } diff --git a/gateway-api/src/gateway_api/clinical_jwt/practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py deleted file mode 100644 index 0041dd59..00000000 --- a/gateway-api/src/gateway_api/clinical_jwt/practitioner.py +++ /dev/null @@ -1,43 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from fhir.constants import FHIRSystem - - -@dataclass(frozen=True, kw_only=True) -class Practitioner: - id: str - sds_userid: str - role_profile_id: str - userid_url: str - userid_value: str - family_name: str - given_name: str | None = None - prefix: str | None = None - - def _build_name(self) -> list[dict[str, Any]]: - """Build the name array with proper structure for JWT.""" - name_dict: dict[str, Any] = {"family": self.family_name} - if self.given_name is not None: - name_dict["given"] = [self.given_name] - if self.prefix is not None: - name_dict["prefix"] = [self.prefix] - return [name_dict] - - def to_dict(self) -> dict[str, Any]: - """ - Return the Practitioner as a dictionary suitable for JWT payload. - """ - user_id_system = FHIRSystem.SDS_USER_ID - role_id_system = FHIRSystem.SDS_ROLE_PROFILE_ID - - return { - "resourceType": "Practitioner", - "id": self.id, - "identifier": [ - {"system": user_id_system, "value": self.sds_userid}, - {"system": role_id_system, "value": self.role_profile_id}, - {"system": self.userid_url, "value": self.userid_value}, - ], - "name": self._build_name(), - } diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_device.py b/gateway-api/src/gateway_api/clinical_jwt/test_device.py deleted file mode 100644 index c936f553..00000000 --- a/gateway-api/src/gateway_api/clinical_jwt/test_device.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Unit tests for :mod:`gateway_api.clinical_jwt.device`. -""" - -from gateway_api.clinical_jwt import Device - - -def test_device_creation_with_all_required_fields() -> None: - """ - Test that a Device instance can be created with all required fields. - """ - device = Device( - system="https://consumersupplier.com/Id/device-identifier", - value="CONS-APP-4", - model="Consumer product name", - version="5.3.0", - ) - - assert device.system == "https://consumersupplier.com/Id/device-identifier" - assert device.value == "CONS-APP-4" - assert device.model == "Consumer product name" - assert device.version == "5.3.0" - - -def test_device_json_property_returns_valid_json_structure() -> None: - """ - Test that the json property returns a valid JSON structure for requesting_device. - """ - input_device = Device( - system="https://consumersupplier.com/Id/device-identifier", - value="CONS-APP-4", - model="Consumer product name", - version="5.3.0", - ) - - jdict = input_device.to_dict() - - output_device = Device( - system=jdict["identifier"][0]["system"], - value=jdict["identifier"][0]["value"], - model=jdict["model"], - version=jdict["version"], - ) - - assert input_device == output_device diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py deleted file mode 100644 index 57186c5b..00000000 --- a/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Unit tests for :mod:`gateway_api.clinical_jwt.practitioner`. -""" - -from gateway_api.clinical_jwt import Practitioner - - -def test_practitioner_creation_with_all_fields() -> None: - """ - Test that a Practitioner instance can be created with all fields. - """ - practitioner = Practitioner( - id="10019", - sds_userid="111222333444", - role_profile_id="444555666777", - userid_url="https://consumersupplier.com/Id/user-guid", - userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", - family_name="Doe", - given_name="John", - prefix="Mr", - ) - - assert practitioner.id == "10019" - assert practitioner.sds_userid == "111222333444" - assert practitioner.role_profile_id == "444555666777" - assert practitioner.userid_url == "https://consumersupplier.com/Id/user-guid" - assert practitioner.userid_value == "98ed4f78-814d-4266-8d5b-cde742f3093c" - assert practitioner.family_name == "Doe" - assert practitioner.given_name == "John" - assert practitioner.prefix == "Mr" - - -def test_practitioner_json_property_returns_valid_structure() -> None: - """ - Test that the json property returns a valid JSON structure for - requesting_practitioner. - """ - input_practitioner = Practitioner( - id="10019", - sds_userid="111222333444", - role_profile_id="444555666777", - userid_url="https://consumersupplier.com/Id/user-guid", - userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", - family_name="Doe", - given_name="John", - prefix="Mr", - ) - - jdict = input_practitioner.to_dict() - - output_practitioner = Practitioner( - id=jdict["id"], - sds_userid=jdict["identifier"][0]["value"], - role_profile_id=jdict["identifier"][1]["value"], - userid_url=jdict["identifier"][2]["system"], - userid_value=jdict["identifier"][2]["value"], - family_name=jdict["name"][0]["family"], - given_name=jdict["name"][0].get("given", [None])[0], - prefix=jdict["name"][0].get("prefix", [None])[0], - ) - - assert input_practitioner == output_practitioner diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 79f2a561..3634d6a0 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -1,19 +1,8 @@ -import json import traceback from dataclasses import dataclass -from enum import StrEnum from http.client import BAD_GATEWAY, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND -from typing import TYPE_CHECKING -from flask import Response - -if TYPE_CHECKING: - from fhir.operation_outcome import OperationOutcome - - -class ErrorCode(StrEnum): - INVALID = "invalid" - EXCEPTION = "exception" +from fhir.stu3 import Issue, IssueCode, IssueSeverity, OperationOutcome @dataclass @@ -24,8 +13,8 @@ class AbstractCDGError(Exception): _message: str status_code: int - error_code: ErrorCode - severity: str = "error" + error_code: IssueCode + severity: IssueSeverity = IssueSeverity.ERROR def __init__(self, **additional_details: str): """ @@ -35,23 +24,18 @@ def __init__(self, **additional_details: str): self.additional_details = additional_details super().__init__(self) - def build_response(self) -> Response: - operation_outcome: OperationOutcome = { - "resourceType": "OperationOutcome", - "issue": [ - { - "severity": self.severity, - "code": self.error_code, - "diagnostics": self.message, - } - ], - } - response = Response( - response=json.dumps(operation_outcome), - status=self.status_code, - content_type="application/fhir+json", + @property + def operation_outcome(self) -> OperationOutcome: + operation_outcome = OperationOutcome.create( + issue=[ + Issue( + severity=self.severity, + code=self.error_code, + diagnostics=self.message, + ) + ] ) - return response + return operation_outcome def log(self) -> None: print(traceback.format_exc(), flush=True) @@ -66,26 +50,26 @@ def __str__(self) -> str: class InvalidRequestJSONError(AbstractCDGError): _message = "Invalid JSON body sent in request" - error_code = ErrorCode.INVALID + error_code = IssueCode.INVALID status_code = BAD_REQUEST class MissingOrEmptyHeaderError(AbstractCDGError): _message = 'Missing or empty required header "{header}"' status_code = BAD_REQUEST - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoCurrentProviderError(AbstractCDGError): _message = "PDS patient {nhs_number} did not contain a current provider ODS code" status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoOrganisationFoundError(AbstractCDGError): _message = "No SDS org found for {org_type} ODS code {ods_code}" status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoAsidFoundError(AbstractCDGError): @@ -93,7 +77,7 @@ class NoAsidFoundError(AbstractCDGError): "SDS result for {org_type} ODS code {ods_code} did not contain a current ASID" ) status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoCurrentEndpointError(AbstractCDGError): @@ -102,29 +86,29 @@ class NoCurrentEndpointError(AbstractCDGError): "a current endpoint" ) status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class PdsRequestFailedError(AbstractCDGError): _message = "PDS FHIR API request failed: {error_reason}" status_code = BAD_GATEWAY - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class ProviderRequestFailedError(AbstractCDGError): _message = "Provider request failed: {error_reason}" status_code = BAD_GATEWAY - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class JWTValidationError(AbstractCDGError): _message = "{error_details}" status_code = BAD_REQUEST - error_code = ErrorCode.INVALID + error_code = IssueCode.INVALID class UnexpectedError(AbstractCDGError): _message = "Internal Server Error: {traceback}" status_code = INTERNAL_SERVER_ERROR - severity = "error" - error_code = ErrorCode.EXCEPTION + severity = IssueSeverity.ERROR + error_code = IssueCode.EXCEPTION diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 656785cc..ed7844f0 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -6,9 +6,7 @@ import pytest import requests -from fhir import Bundle, OperationOutcome, Patient from fhir.constants import FHIRSystem -from fhir.parameters import Parameters from flask import Request from requests.structures import CaseInsensitiveDict from werkzeug.test import EnvironBuilder @@ -24,10 +22,12 @@ class FakeResponse: status_code: int headers: dict[str, str] | CaseInsensitiveDict[str] - _json: dict[str, Any] | Patient | OperationOutcome | Bundle + _json: dict[str, Any] reason: str = "" - def json(self) -> dict[str, Any] | Patient | OperationOutcome | Bundle: + def json( + self, + ) -> dict[str, Any]: return self._json def raise_for_status(self) -> None: @@ -42,7 +42,7 @@ def text(self) -> str: return json.dumps(self._json) -def create_mock_request(headers: dict[str, str], body: Parameters) -> Request: +def create_mock_request(headers: dict[str, str], body: dict[str, Any]) -> Request: """Create a proper Flask Request object with headers and JSON body.""" builder = EnvironBuilder( method="POST", @@ -56,7 +56,7 @@ def create_mock_request(headers: dict[str, str], body: Parameters) -> Request: @pytest.fixture -def valid_simple_request_payload() -> Parameters: +def valid_simple_request_payload() -> dict[str, Any]: return { "resourceType": "Parameters", "parameter": [ @@ -72,7 +72,7 @@ def valid_simple_request_payload() -> Parameters: @pytest.fixture -def valid_simple_response_payload() -> Bundle: +def valid_simple_response_payload() -> dict[str, Any]: return { "resourceType": "Bundle", "id": "example-patient-bundle", @@ -95,7 +95,10 @@ def valid_simple_response_payload() -> Bundle: "resourceType": "Patient", "id": "9999999999", "identifier": [ - {"value": "9999999999", "system": "urn:nhs:numbers"} + { + "value": "9999999999", + "system": "https://fhir.nhs.uk/Id/nhs-number", + } ], "generalPractitioner": [ { @@ -124,11 +127,13 @@ def valid_headers() -> dict[str, str]: @pytest.fixture -def happy_path_pds_response_body() -> Patient: +def happy_path_pds_response_body() -> dict[str, Any]: return { "resourceType": "Patient", "id": "9999999999", - "identifier": [{"value": "9999999999", "system": "urn:nhs:numbers"}], + "identifier": [ + {"value": "9999999999", "system": "https://fhir.nhs.uk/Id/nhs-number"} + ], "name": [ { "family": "Johnson", diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 475c6f2e..0517ff3c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,8 +2,10 @@ Controller layer for orchestrating calls to external services """ -from gateway_api.clinical_jwt import JWT, Device, Organization, Practitioner -from gateway_api.common.common import FlaskResponse +from fhir.r4 import Device, Organization, Practitioner +from requests import Response + +from gateway_api.clinical_jwt import JWT from gateway_api.common.error import ( NoAsidFoundError, NoCurrentEndpointError, @@ -11,7 +13,7 @@ NoOrganisationFoundError, ) from gateway_api.get_structured_record.request import GetStructuredRecordRequest -from gateway_api.pds import PdsClient, PdsSearchResults +from gateway_api.pds import PdsClient from gateway_api.provider import GpProviderClient from gateway_api.sds import SdsClient, SdsSearchResults @@ -37,7 +39,7 @@ def __init__( self.timeout = timeout self.gp_provider_client = None - def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: + def run(self, request: GetStructuredRecordRequest) -> Response: """ Controller entry point @@ -68,16 +70,12 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: token=token, ) - response = self.gp_provider_client.access_structured_record( + provider_response = self.gp_provider_client.access_structured_record( trace_id=request.trace_id, body=request.request_body, ) - return FlaskResponse( - status_code=response.status_code, - data=response.text, - headers=dict(response.headers), - ) + return provider_response def get_auth_token(self) -> str: """ @@ -102,28 +100,52 @@ def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT # version="5.3.0", # ) - requesting_device = Device( - system="https://orange.testlab.nhs.uk/gpconnect-demonstrator/Id/local-system-instance-id", - value="gpcdemonstrator-1-orange", - model="GP Connect Demonstrator", - version="1.5.0", + requesting_device = Device.model_validate( + { + "resourceType": "Device", + "identifier": [ + { + "system": "https://orange.testlab.nhs.uk/gpconnect-demonstrator/Id/local-system-instance-id", + "value": "gpcdemonstrator-1-orange", + } + ], + "model": "GP Connect Demonstrator", + "version": "1.5.0", + } ) # TODO: Get practitioner details from consumer pending outcome of GPCAPIM-286? - requesting_practitioner = Practitioner( - id="10019", - sds_userid="111222333444", - role_profile_id="444555666777", - userid_url="https://orange.testlab.nhs.uk/gpconnect-demonstrator/Id/local-user-id", - userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", - family_name="Doe", - given_name="John", - prefix="Mr", + requesting_practitioner = Practitioner.model_validate( + { + "resourceType": "Practitioner", + "id": "10019", + "name": [ + { + "family": "Doe", + "given": ["John"], + "prefix": ["Mr"], + } + ], + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/sds-user-id", + "value": "111222333444", + }, + { + "system": "https://fhir.nhs.uk/Id/sds-role-profile-id", + "value": "444555666777", + }, + { + "system": "https://orange.testlab.nhs.uk/gpconnect-demonstrator/Id/local-user-id", + "value": "98ed4f78-814d-4266-8d5b-cde742f3093c", + }, + ], + } ) # TODO: Where do we get the consumer org name from? SDS only returns ODS/ASID - requesting_organization = Organization( - ods_code=consumer_ods, name="Consumer organisation name" + requesting_organization = Organization.from_ods_code( + name="Consumer organisation name", ods_code=consumer_ods ) # TODO: Get consumer URL for issuer. Use CDG API URL for now. @@ -134,9 +156,9 @@ def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT issuer=issuer, subject=requesting_practitioner.id, audience=audience, - requesting_device=requesting_device.to_dict(), - requesting_organization=requesting_organization.to_dict(), - requesting_practitioner=requesting_practitioner.to_dict(), + requesting_device=requesting_device.model_dump(), + requesting_organization=requesting_organization.model_dump(), + requesting_practitioner=requesting_practitioner.model_dump(), ) return token @@ -152,12 +174,12 @@ def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: ignore_dates=True, ) - pds_result: PdsSearchResults = pds.search_patient_by_nhs_number(nhs_number) + patient = pds.search_patient_by_nhs_number(nhs_number) - if not pds_result.gp_ods_code: + if not patient.gp_ods_code: raise NoCurrentProviderError(nhs_number=nhs_number) - return pds_result.gp_ods_code + return patient.gp_ods_code def _get_sds_details( self, consumer_ods: str, provider_ods: str 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 e665e366..2f59f851 100644 --- a/gateway-api/src/gateway_api/get_structured_record/__init__.py +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -4,8 +4,10 @@ ACCESS_RECORD_STRUCTURED_INTERACTION_ID, GetStructuredRecordRequest, ) +from gateway_api.get_structured_record.response import GetStructuredRecordResponse __all__ = [ "GetStructuredRecordRequest", + "GetStructuredRecordResponse", "ACCESS_RECORD_STRUCTURED_INTERACTION_ID", ] 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 7e723e7c..d47d1a9e 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -1,13 +1,16 @@ -import json -from typing import TYPE_CHECKING, ClassVar +from collections.abc import Mapping +from typing import ClassVar -from fhir import OperationOutcome, Parameters -from fhir.operation_outcome import OperationOutcomeIssue -from flask.wrappers import Request, Response +from fhir.stu3 import Parameters +from flask.wrappers import Request +from pydantic import ValidationError +from requests.structures import CaseInsensitiveDict from werkzeug.exceptions import BadRequest -from gateway_api.common.common import FlaskResponse -from gateway_api.common.error import InvalidRequestJSONError, MissingOrEmptyHeaderError +from gateway_api.common.error import ( + InvalidRequestJSONError, + MissingOrEmptyHeaderError, +) # Access record structured interaction ID from # https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions @@ -15,9 +18,6 @@ "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ) -if TYPE_CHECKING: - from fhir.bundle import Bundle - class GetStructuredRecordRequest: INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -26,13 +26,12 @@ class GetStructuredRecordRequest: def __init__(self, request: Request) -> None: self._http_request = request - self._headers = request.headers + self._headers = CaseInsensitiveDict(request.headers) try: - self._request_body: Parameters = request.get_json() - except BadRequest as error: + self.parameters = Parameters.model_validate(request.get_json()) + except (BadRequest, ValidationError) as error: raise InvalidRequestJSONError() from error - self._response_body: Bundle | OperationOutcome | None = None self._status_code: int | None = None self._validate_headers() @@ -44,7 +43,7 @@ def trace_id(self) -> str: @property def nhs_number(self) -> str: - nhs_number: str = self._request_body["parameter"][0]["valueIdentifier"]["value"] + nhs_number = self.parameters.parameter[0].valueIdentifier.value return nhs_number @property @@ -54,7 +53,11 @@ def ods_from(self) -> str: @property def request_body(self) -> str: - return json.dumps(self._request_body) + return self.parameters.model_dump_json() + + @property + def headers(self) -> Mapping[str, str]: + return self._headers def _validate_headers(self) -> None: trace_id = self._headers.get("Ssp-TraceID", "").strip() @@ -64,40 +67,3 @@ def _validate_headers(self) -> None: ods_from = self._headers.get("ODS-from", "").strip() if not ods_from: raise MissingOrEmptyHeaderError(header="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_negative_response(self, error: str, status_code: int = 500) -> None: - self._status_code = status_code - self._response_body = OperationOutcome( - resourceType="OperationOutcome", - issue=[ - OperationOutcomeIssue( - severity="error", - code="exception", - diagnostics=error, - ) - ], - ) - - 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/response.py b/gateway-api/src/gateway_api/get_structured_record/response.py new file mode 100644 index 00000000..c1d57325 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/response.py @@ -0,0 +1,46 @@ +import json +from collections.abc import Mapping +from typing import ClassVar + +from flask import Request, Response +from requests import Response as HTTPResponse +from requests.structures import CaseInsensitiveDict + +from gateway_api.common.error import AbstractCDGError + + +class GetStructuredRecordResponse: + MIME_TYPE: ClassVar[str] = "application/fhir+json" + + def __init__(self) -> None: + self._response_body: str | None = None + self._headers: Mapping[str, str] | None = None + self._status_code: int | None = None + + def mirror_headers(self, request: Request) -> None: + headers_to_mirror = [ + "ssp-traceid", + ] + self._headers = CaseInsensitiveDict( + {k: v for k, v in request.headers.items() if k.lower() in headers_to_mirror} + ) + + @property + def headers(self) -> Mapping[str, str] | None: + return self._headers + + def add_provider_response(self, provider_response: HTTPResponse) -> None: + self._response_body = json.dumps(provider_response.json()) + self._status_code = provider_response.status_code + + def add_error_response(self, error: AbstractCDGError) -> None: + self._response_body = error.operation_outcome.model_dump_json() + self._status_code = error.status_code + + def build(self) -> Response: + return Response( + response=self._response_body, + status=self._status_code, + mimetype=self.MIME_TYPE, + headers=self.headers, + ) 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 34498655..688bbf2a 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,21 +1,17 @@ -import json -from typing import TYPE_CHECKING, cast +from typing import Any import pytest -from fhir.parameters import Parameters from flask import Request -from gateway_api.common.common import FlaskResponse -from gateway_api.common.error import MissingOrEmptyHeaderError +from gateway_api.common.error import ( + MissingOrEmptyHeaderError, +) from gateway_api.conftest import create_mock_request from gateway_api.get_structured_record.request import GetStructuredRecordRequest -if TYPE_CHECKING: - from fhir.bundle import Bundle - @pytest.fixture -def mock_request_with_headers(valid_simple_request_payload: Parameters) -> Request: +def mock_request_with_headers(valid_simple_request_payload: dict[str, Any]) -> Request: headers = { "Ssp-TraceID": "test-trace-id", "ODS-from": "test-ods", @@ -58,7 +54,7 @@ def test_nhs_number_is_pulled_from_request_body( assert actual == expected def test_raises_value_error_when_ods_from_header_is_missing( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """Test that ValueError is raised when ODS-from header is missing.""" headers = { @@ -73,7 +69,7 @@ def test_raises_value_error_when_ods_from_header_is_missing( GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_ods_from_header_is_whitespace( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """ Test that ValueError is raised when ODS-from header contains only whitespace. @@ -91,7 +87,7 @@ def test_raises_value_error_when_ods_from_header_is_whitespace( GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_trace_id_header_is_missing( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """Test that ValueError is raised when Ssp-TraceID header is missing.""" headers = { @@ -106,7 +102,7 @@ def test_raises_value_error_when_trace_id_header_is_missing( GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_trace_id_header_is_whitespace( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """ Test that ValueError is raised when Ssp-TraceID header contains only whitespace. @@ -122,151 +118,3 @@ def test_raises_value_error_when_trace_id_header_is_whitespace( 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/get_structured_record/test_response.py b/gateway-api/src/gateway_api/get_structured_record/test_response.py new file mode 100644 index 00000000..2c4b1a3e --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/test_response.py @@ -0,0 +1,83 @@ +import json +from typing import Any +from unittest.mock import Mock + +from flask import request +from requests.structures import CaseInsensitiveDict + +from gateway_api.app import app +from gateway_api.common.error import UnexpectedError +from gateway_api.get_structured_record import GetStructuredRecordResponse + + +class TestGetStructuredRecordResponse: + def test_mirror_headers_adds_request_headers_to_response( + self, valid_simple_request_payload: dict[str, Any] + ) -> None: + headers_to_be_mirrored = CaseInsensitiveDict({"Ssp-TraceId": "a_trace_id"}) + + with app.test_request_context( + "/patient/$gpc.getstructuredrecord", + method="POST", + data=json.dumps(valid_simple_request_payload), + headers=headers_to_be_mirrored, + ): + response = GetStructuredRecordResponse() + response.mirror_headers(request) + + assert response.headers is not None, ( + "Expected headers to be set, but they were None" + ) + assert all( + response.headers.get(key) == value + for key, value in headers_to_be_mirrored.items() + ), "Expected response headers to match request headers, but they did not" + + def test_add_provider_response_adds_provider_response_body( + self, valid_simple_response_payload: dict[str, Any] + ) -> None: + provider_response = Mock() + provider_response.status_code = 200 + provider_response.json.return_value = valid_simple_response_payload + + response = GetStructuredRecordResponse() + response.add_provider_response(provider_response) + + actual = response.build().json + assert actual == valid_simple_response_payload, ( + "Actual response body did not match expected response body." + ) + + def test_add_provider_response_adds_200_status( + self, valid_simple_response_payload: dict[str, Any] + ) -> None: + provider_response = Mock() + provider_response.status_code = 200 + provider_response.json.return_value = valid_simple_response_payload + + response = GetStructuredRecordResponse() + response.add_provider_response(provider_response) + + actual = response.build().status_code + assert actual == 200, f"Expected status code to be 200, but got {actual}" + + def test_add_error_response_adds_error_response_body(self) -> None: + error = UnexpectedError(traceback="something broke") + + response = GetStructuredRecordResponse() + response.add_error_response(error) + + expected_response_body = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "Internal Server Error: something broke", + } + ], + } + actual_response_body = response.build().json + assert actual_response_body == expected_response_body, ( + "Actual response body did not match expected response body." + ) diff --git a/gateway-api/src/gateway_api/pds/__init__.py b/gateway-api/src/gateway_api/pds/__init__.py index 7c687699..5dc353b6 100644 --- a/gateway-api/src/gateway_api/pds/__init__.py +++ b/gateway-api/src/gateway_api/pds/__init__.py @@ -1,9 +1,7 @@ """PDS (Personal Demographics Service) client and data structures.""" -from gateway_api.pds.client import PdsClient -from gateway_api.pds.search_results import PdsSearchResults +from .client import PdsClient __all__ = [ "PdsClient", - "PdsSearchResults", ] diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 773ca423..f179fea5 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -21,14 +21,12 @@ import os import uuid from collections.abc import Callable -from datetime import UTC, date, datetime -from typing import cast import requests -from fhir import Bundle, BundleEntry, GeneralPractitioner, HumanName, Patient +from fhir.r4 import Patient +from pydantic import ValidationError from gateway_api.common.error import PdsRequestFailedError -from gateway_api.pds.search_results import PdsSearchResults # TODO: Once stub servers/containers made for PDS, SDS and provider # we should remove the STUB_PDS environment variable and just @@ -53,8 +51,8 @@ class PdsClient: * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` - This method returns a :class:`PdsSearchResults` instance when a patient can be - extracted, otherwise ``None``. + This method returns a :class:`Patient` instance when a patient can be + extracted, otherwise raise `PdsRequestFailedError` with a reason for the failure. **Usage example**:: @@ -111,12 +109,12 @@ def search_patient_by_nhs_number( request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, - ) -> PdsSearchResults: + ) -> Patient: """ 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:`PdsSearchResults`. + resource on success, then builds and returns a single :class:`Patient`. """ headers = self._build_headers( request_id=request_id, @@ -138,132 +136,12 @@ def search_patient_by_nhs_number( except requests.HTTPError as err: raise PdsRequestFailedError(error_reason=err.response.reason) from err - body = response.json() - return self._extract_single_search_result(body) - - # --------------- internal helpers for result extraction ----------------- - - def _get_gp_ods_code( - self, general_practitioners: list[GeneralPractitioner] - ) -> str | None: - """ - Extract the current GP ODS code from ``Patient.generalPractitioner``. - - This function implements the business rule: - - * If the list is empty, return ``None``. - * If the list is non-empty and no record is current, return ``None``. - * If exactly one record is current, return its ``identifier.value``. - - In future this may change to return the most recent record if none is current. - """ - if len(general_practitioners) == 0: - return None - - gp = self.find_current_gp(general_practitioners) - if gp is None: - return None - - ods_code = gp["identifier"]["value"] - - return None if ods_code == "None" else ods_code - - def _extract_single_search_result(self, body: Patient | Bundle) -> PdsSearchResults: - """ - 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 - * a FHIR Bundle containing Patient entries (as typically returned by searches). - - For Bundle inputs, the code assumes either zero matches (empty entry list) or a - single match; if multiple entries are present, the first entry is used. - """ - # Accept either: - # 1) Patient (GET /Patient/{id}) - # 2) Bundle with Patient in entry[0].resource (search endpoints) - if str(body.get("resourceType", "")) == "Patient": - patient = cast("Patient", body) - else: - entries = cast("list[BundleEntry]", body.get("entry", [])) - if not entries: - raise PdsRequestFailedError( - error_response="PDS response contains no patient entries" - ) - - # Use the first patient entry. Search by NHS number is unique. Search by - # demographics for an application is allowed to return max one entry from - # PDS. Search by a human can return more, but presumably we count as an - # application. - # See MaxResults parameter in the PDS OpenAPI spec. - entry = entries[0] - patient = entry.get("resource", {}) - nhs_number = str(patient.get("id", "")).strip() - if not nhs_number: + try: + patient = Patient.model_validate(response.json()) + except ValidationError as err: + first_error = err.errors()[0] raise PdsRequestFailedError( - error_reason="PDS Patient resource missing NHS number" - ) - - current_name = self.find_current_name_record(patient["name"]) - - if current_name is not None: - given_names = " ".join(current_name.get("given", [])).strip() - family_name = current_name.get("family", "") - else: - given_names = "" - family_name = "" - - # Extract GP ODS code if a current GP record exists. - gp_ods_code = self._get_gp_ods_code(patient.get("generalPractitioner", [])) - - return PdsSearchResults( - given_names=given_names, - family_name=family_name, - nhs_number=nhs_number, - gp_ods_code=gp_ods_code, - ) + error_reason=str(first_error), + ) from err - def find_current_gp( - self, - general_practitioners: list[GeneralPractitioner], - today: date | None = None, - ) -> GeneralPractitioner | None: - if today is None: - today = datetime.now(UTC).date() - - if self.ignore_dates: - if len(general_practitioners) > 0: - return general_practitioners[-1] - else: - return None - - for record in general_practitioners: - period = record["identifier"]["period"] - start = date.fromisoformat(period["start"]) - # TODO: period is not required to have end - end = date.fromisoformat(period["end"]) - if start <= today <= end: - return record - - return None - - def find_current_name_record( - self, names: list[HumanName], today: date | None = None - ) -> HumanName | None: - if today is None: - today = datetime.now(UTC).date() - - if self.ignore_dates: - if len(names) > 0: - return names[-1] - else: - return None - - for name in names: - period = cast("dict[str, str]", name["period"]) - start = date.fromisoformat(period["start"]) - end = date.fromisoformat(period["end"]) - if start <= today <= end: - return name - - return None + return patient diff --git a/gateway-api/src/gateway_api/pds/search_results.py b/gateway-api/src/gateway_api/pds/search_results.py deleted file mode 100644 index 331a476d..00000000 --- a/gateway-api/src/gateway_api/pds/search_results.py +++ /dev/null @@ -1,18 +0,0 @@ -"""PDS search result data structures.""" - -from dataclasses import dataclass - - -@dataclass -class PdsSearchResults: - """ - A single extracted patient record. - - Only a small subset of the PDS Patient fields are currently required by this - gateway. More will be added in later phases. - """ - - given_names: str - family_name: str - nhs_number: str - gp_ods_code: str | None diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index 95bade84..0263ea89 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -2,27 +2,22 @@ Unit tests for :mod:`gateway_api.pds_search`. """ -from datetime import date -from typing import TYPE_CHECKING, Any +from typing import Any from uuid import UUID, uuid4 import pytest -from fhir import Patient -from fhir.constants import FHIRSystem +from fhir.r4 import Patient from pytest_mock import MockerFixture from gateway_api.common.error import PdsRequestFailedError from gateway_api.conftest import FakeResponse from gateway_api.pds.client import PdsClient -if TYPE_CHECKING: - from fhir import GeneralPractitioner, HumanName - def test_search_patient_by_nhs_number_happy_path( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: dict[str, Any], ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -30,19 +25,17 @@ def test_search_patient_by_nhs_number_happy_path( mocker.patch("gateway_api.pds.client.get", return_value=happy_path_response) client = PdsClient(auth_token) - result = client.search_patient_by_nhs_number("9999999999") + patient = client.search_patient_by_nhs_number("9999999999") - assert result is not None - assert result.nhs_number == "9999999999" - assert result.family_name == "Johnson" - assert result.given_names == "Alice" - assert result.gp_ods_code == "A12345" + assert isinstance(patient, Patient) + assert patient.nhs_number == "9999999999" + assert patient.gp_ods_code == "A12345" def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: dict[str, Any], ) -> None: gp_less_response_body = happy_path_pds_response_body.copy() del gp_less_response_body["generalPractitioner"] @@ -52,19 +45,17 @@ def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( mocker.patch("gateway_api.pds.client.get", return_value=gp_less_response) client = PdsClient(auth_token) - result = client.search_patient_by_nhs_number("9999999999") + patient = client.search_patient_by_nhs_number("9999999999") - assert result is not None - assert result.nhs_number == "9999999999" - assert result.family_name == "Johnson" - assert result.given_names == "Alice" - assert result.gp_ods_code is None + assert isinstance(patient, Patient) + assert patient.nhs_number == "9999999999" + assert patient.gp_ods_code is None def test_search_patient_by_nhs_number_sends_expected_headers( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: dict[str, Any], ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -96,7 +87,7 @@ def test_search_patient_by_nhs_number_sends_expected_headers( def test_search_patient_by_nhs_number_generates_request_id( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: dict[str, Any], ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -134,238 +125,25 @@ def test_search_patient_by_nhs_number_not_found_raises_error( pds.search_patient_by_nhs_number("9900000001") -def test_search_patient_by_nhs_number_finds_current_gp_ods_code_when_pds_returns_two( +def test_search_patient_by_nhs_number_missing_nhs_number_raises_error( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: dict[str, Any], ) -> None: - old_gp: GeneralPractitioner = { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - "system": FHIRSystem.ODS_CODE, - }, - } - current_gp: GeneralPractitioner = { - "id": "2", - "type": "Organization", - "identifier": { - "value": "CURRGP", - "period": {"start": "2020-01-01", "end": "9999-01-01"}, - "system": FHIRSystem.ODS_CODE, - }, - } - pds_response_body_with_two_gps = happy_path_pds_response_body.copy() - pds_response_body_with_two_gps["generalPractitioner"] = [old_gp, current_gp] - pds_response_with_two_gps = FakeResponse( - status_code=200, headers={}, _json=pds_response_body_with_two_gps - ) - mocker.patch("gateway_api.pds.client.get", return_value=pds_response_with_two_gps) - - client = PdsClient(auth_token) - - result = client.search_patient_by_nhs_number("9999999999") - assert result is not None - assert result.nhs_number == "9999999999" - assert result.family_name == "Johnson" - assert result.given_names == "Alice" - assert result.gp_ods_code == "CURRGP" - - -def test_find_current_gp_with_today_override() -> None: - """ - Verify that ``find_current_gp`` honours an explicit ``today`` value. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_dates = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[GeneralPractitioner] = [ - { - "id": "1234", - "type": "Organization", - "identifier": { - "value": "a", - "period": {"start": "2020-01-01", "end": "2020-12-31"}, - "system": FHIRSystem.ODS_CODE, - }, - }, - { - "id": "abcd", - "type": "Organization", - "identifier": { - "value": "b", - "period": {"start": "2021-01-01", "end": "2021-12-31"}, - "system": FHIRSystem.ODS_CODE, - }, - }, - ] - - assert pds.find_current_gp(records, today=date(2020, 6, 1)) == records[0] - assert pds.find_current_gp(records, today=date(2021, 6, 1)) == records[1] - assert pds.find_current_gp(records, today=date(2019, 6, 1)) is None - assert pds_ignore_dates.find_current_gp(records, today=date(2019, 6, 1)) is not None - - -def test_find_current_name_record_no_current_name() -> None: - """ - Verify that ``find_current_name_record`` returns ``None`` when no current name - exists. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_date = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[HumanName] = [ - { - "use": "official", - "family": "Doe", - "given": ["John"], - "period": {"start": "2000-01-01", "end": "2010-12-31"}, - }, - { - "use": "official", - "family": "Smith", - "given": ["John"], - "period": {"start": "2011-01-01", "end": "2020-12-31"}, - }, - ] - - assert pds.find_current_name_record(records) is None - assert pds_ignore_date.find_current_name_record(records) is not None - - -def test_extract_single_search_result_with_invalid_body_raises_pds_request_failed() -> ( - None -): - """ - Verify that ``PdsClient._extract_single_search_result`` raises ``PdsRequestFailed`` - when mandatory patient content is missing. + response_body_missing_nhs_number = happy_path_pds_response_body.copy() + response_body_missing_nhs_number["identifier"] = [] - This test asserts that a ``PdsRequestFailed`` is raised when: - - * The body is a bundle containing no entries (``entry`` is empty). - * The body is a patient resource with no NHS number (missing/blank ``id``). - * The body is a patient resource with an NHS number, - but the patient has no *current* name record. - """ - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - base_url="https://example.test/personal-demographics/FHIR/R4", + response = FakeResponse( + status_code=200, + headers={}, + _json=response_body_missing_nhs_number, ) + mocker.patch("gateway_api.pds.client.get", return_value=response) - # 1) Bundle contains no entries. - bundle_no_entries: Any = {"resourceType": "Bundle", "entry": []} - with pytest.raises(PdsRequestFailedError): - client._extract_single_search_result(bundle_no_entries) # noqa SLF001 (testing private method) - - # 2) Patient has no NHS number (Patient.id missing/blank). - patient_missing_nhs_number: Any = { - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": [], - } - with pytest.raises(PdsRequestFailedError): - client._extract_single_search_result(patient_missing_nhs_number) # noqa SLF001 (testing private method) - - # 3) Bundle entry exists with NHS number, but no current name record. - bundle_no_current_name: Any = { - "resourceType": "Bundle", - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "9000000009", - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - } - ], - "generalPractitioner": [], - } - } - ], - } - - # No current name record is tolerated by PdsClient; names are returned as empty. - result = client._extract_single_search_result(bundle_no_current_name) # noqa SLF001 (testing private method) - assert result is not None - assert result.nhs_number == "9000000009" - assert result.given_names == "" - assert result.family_name == "" - - -def test_find_current_name_record_ignore_dates_returns_last_or_none() -> None: - """ - If ignore_dates=True: - * returns the last name record even if none are current - * returns None when the list is empty - """ - pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[HumanName] = [ - { - "use": "official", - "family": "Old", - "given": ["First"], - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - }, - { - "use": "official", - "family": "Newer", - "given": ["Second"], - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - }, - ] - - # Pick a date that is not covered by any record; ignore_dates should still pick last - chosen = pds_ignore.find_current_name_record(records, today=date(2026, 1, 1)) - assert chosen == records[-1] - - assert pds_ignore.find_current_name_record([]) is None - - -def test_find_current_gp_ignore_dates_returns_last_or_none() -> None: - """ - If ignore_dates=True: - * returns the last GP record even if none are current - * returns None when the list is empty - """ - pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[GeneralPractitioner] = [ - { - "id": "abcd", - "type": "Organization", - "identifier": { - "value": "GP-OLD", - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - "system": FHIRSystem.ODS_CODE, - }, - }, - { - "id": "1234", - "type": "Organization", - "identifier": { - "value": "GP-NEWER", - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - "system": FHIRSystem.ODS_CODE, - }, - }, - ] + client = PdsClient(auth_token) - # Pick a date that is not covered by any record; ignore_dates should still pick last - chosen = pds_ignore.find_current_gp(records, today=date(2026, 1, 1)) - assert chosen == records[-1] + with pytest.raises(PdsRequestFailedError) as error: + client.search_patient_by_nhs_number("9999999999") - assert pds_ignore.find_current_gp([]) is None + assert "'identifier'" in str(error.value) + assert "too_short" in str(error.value) diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index c9ae14de..6fb56c2f 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -10,7 +10,6 @@ from typing import Any import pytest -from fhir import Parameters from requests import Response from requests.structures import CaseInsensitiveDict from stubs.provider.stub import GpProviderStub @@ -63,7 +62,7 @@ def _fake_post( def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], valid_jwt: JWT, ) -> None: """ @@ -97,7 +96,7 @@ 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], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], valid_jwt: JWT, ) -> None: """ @@ -139,7 +138,7 @@ 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], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], valid_jwt: JWT, ) -> None: """ @@ -174,7 +173,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( @pytest.mark.usefixtures("mock_request_post") def test_valid_gpprovider_access_structured_record_returns_stub_response_200( stub: GpProviderStub, - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], valid_jwt: JWT, ) -> None: """ @@ -223,7 +222,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( @pytest.mark.usefixtures("mock_request_post") def test_access_structured_record_raises_external_service_error( - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], valid_jwt: JWT, ) -> None: """ @@ -252,7 +251,7 @@ def test_access_structured_record_raises_external_service_error( def test_gpprovider_client_includes_authorization_header_with_bearer_token( mock_request_post: dict[str, Any], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], valid_jwt: JWT, ) -> None: """ @@ -284,7 +283,7 @@ def test_gpprovider_client_includes_authorization_header_with_bearer_token( @pytest.mark.usefixtures("mock_request_post") def test_access_structured_record_debug_error_when_cdg_debug_set( - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], valid_jwt: JWT, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index 01e2c283..cd4b6607 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -10,10 +10,11 @@ import os from enum import StrEnum -from typing import Any, cast +from typing import Any +from fhir import Resource from fhir.constants import FHIRSystem -from stubs import SdsFhirApiStub +from fhir.r4 import Bundle, Device, Endpoint from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID from gateway_api.sds.search_results import SdsSearchResults @@ -25,16 +26,11 @@ if not STUB_SDS: from requests import get else: - from stubs.sds.stub import SdsFhirApiStub + from stubs import SdsFhirApiStub sds = SdsFhirApiStub() get = sds.get # type: ignore -# Recursive JSON-like structure typing used for parsed FHIR bodies. -type ResultStructureDict = dict[str, ResultStructure] -type ResultList = list[ResultStructureDict] -type ResultStructure = str | ResultStructureDict | list["ResultStructure"] - class SdsResourceType(StrEnum): """SDS FHIR resource types.""" @@ -129,12 +125,16 @@ def get_org_details( querytype=SdsResourceType.DEVICE, ) - device = self._extract_first_entry(device_bundle) + device = self._extract_first_resource(device_bundle, Device) - # TODO: Post-steel-thread handle case where no device is found for ODS code + if not device: + empty_search_results = SdsSearchResults(asid=None, endpoint=None) + return empty_search_results - asid = self._extract_identifier(device, FHIRSystem.NHS_SPINE_ASID) - party_key = self._extract_identifier(device, FHIRSystem.NHS_MHS_PARTY_KEY) + asid = self._extract_device_identifier(device, FHIRSystem.NHS_SPINE_ASID) + party_key = self._extract_device_identifier( + device, FHIRSystem.NHS_MHS_PARTY_KEY + ) # Step 2: Get Endpoint to obtain endpoint URL endpoint_url: str | None = None @@ -149,11 +149,9 @@ def get_org_details( timeout=timeout, querytype=SdsResourceType.ENDPOINT, ) - endpoint = self._extract_first_entry(endpoint_bundle) - if endpoint: - address = endpoint.get("address") - if address: - endpoint_url = str(address).strip() + endpoint = self._extract_first_resource(endpoint_bundle, Endpoint) + if endpoint and endpoint.address: + endpoint_url = str(endpoint.address).strip() return SdsSearchResults(asid=asid, endpoint=endpoint_url) @@ -177,7 +175,7 @@ def _query_sds( correlation_id: str | None = None, timeout: int | None = 10, querytype: SdsResourceType = SdsResourceType.DEVICE, - ) -> ResultStructureDict: + ) -> Bundle: """ Query SDS /Device or /Endpoint endpoint. """ @@ -203,38 +201,29 @@ def _query_sds( # TODO: Post-steel-thread we probably want a raise_for_status() here - body = response.json() - return cast("ResultStructureDict", body) + bundle = Bundle.model_validate(response.json()) + return bundle @staticmethod - def _extract_first_entry( - bundle: ResultStructureDict, - ) -> ResultStructureDict: # TODO: Post-steel-thread this may return a None as well - """ - Extract the first resource from a Bundle. - """ - entries = cast("ResultList", bundle.get("entry", [])) - + def _extract_first_resource[T: Resource]( + bundle: Bundle, resource: type[T] + ) -> T | None: # TODO: Post-steel-thread handle case where bundle contains no entries # TODO: more carefully consider business logic for handling multiple # entries in beta - if not entries: - return {} - first_entry = entries[0] - return cast("ResultStructureDict", first_entry.get("resource", {})) - - def _extract_identifier( - self, device: ResultStructureDict, system: str - ) -> str | None: + resources = bundle.find_resources(resource) + if not resources: + return None + first_entry = resources[0] + return first_entry + + def _extract_device_identifier(self, device: Device, system: str) -> str | None: """ Extract an identifier value from a Device resource for a given system. """ - identifiers = cast("ResultList", device.get("identifier", [])) - - for identifier in identifiers: - id_system = str(identifier.get("system", "")) - if id_system == system: - return cast("str", identifier.get("value", "")) + for identifier in device.identifier: + if identifier.system == system: + return identifier.value return None diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index de7982f9..e6f808e0 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -4,20 +4,15 @@ import os from collections.abc import Generator from copy import copy -from typing import TYPE_CHECKING +from typing import Any +from unittest.mock import Mock import pytest -from fhir.bundle import Bundle -from fhir.parameters import Parameters from flask import Flask from flask.testing import FlaskClient from pytest_mock import MockerFixture from gateway_api.app import app, get_app_host, get_app_port -from gateway_api.common.common import FlaskResponse - -if TYPE_CHECKING: - from fhir.operation_outcome import OperationOutcome @pytest.fixture @@ -58,7 +53,7 @@ class TestGetStructuredRecord: def test_valid_get_structured_record_request_returns_expected_bundle( self, get_structured_record_response: Flask, - valid_simple_response_payload: Bundle, + valid_simple_response_payload: dict[str, Any], ) -> None: actual_bundle = get_structured_record_response.get_json() assert actual_bundle == valid_simple_response_payload @@ -133,7 +128,7 @@ def test_get_structured_record_returns_operation_outcome_when_missing_header( get_structured_record_response_from_missing_header: Flask, expected_message: str, ) -> None: - expected_body: OperationOutcome = { + expected_body = { "resourceType": "OperationOutcome", "issue": [ { @@ -164,7 +159,7 @@ def test_get_structured_record_returns_content_type_fhir_json_for_invalid_json_s def test_get_structured_record_returns_internal_server_error_when_invalid_json_sent( self, get_structured_record_response_using_invalid_json_body: Flask ) -> None: - expected: OperationOutcome = { + expected = { "resourceType": "OperationOutcome", "issue": [ { @@ -182,7 +177,7 @@ def test_get_structured_record_returns_internal_server_error_when_invalid_json_s def get_structured_record_response( client: FlaskClient[Flask], valid_headers: dict[str, str], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], ) -> Flask: response = client.post( "/patient/$gpc.getstructuredrecord", @@ -196,7 +191,7 @@ def get_structured_record_response( def get_structured_record_response_from_missing_header( client: FlaskClient[Flask], missing_headers: dict[str, str], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], ) -> Flask: response = client.post( "/patient/$gpc.getstructuredrecord", @@ -225,15 +220,15 @@ def get_structured_record_response_using_invalid_json_body( def mock_positive_return_value_from_controller_run( mocker: MockerFixture, valid_headers: dict[str, str], - valid_simple_response_payload: Bundle, + valid_simple_response_payload: dict[str, Any], ) -> None: - postive_response = FlaskResponse( - status_code=200, - data=json.dumps(valid_simple_response_payload), - headers=valid_headers, - ) + positive_response = Mock() + positive_response.status_code = 200 + positive_response.json.return_value = valid_simple_response_payload + positive_response.headers = valid_headers + mocker.patch( - "gateway_api.controller.Controller.run", return_value=postive_response + "gateway_api.controller.Controller.run", return_value=positive_response ) @staticmethod diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index a55abbc2..d6cfb1c1 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,10 +1,14 @@ """Unit tests for :mod:`gateway_api.controller`.""" -import json +from typing import Any import pytest -from fhir.bundle import Bundle -from fhir.parameters import Parameters +from fhir.r4 import ( + GeneralPractitioner, + OrganizationIdentifier, + Patient, + PatientIdentifier, +) from flask import Request from pytest_mock import MockerFixture @@ -17,30 +21,48 @@ from gateway_api.conftest import FakeResponse, create_mock_request from gateway_api.controller import Controller from gateway_api.get_structured_record import GetStructuredRecordRequest -from gateway_api.pds import PdsSearchResults from gateway_api.sds import SdsSearchResults +def _create_patient(nhs_number: str, gp_ods_code: str | None) -> Patient: + general_practitioner = None + if gp_ods_code is not None: + general_practitioner = [ + GeneralPractitioner( + type="Organization", + identifier=OrganizationIdentifier( + value=gp_ods_code, + ), + ) + ] + + return Patient.create( + identifier=[PatientIdentifier.from_nhs_number(nhs_number)], + generalPractitioner=general_practitioner, + ) + + def test_controller_run_happy_path_returns_200_status_code( mock_happy_path_get_structured_record_request: Request, ) -> None: + request = GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) + controller = Controller() - actual_response = controller.run( - GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) - ) + actual_response = controller.run(request) + assert actual_response.status_code == 200 def test_controller_run_happy_path_returns_returns_expected_body( mock_happy_path_get_structured_record_request: Request, - valid_simple_response_payload: Bundle, + valid_simple_response_payload: dict[str, Any], ) -> None: + request = GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) + controller = Controller() - actual_response = controller.run( - GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) - ) - assert isinstance(actual_response.data, str) - assert json.loads(actual_response.data) == valid_simple_response_payload + actual_response = controller.run(request) + + assert actual_response.json() == valid_simple_response_payload def test_get_pds_details_returns_provider_ods_code_for_happy_path( @@ -48,15 +70,9 @@ def test_get_pds_details_returns_provider_ods_code_for_happy_path( auth_token: str, ) -> None: nhs_number = "9000000009" - pds_search_result = PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code="A12345", - ) mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=pds_search_result, + return_value=_create_patient(nhs_number, "A12345"), ) controller = Controller(pds_base_url="https://example.test/pds", timeout=7) @@ -70,15 +86,9 @@ def test_get_pds_details_raises_no_current_provider_when_ods_code_missing_in_pds auth_token: str, ) -> None: nhs_number = "9000000009" - pds_search_result_without_ods_code = PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code=None, - ) mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=pds_search_result_without_ods_code, + return_value=_create_patient(nhs_number, None), ) controller = Controller() @@ -238,8 +248,8 @@ def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_consumer_as @pytest.fixture def mock_happy_path_get_structured_record_request( mocker: MockerFixture, - valid_simple_request_payload: Parameters, - valid_simple_response_payload: Bundle, + valid_simple_request_payload: dict[str, Any], + valid_simple_response_payload: dict[str, Any], ) -> Request: nhs_number = "9000000009" provider_ods = "ProviderODS" @@ -253,12 +263,7 @@ def mock_happy_path_get_structured_record_request( sds_results = [provider_sds_results, consumer_sds_results] mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code=provider_ods, - ), + return_value=_create_patient(nhs_number, provider_ods), ) mocker.patch( "gateway_api.sds.SdsClient.get_org_details", @@ -284,8 +289,8 @@ def mock_happy_path_get_structured_record_request( def test_controller_creates_jwt_token_with_correct_claims( mocker: MockerFixture, - valid_simple_request_payload: Parameters, - valid_simple_response_payload: Bundle, + valid_simple_request_payload: dict[str, Any], + valid_simple_response_payload: dict[str, Any], ) -> None: """ Test that the controller creates a JWT token with the correct claims. @@ -296,15 +301,9 @@ def test_controller_creates_jwt_token_with_correct_claims( provider_endpoint = "https://provider.example/ep" # Mock PDS to return provider ODS code - pds_search_result = PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code=provider_ods, - ) mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=pds_search_result, + return_value=_create_patient(nhs_number, provider_ods), ) # Mock SDS to return provider and consumer details @@ -336,8 +335,10 @@ def test_controller_creates_jwt_token_with_correct_claims( body=valid_simple_request_payload, ) + get_structured_record_request = GetStructuredRecordRequest(request) + controller = Controller() - _ = controller.run(GetStructuredRecordRequest(request)) + controller.run(get_structured_record_request) # Verify that GpProviderClient was called and extract the JWT token mock_gp_provider.assert_called_once() diff --git a/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json index 558a4e30..a9f9665e 100644 --- a/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json +++ b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "A12345", "period": {"start": "2020-01-01", "end": "9999-12-31"} } diff --git a/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json index 58b47242..d635e8e5 100644 --- a/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json +++ b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "BlankAsidInSDS", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json index 1e3645b6..3d6f18d8 100644 --- a/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json +++ b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "BlankEndpointInSDS", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json index 94ed1c30..fd1c7214 100644 --- a/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json +++ b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "A12345", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json index f43198ba..78b61fb9 100644 --- a/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json +++ b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "DoesNotExistInSDS", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json index 6834ebe6..b3168cf6 100644 --- a/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json +++ b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "BlankConsumerRequest", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/tests/acceptance/steps/gateway_api_responses.py b/gateway-api/tests/acceptance/steps/gateway_api_responses.py index 1042dc9a..7014b9ad 100644 --- a/gateway-api/tests/acceptance/steps/gateway_api_responses.py +++ b/gateway-api/tests/acceptance/steps/gateway_api_responses.py @@ -1,8 +1,8 @@ """Step definitions for Gateway API response behaviour feature.""" import json +from typing import Any -from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when from stubs.data.bundles import Bundles @@ -33,7 +33,7 @@ def check_api_is_running(client: Client) -> None: def send_get_request( client: Client, response_context: ResponseContext, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response_context.response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) @@ -44,7 +44,7 @@ def send_get_request( def send_to_nonexistent_endpoint( client: Client, response_context: ResponseContext, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response_context.response = client.send_post_to_path( path="/nonexistent", diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index c5a6e7bd..851eee98 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -2,13 +2,12 @@ import os from datetime import timedelta -from typing import Protocol, cast +from typing import Any, Protocol, cast import pytest import requests from dotenv import find_dotenv, load_dotenv from fhir.constants import FHIRSystem -from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root load_dotenv(find_dotenv(usecwd=True)) @@ -145,7 +144,7 @@ def send_health_check(self) -> requests.Response: @pytest.fixture -def simple_request_payload() -> Parameters: +def simple_request_payload() -> dict[str, Any]: return { "resourceType": "Parameters", "parameter": [ diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 868ecb7e..480ca4f5 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -2,9 +2,9 @@ import json from collections.abc import Callable +from typing import Any import pytest -from fhir.parameters import Parameters from requests import Response from stubs.data.bundles import Bundles @@ -15,7 +15,7 @@ class TestGetStructuredRecord: def test_happy_path_returns_200( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) @@ -25,7 +25,7 @@ def test_happy_path_returns_200( def test_happy_path_returns_correct_message( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) @@ -35,13 +35,25 @@ def test_happy_path_returns_correct_message( def test_happy_path_content_type( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) assert "application/fhir+json" in response.headers["Content-Type"] + def test_happy_path_response_mirrors_request_headers( + self, + client: Client, + simple_request_payload: dict[str, Any], + ) -> None: + headers_to_be_mirrored = {"Ssp-TraceID": "a_trace_id"} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=headers_to_be_mirrored + ) + for header_key, header_value in headers_to_be_mirrored.items(): + assert response.headers.get(header_key) == header_value + def test_empty_request_body_returns_400_status_code( self, response_from_sending_request_with_empty_body: Response ) -> None: @@ -273,7 +285,7 @@ def response_when_sds_returns_blank_provider_asid( @pytest.fixture def response_when_sds_returns_blank_consumer_asid( - self, client: Client, simple_request_payload: Parameters + self, client: Client, simple_request_payload: dict[str, Any] ) -> Response: ods_from_for_consumer_with_blank_consumer_asid_in_sds = "BlankAsidInSDS" headers = {"Ods-From": ods_from_for_consumer_with_blank_consumer_asid_in_sds} @@ -312,7 +324,7 @@ def response_when_sds_provider_endpoint_blank( @pytest.fixture def response_when_consumer_is_none_from_sds( - self, client: Client, simple_request_payload: Parameters + self, client: Client, simple_request_payload: dict[str, Any] ) -> Response: ods_from_for_consumer_with_none_consumer_in_sds = "ConsumerWithNoneInSDS" headers = {"Ods-From": ods_from_for_consumer_with_none_consumer_in_sds} @@ -325,7 +337,7 @@ def response_when_consumer_is_none_from_sds( def get_structured_record_requester( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> Callable[[str], Response]: def requester(nhs_number: str) -> Response: simple_request_payload["parameter"][0]["valueIdentifier"]["value"] = ( diff --git a/ruff.toml b/ruff.toml index db28865d..6417e6d5 100644 --- a/ruff.toml +++ b/ruff.toml @@ -50,4 +50,7 @@ ignore =["COM812"] # Ignore assert rule in test files to keep test code susinct and easy to read. [lint.per-file-ignores] "**/{tests,steps}/*" = ["S101"] -"**/test_*.py" = ["S101"] +"**/test_*.py" = [ + "S101", # Allow `assert` in tests + "SLF001", # private members can be accessed in tests +] diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 33ed410e..d06d7613 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -36,6 +36,7 @@ Octokit onboarding Podman [Pp]roxygen +[Pp]ydantic [Pp]ytest Python [Rr]epos? @@ -47,5 +48,6 @@ Terraform toolchain Trufflehog usebruno +validators? VMs [Vv]scode diff --git a/scripts/tests/run-test.sh b/scripts/tests/run-test.sh index 97d5cb69..3f122077 100755 --- a/scripts/tests/run-test.sh +++ b/scripts/tests/run-test.sh @@ -55,7 +55,6 @@ if [[ "${ENV:-local}" = "remote" ]] && [[ "$TEST_TYPE" != "unit" ]]; then --html="test-artefacts/${TEST_TYPE}-tests.html" --self-contained-html else poetry run pytest ${TEST_PATH} -v \ - --api-name="clinical-data-gateway-api" \ --cov="${COV_PATH}" \ --cov-report=html:test-artefacts/coverage-html \ --cov-report=term \