diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ccfbeb7c..2dcf360c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,8 @@ "ms-python.vscode-python-envs", "ms-python.mypy-type-checker", "sonarsource.sonarlint-vscode", - "alexkrechik.cucumberautocomplete" + "alexkrechik.cucumberautocomplete", + "streetsidesoftware.code-spell-checker" ], "extensions.ignoreRecommendations": true, "settings": { diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 32a5fd2b..5e9857aa 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -3,6 +3,9 @@ name: "Test stage" env: BASE_URL: "http://localhost:5000" HOST: "localhost" + STUB_SDS: "true" + STUB_PDS: "true" + STUB_PROVIDER: "true" on: workflow_call: diff --git a/.tool-versions b/.tool-versions index 253dc21c..fcbb5c74 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,6 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.14.0 +terraform 1.14.5 pre-commit 3.6.0 gitleaks 8.18.4 @@ -15,7 +15,7 @@ gitleaks 8.18.4 # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags -# docker/hashicorp/terraform 1.12.2@sha256:b3d13c9037d2bd858fe10060999aa7ca56d30daafe067d7715b29b3d4f5b162f # SEE: https://hub.docker.com/r/hashicorp/terraform/tags +# docker/hashicorp/terraform 1.14.5@sha256:96d2bc440714bf2b2f2998ac730fd4612f30746df43fca6f0892b2e2035b11bc # SEE: https://hub.docker.com/r/hashicorp/terraform/tags # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags # docker/sonarsource/sonar-scanner-cli 10.0@sha256:0bc49076468d2955948867620b2d98d67f0d59c0fd4a5ef1f0afc55cf86f2079 # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..fe95d626 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Run Gateway API", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/gateway-api/src/gateway_api/app.py", + "envFile": "${workspaceFolder}/.env", + "jinja": true, + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c5f1eea..bebdaa8f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,5 +60,17 @@ "projectKey": "NHSDigital_clinical-data-gateway-api" }, // Disabling automatic port forwarding as the devcontainer should already have access to any required ports. - "remote.autoForwardPorts": false + "remote.autoForwardPorts": false, + + // Code spell checker configuration + "cSpell.language": "en-GB", + "cSpell.words": [ + "asid", + "fhir", + "getstructuredrecord", + "gpconnect", + "usefixtures", + ], + "python-envs.defaultEnvManager": "ms-python.python:system", + "python-envs.pythonProjects": [], } diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index b9c73434..ef29b2c1 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -247,3 +247,28 @@ paths: diagnostics: type: string example: "Internal server error" + + '502': + description: Received an error response from a downstream server + content: + application/fhir+json: + schema: + type: object + properties: + resourceType: + type: string + example: "OperationOutcome" + issue: + type: array + items: + type: object + properties: + severity: + type: string + example: "error" + code: + type: string + example: "exception" + diagnostics: + type: string + example: "PDS FHIR API request failed: Bad Gateway" diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 88b054f5..3c5a3418 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -51,18 +51,6 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[[package]] -name = "backoff" -version = "2.2.1" -description = "Function decoration for backoff and retry" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["dev"] -files = [ - {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, - {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, -] - [[package]] name = "blinker" version = "1.9.0" @@ -628,14 +616,14 @@ zoneinfo = ["tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \" [[package]] name = "hypothesis-graphql" -version = "0.11.1" +version = "0.12.0" description = "Hypothesis strategies for GraphQL queries" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "hypothesis_graphql-0.11.1-py3-none-any.whl", hash = "sha256:a6968f703bcdc31fbe1b26be69185aa2c824eb3b478057a66aa85967c81cadca"}, - {file = "hypothesis_graphql-0.11.1.tar.gz", hash = "sha256:bd49ab6804a3f488ecab2e39c20dba6dfc2101525c6742f5831cfa9eff95285a"}, + {file = "hypothesis_graphql-0.12.0-py3-none-any.whl", hash = "sha256:d200d3d4320e772248075f13c656f4b1de01e7f0f5e7d9fd6fea7da759b325f3"}, + {file = "hypothesis_graphql-0.12.0.tar.gz", hash = "sha256:15f5f69b6e0b9ad889f59d340e091d7d481471373eb6a8a8591d126aa56e7700"}, ] [package.dependencies] @@ -777,6 +765,48 @@ webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format\" format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] +[[package]] +name = "jsonschema-rs" +version = "0.42.1" +description = "A high-performance JSON Schema validator for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jsonschema_rs-0.42.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7d4c2cf89fb1f49399be7f0e601526f189497f4f7bbefc4fac5f4447ca52609c"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:40d53eea48a17876d6802405edc6e0367f07260545713ac6d727054bce8c425f"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de86325cf5e0d1c35ec14e60ffe2ce4547c8385802ea69ac11540616b822cb2"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c664bd3ffb1cfd70d2b8b8b9587782184a81a8467a70bcc6c71a84cf573ecdf3"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d7dc31fa2b644205271ac1071aec005f88565b135ad1f983b8c1de2589266e1e"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d5602f07fe69f108f7dd9d2d05d940a2498517f07a230da2efb8f36bf06e0703"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:005cd79783a4980ad68d21f7a25c913778dc6a0fe8e3d3c76132eabb7a40287a"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-win32.whl", hash = "sha256:6817e5c1fcb10d80b4dda38cd106850c7e3e9dc06d5afe93668b9c99744723c9"}, + {file = "jsonschema_rs-0.42.1-cp310-abi3-win_amd64.whl", hash = "sha256:b508dd9a114352bf8fc20d8e6d01563fbb18f3f877d11e0ecbbb43c556ec4174"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:4849b65048e4fd53991424a827f8369d0b6c7ad1d9ff05bb854afa685131b954"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98ca39207afab8782149810b789c717d5a0bb7bbb6330bd537827eeecbbeccbb"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a363739a254c3990cbea18a7350de3bc06cdf02307ee1db60ff86fd13e1ff58"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:28b235ab6263f96ed2448f645291d446e4433c7fa6cc255bff2dcffd2a2b65a9"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f50e0baabb3d6d1b250ad776ade46c6bf3599c681e961f686a6a50e925322d64"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6abf63a5523e13257a0e3a8cf58d15a46e00fcaaafc6f92c34c9109575529"}, + {file = "jsonschema_rs-0.42.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e20154d59e843b36e2e7d6b7415954ae3374e23d97a2ad11670a75fe0884c246"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2fa26381601a32439ff46c7588476a0fc4d2a0b97a58da756196fd747b99bf01"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0d76660cd5b143e342c5d98bf9690258171dbe1beba7cdc6354bc736eb286f7e"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52afd2b1ebf7360a0e9ec40b23cda167b92d776ed8f14f1a6c78a0fc3070453d"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:1a7869a0c81a12bf7875235e0d4b95b68392655373c1f443fbbacd0e7cc9d289"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cdbed80ae956fff192bf32b5be902f07a46788b12a038c8093928c1e80035a73"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b6e795ee51b807eea75df5b43a4380f950c6ff3feb586e01b2423b4d31093881"}, + {file = "jsonschema_rs-0.42.1-cp314-cp314t-win_amd64.whl", hash = "sha256:50bda44a74ddae8bdd1e35785d738c39847b54fcaf4e803f4b51ace095d94a51"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5844afa812db3a61b8994ec562f7a13a62208ec7f9806c34784fc22c945ac87c"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad9a5bfc34394d812f88d3e4b320236fbb9b66b34c88f9ec13f9143f97d562a6"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dce9ddf7084bc7d2fbd1bab4a81d69f99413d001971b56ca26403bafd6da5432"}, + {file = "jsonschema_rs-0.42.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3cf915dc8eeb304c045fb85e34a97d6d3f563aa75e13d8eb2ab6ebb145b3adc4"}, + {file = "jsonschema_rs-0.42.1.tar.gz", hash = "sha256:4144cd351d39ce457f2c7d45111e7225eb5ed1791e0226dec5b9099d78651e32"}, +] + +[package.extras] +bench = ["fastjsonschema (>=2.20.0)", "jsonschema (>=4.23.0)", "pytest-benchmark (>=4.0.0)"] +tests = ["flask (>=2.2.5)", "hypothesis (>=6.79.4)", "pytest (>=7.4.4)"] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -1482,20 +1512,20 @@ docs = ["furo (>=2022.3.4,<2023.0.0)", "myst-parser (>=0.17)", "sphinx (>=4.3.0, [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -1582,6 +1612,24 @@ pytest = ">=7.0.0" [package.extras] test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pytest-subtests" version = "0.14.2" @@ -1922,43 +1970,44 @@ files = [ [[package]] name = "schemathesis" -version = "4.4.1" +version = "4.10.2" description = "Property-based testing framework for Open API and GraphQL based apps" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "schemathesis-4.4.1-py3-none-any.whl", hash = "sha256:6b68170cef21b001cc43a244ed5aaaf62b5d6826984c8ae09a495047d47cf065"}, - {file = "schemathesis-4.4.1.tar.gz", hash = "sha256:e248edf5e6d5a47babf70133593064104ceddf45019e2bd89b1da5150eec4c61"}, + {file = "schemathesis-4.10.2-py3-none-any.whl", hash = "sha256:47a1f32a81dd237dbeb1da4374e48dd4402813c4c75fa23091a4f0986a8616be"}, + {file = "schemathesis-4.10.2.tar.gz", hash = "sha256:ad69508a9dd1a5b6fd6f4891abe86a9fc5f3f0d7a1133353359aadfd9522ac1f"}, ] [package.dependencies] -backoff = ">=2.1.2,<3.0" click = ">=8.0,<9" colorama = ">=0.4,<1.0" harfile = ">=0.4.0,<1.0" httpx = ">=0.22.0,<1.0" hypothesis = ">=6.108.0,<7" -hypothesis-graphql = ">=0.11.1,<1" +hypothesis-graphql = ">=0.12.0,<1" hypothesis-jsonschema = ">=0.23.1,<0.24" jsonschema = {version = ">=4.18.0,<5.0", extras = ["format"]} +jsonschema-rs = ">=0.41.0" junit-xml = ">=1.9,<2.0" pyrate-limiter = ">=3.0,<4.0" -pytest = ">=8,<9" -pytest-subtests = ">=0.11,<0.15.0" +pytest = ">=8,<10" +pytest-subtests = ">=0.11,<0.16.0" pyyaml = ">=5.1,<7.0" requests = ">=2.22,<3" rich = ">=13.9.4" starlette-testclient = ">=0.4.1,<1" +tenacity = ">=9.1.2,<10.0" typing-extensions = ">=4.12.2" werkzeug = ">=0.16.0,<4" [package.extras] bench = ["pytest-codspeed (==4.2.0)"] cov = ["coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=2,<5.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] docs = ["mkdocs-material", "mkdocstrings[python]"] -tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=2,<5.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] [[package]] name = "six" @@ -2030,6 +2079,22 @@ files = [ requests = "*" starlette = ">=0.20.1" +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "types-click" version = "7.1.8" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 748ebd4f..a841d21e 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -55,6 +55,7 @@ dev = [ "schemathesis>=4.4.1", "types-requests (>=2.32.4.20250913,<3.0.0.0)", "types-pyyaml (>=6.0.12.20250915,<7.0.0.0)", + "pytest-mock (>=3.15.1,<4.0.0)", ] [tool.mypy] diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 4ad915ee..f7b15f5c 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,6 +1,7 @@ """FHIR data types and resources.""" 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 @@ -17,4 +18,5 @@ "Parameter", "Parameters", "Patient", + "GeneralPractitioner", ] diff --git a/gateway-api/src/fhir/general_practitioner.py b/gateway-api/src/fhir/general_practitioner.py new file mode 100644 index 00000000..6589fffe --- /dev/null +++ b/gateway-api/src/fhir/general_practitioner.py @@ -0,0 +1,21 @@ +"""FHIR GeneralPractitioner type.""" + +from typing import TypedDict + +from fhir.period import Period + + +class GeneralPractitionerIdentifier(TypedDict): + """Identifier for GeneralPractitioner with optional period.""" + + 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 index 2a73deb0..6b284c88 100644 --- a/gateway-api/src/fhir/human_name.py +++ b/gateway-api/src/fhir/human_name.py @@ -2,8 +2,11 @@ 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/patient.py b/gateway-api/src/fhir/patient.py index 33d0ce41..453a6f2a 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -1,7 +1,8 @@ """FHIR Patient resource.""" -from typing import TypedDict +from typing import NotRequired, TypedDict +from fhir.general_practitioner import GeneralPractitioner from fhir.human_name import HumanName from fhir.identifier import Identifier @@ -13,3 +14,4 @@ class Patient(TypedDict): 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 new file mode 100644 index 00000000..6ac40b4f --- /dev/null +++ b/gateway-api/src/fhir/period.py @@ -0,0 +1,10 @@ +"""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/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 265601e5..881c32fa 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,10 +4,10 @@ from flask import Flask, request from flask.wrappers import Response +from gateway_api.common.error import BaseError from gateway_api.controller import Controller from gateway_api.get_structured_record import ( GetStructuredRecordRequest, - RequestValidationError, ) app = Flask(__name__) @@ -38,27 +38,16 @@ def get_app_port() -> int: def get_structured_record() -> Response: try: get_structured_record_request = GetStructuredRecordRequest(request) - except RequestValidationError as e: - response = Response( - response=str(e), - status=400, - content_type="text/plain", - ) - return response - except Exception as e: - response = Response( - response=f"Internal Server Error: {e}", - status=500, - content_type="text/plain", - ) - return response - - try: controller = Controller() flask_response = controller.run(request=get_structured_record_request) get_structured_record_request.set_response_from_flaskresponse(flask_response) - except Exception as e: - get_structured_record_request.set_negative_response(str(e)) + except BaseError as e: + e.log() + return e.build_response() + except Exception: + error = BaseError() + error.log() + return error.build_response() return get_structured_record_request.build_response() diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py new file mode 100644 index 00000000..a2779848 --- /dev/null +++ b/gateway-api/src/gateway_api/common/error.py @@ -0,0 +1,101 @@ +import json +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" + + +@dataclass +class BaseError(Exception): + _message = "Internal Server Error" + status_code: int = INTERNAL_SERVER_ERROR + severity: str = "error" + error_code: ErrorCode = ErrorCode.EXCEPTION + + 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", + ) + return response + + def log(self) -> None: + print(self) # TODO: Use traceback.print_exec() + + @property + def message(self) -> str: + return self._message.format(**self.additional_details) + + def __str__(self) -> str: + return self.message + + +class InvalidRequestJSON(BaseError): + _message = "Invalid JSON body sent in request" + error_code = ErrorCode.INVALID + status_code = BAD_REQUEST + + +class MissingOrEmptyHeader(BaseError): + _message = 'Missing or empty required header "{header}"' + status_code = BAD_REQUEST + + +class NoCurrentProvider(BaseError): + _message = "PDS patient {nhs_number} did not contain a current provider ODS code" + status_code = NOT_FOUND + + +class NoOrganisationFound(BaseError): + _message = "No SDS org found for {org_type} ODS code {ods_code}" + status_code = NOT_FOUND + + +class NoAsidFound(BaseError): + _message = ( + "SDS result for {org_type} ODS code {ods_code} did not contain a current ASID" + ) + status_code = NOT_FOUND + + +class NoCurrentEndpoint(BaseError): + _message = ( + "SDS result for provider ODS code {provider_ods} did not contain " + "a current endpoint" + ) + status_code = NOT_FOUND + + +class PdsRequestFailed(BaseError): + _message = "PDS FHIR API request failed: {error_reason}" + status_code = BAD_GATEWAY + + +class ProviderRequestFailed(BaseError): + _message = "Provider request failed: {error_reason}" + status_code = BAD_GATEWAY diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 05307c86..65e3c779 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -1,7 +1,54 @@ """Pytest configuration and shared fixtures for gateway API tests.""" +import json +from dataclasses import dataclass +from typing import Any + import pytest -from fhir.parameters import Parameters +import requests +from fhir import Bundle, OperationOutcome, Parameters, Patient +from flask import Request +from requests.structures import CaseInsensitiveDict +from werkzeug.test import EnvironBuilder + + +@dataclass +class FakeResponse: + """ + Minimal substitute for :class:`requests.Response` used by tests. + """ + + status_code: int + headers: dict[str, str] | CaseInsensitiveDict[str] + _json: dict[str, Any] | Patient | OperationOutcome | Bundle + reason: str = "" + + def json(self) -> dict[str, Any] | Patient | OperationOutcome | Bundle: + return self._json + + def raise_for_status(self) -> None: + if self.status_code != 200: + err = requests.HTTPError(f"{self.status_code} Error") + # requests attaches a Response to HTTPError.response; the client expects it + err.response = self + raise err + + @property + def text(self) -> str: + return json.dumps(self._json) + + +def create_mock_request(headers: dict[str, str], body: Parameters) -> Request: + """Create a proper Flask Request object with headers and JSON body.""" + builder = EnvironBuilder( + method="POST", + path="/patient/$gpc.getstructuredrecord", + data=json.dumps(body), + content_type="application/fhir+json", + headers=headers, + ) + env = builder.get_environ() + return Request(env) @pytest.fixture @@ -18,3 +65,90 @@ def valid_simple_request_payload() -> Parameters: }, ], } + + +@pytest.fixture +def valid_simple_response_payload() -> Bundle: + return { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-02-05T22:45:42.766330+00:00", + "entry": [ + { + "fullUrl": "https://example.com/Patient/9999999999", + "resource": { + "name": [ + { + "family": "Alice", + "given": ["Johnson"], + "use": "Ally", + "period": {"start": "2020-01-01"}, + } + ], + "gender": "female", + "birthDate": "1990-05-15", + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + {"value": "9999999999", "system": "urn:nhs:numbers"} + ], + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], + }, + } + ], + } + + +@pytest.fixture +def valid_headers() -> dict[str, str]: + return { + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + "Content-type": "application/fhir+json", + } + + +@pytest.fixture +def happy_path_pds_response_body() -> Patient: + return { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [{"value": "9999999999", "system": "urn:nhs:numbers"}], + "name": [ + { + "family": "Johnson", + "given": ["Alice"], + "use": "Ally", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + } + ], + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], + "gender": "female", + "birthDate": "1990-05-15", + } + + +@pytest.fixture +def auth_token() -> str: + return "AUTH_TOKEN123" diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 4a17d08c..de038690 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,110 +2,22 @@ Controller layer for orchestrating calls to external services """ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -from gateway_api.provider_request import GpProviderClient - -if TYPE_CHECKING: - from gateway_api.get_structured_record.request import GetStructuredRecordRequest - -__all__ = ["json"] # Make mypy happy in tests - -from dataclasses import dataclass - from gateway_api.common.common import FlaskResponse -from gateway_api.pds_search import PdsClient, PdsSearchResults - - -@dataclass -class RequestError(Exception): - """ - Raised (and handled) when there is a problem with the incoming request. - - Instances of this exception are caught by controller entry points and converted - into an appropriate :class:`FlaskResponse`. - - :param status_code: HTTP status code that should be returned. - :param message: Human-readable error message. - """ - - status_code: int - message: str - - def __str__(self) -> str: - """ - Coercing this exception to a string returns the error message. - - :returns: The error message. - """ - return self.message - - -@dataclass -class SdsSearchResults: - """ - Stub SDS search results dataclass. - - Replace this with the real one once it's implemented. - - :param asid: Accredited System ID. - :param endpoint: Endpoint URL associated with the organisation, if applicable. - """ - - asid: str - endpoint: str | None - - -class SdsClient: - """ - Stub SDS client for obtaining ASID from ODS code. - - Replace this with the real one once it's implemented. - """ - - SANDBOX_URL = "https://example.invalid/sds" - - def __init__( - self, - auth_token: str, - base_url: str = SANDBOX_URL, - timeout: int = 10, - ) -> None: - """ - Create an SDS client. - - :param auth_token: Authentication token to present to SDS. - :param base_url: Base URL for SDS. - :param timeout: Timeout in seconds for SDS calls. - """ - self.auth_token = auth_token - self.base_url = base_url - self.timeout = timeout - - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - """ - Retrieve SDS org details for a given ODS code. - - This is a placeholder implementation that always returns an ASID and endpoint. - - :param ods_code: ODS code to look up. - :returns: SDS search results or ``None`` if not found. - """ - # Placeholder implementation - return SdsSearchResults( - asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" - ) +from gateway_api.common.error import ( + NoAsidFound, + NoCurrentEndpoint, + NoCurrentProvider, + NoOrganisationFound, +) +from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.pds import PdsClient, PdsSearchResults +from gateway_api.provider import GpProviderClient +from gateway_api.sds import SdsClient, SdsSearchResults class Controller: """ Orchestrates calls to PDS -> SDS -> GP provider. - - Entry point: - - ``call_gp_provider(request_body_json, headers, auth_token) -> FlaskResponse`` """ gp_provider_client: GpProviderClient | None @@ -113,21 +25,14 @@ class Controller: def __init__( self, pds_base_url: str = PdsClient.SANDBOX_URL, - sds_base_url: str = "https://example.invalid/sds", - nhsd_session_urid: str | None = None, + sds_base_url: str = SdsClient.SANDBOX_URL, timeout: int = 10, ) -> None: """ Create a controller instance. - - :param pds_base_url: Base URL for PDS client. - :param sds_base_url: Base URL for SDS client. - :param nhsd_session_urid: Session URID for NHS Digital session handling. - :param timeout: Timeout in seconds for downstream calls. """ self.pds_base_url = pds_base_url self.sds_base_url = sds_base_url - self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout self.gp_provider_client = None @@ -143,26 +48,14 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. 3) Call SDS using consumer ODS to obtain consumer ASID. 4) Call GP provider to obtain patient records. - - :param request: A GetStructuredRecordRequest instance. - :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the - outcome. """ auth_token = self.get_auth_token() - try: - provider_ods = self._get_pds_details( - auth_token, request.ods_from.strip(), request.nhs_number - ) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) + provider_ods = self._get_pds_details(auth_token, request.nhs_number) - try: - consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( - auth_token, request.ods_from.strip(), provider_ods - ) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + request.ods_from.strip(), provider_ods + ) # Call GP provider with correct parameters self.gp_provider_client = GpProviderClient( @@ -176,13 +69,10 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: body=request.request_body, ) - # If we get a None from the GP provider, that means that either the service did - # not respond or we didn't make the request to the service in the first place. - # Therefore a None is a 502, any real response just pass straight back. return FlaskResponse( - status_code=response.status_code if response is not None else 502, - data=response.text if response is not None else "GP provider service error", - headers=dict(response.headers) if response is not None else None, + status_code=response.status_code, + data=response.text, + headers=dict(response.headers), ) def get_auth_token(self) -> str: @@ -191,59 +81,30 @@ def get_auth_token(self) -> str: This is a placeholder implementation. Replace with actual logic to obtain the auth token as needed. - - :returns: Authorization token as a string. """ - # Placeholder implementation - return "PLACEHOLDER_AUTH_TOKEN" + return "AUTH_TOKEN123" - def _get_pds_details( - self, auth_token: str, consumer_ods: str, nhs_number: str - ) -> str: + def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. - - :param auth_token: Authorization token to use for PDS. - :param consumer_ods: Consumer organisation ODS code (from request headers). - :param nhs_number: NHS number - :returns: Provider ODS code (GP ODS code). - :raises RequestError: If the patient cannot be found or has no provider ODS code """ # PDS: find patient and extract GP ODS code (provider ODS) pds = PdsClient( auth_token=auth_token, - end_user_org_ods=consumer_ods, base_url=self.pds_base_url, - nhsd_session_urid=self.nhsd_session_urid, timeout=self.timeout, ignore_dates=True, ) - pds_result: PdsSearchResults | None = pds.search_patient_by_nhs_number( - nhs_number - ) - - if pds_result is None: - raise RequestError( - status_code=404, - message=f"No PDS patient found for NHS number {nhs_number}", - ) + pds_result: PdsSearchResults = pds.search_patient_by_nhs_number(nhs_number) - if pds_result.gp_ods_code: - provider_ods_code = pds_result.gp_ods_code - else: - raise RequestError( - status_code=404, - message=( - f"PDS patient {nhs_number} did not contain a current " - "provider ODS code" - ), - ) + if not pds_result.gp_ods_code: + raise NoCurrentProvider(nhs_number=nhs_number) - return provider_ods_code + return pds_result.gp_ods_code def _get_sds_details( - self, auth_token: str, consumer_ods: str, provider_ods: str + self, consumer_ods: str, provider_ods: str ) -> tuple[str, str, str]: """ Call SDS to obtain consumer ASID, provider ASID, and provider endpoint. @@ -251,63 +112,36 @@ def _get_sds_details( This method performs two SDS lookups: - provider details (ASID + endpoint) - consumer details (ASID) - - :param auth_token: Authorization token to use for SDS. - :param consumer_ods: Consumer organisation ODS code (from request headers). - :param provider_ods: Provider organisation ODS code (from PDS). - :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). - :raises RequestError: If SDS data is missing or incomplete for provider/consumer """ # SDS: Get provider details (ASID + endpoint) for provider ODS sds = SdsClient( - auth_token=auth_token, base_url=self.sds_base_url, timeout=self.timeout, ) - provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) + provider_details: SdsSearchResults | None = sds.get_org_details( + provider_ods, get_endpoint=True + ) if provider_details is None: - raise RequestError( - status_code=404, - message=f"No SDS org found for provider ODS code {provider_ods}", - ) + raise NoOrganisationFound(org_type="provider", ods_code=provider_ods) provider_asid = (provider_details.asid or "").strip() if not provider_asid: - raise RequestError( - status_code=404, - message=( - f"SDS result for provider ODS code {provider_ods} did not contain " - "a current ASID" - ), - ) + raise NoAsidFound(org_type="provider", ods_code=provider_ods) provider_endpoint = (provider_details.endpoint or "").strip() if not provider_endpoint: - raise RequestError( - status_code=404, - message=( - f"SDS result for provider ODS code {provider_ods} did not contain " - "a current endpoint" - ), - ) + raise NoCurrentEndpoint(provider_ods=provider_ods) # SDS: Get consumer details (ASID) for consumer ODS - consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) + consumer_details: SdsSearchResults | None = sds.get_org_details( + consumer_ods, get_endpoint=False + ) if consumer_details is None: - raise RequestError( - status_code=404, - message=f"No SDS org found for consumer ODS code {consumer_ods}", - ) + raise NoOrganisationFound(org_type="consumer", ods_code=consumer_ods) consumer_asid = (consumer_details.asid or "").strip() if not consumer_asid: - raise RequestError( - status_code=404, - message=( - f"SDS result for consumer ODS code {consumer_ods} did not contain " - "a current ASID" - ), - ) + raise NoAsidFound(org_type="consumer", ods_code=consumer_ods) return consumer_asid, provider_asid, provider_endpoint diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py index 56dd174d..e665e366 100644 --- a/gateway-api/src/gateway_api/get_structured_record/__init__.py +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -1,8 +1,11 @@ """Get Structured Record module.""" from gateway_api.get_structured_record.request import ( + ACCESS_RECORD_STRUCTURED_INTERACTION_ID, GetStructuredRecordRequest, - RequestValidationError, ) -__all__ = ["RequestValidationError", "GetStructuredRecordRequest"] +__all__ = [ + "GetStructuredRecordRequest", + "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 c4279272..f1f7fba0 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -4,30 +4,37 @@ from fhir import OperationOutcome, Parameters from fhir.operation_outcome import OperationOutcomeIssue from flask.wrappers import Request, Response +from werkzeug.exceptions import BadRequest from gateway_api.common.common import FlaskResponse +from gateway_api.common.error import InvalidRequestJSON, MissingOrEmptyHeader if TYPE_CHECKING: from fhir.bundle import Bundle - -class RequestValidationError(Exception): - """Exception raised for errors in the request validation.""" +# Access record structured interaction ID from +# https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions +ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( + "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" +) class GetStructuredRecordRequest: - INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" + INTERACTION_ID: str = ACCESS_RECORD_STRUCTURED_INTERACTION_ID RESOURCE: str = "patient" FHIR_OPERATION: str = "$gpc.getstructuredrecord" def __init__(self, request: Request) -> None: self._http_request = request self._headers = request.headers - self._request_body: Parameters = request.get_json() + try: + self._request_body: Parameters = request.get_json() + except BadRequest as error: + raise InvalidRequestJSON() from error + self._response_body: Bundle | OperationOutcome | None = None self._status_code: int | None = None - # Validate required headers self._validate_headers() @property @@ -50,19 +57,13 @@ def request_body(self) -> str: return json.dumps(self._request_body) def _validate_headers(self) -> None: - """Validate required headers are present and non-empty. - - :raises RequestValidationError: If required headers are missing or empty. - """ trace_id = self._headers.get("Ssp-TraceID", "").strip() if not trace_id: - raise RequestValidationError( - 'Missing or empty required header "Ssp-TraceID"' - ) + raise MissingOrEmptyHeader(header="Ssp-TraceID") ods_from = self._headers.get("ODS-from", "").strip() if not ods_from: - raise RequestValidationError('Missing or empty required header "ODS-from"') + raise MissingOrEmptyHeader(header="ODS-from") def build_response(self) -> Response: return Response( 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 6fa5f9a2..fa05e757 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 @@ -4,29 +4,16 @@ import pytest from fhir.parameters import Parameters from flask import Request -from werkzeug.test import EnvironBuilder from gateway_api.common.common import FlaskResponse -from gateway_api.get_structured_record import RequestValidationError +from gateway_api.common.error import BaseError +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 -def create_mock_request(headers: dict[str, str], body: Parameters) -> Request: - """Create a proper Flask Request object with headers and JSON body.""" - builder = EnvironBuilder( - method="POST", - path="/patient/$gpc.getstructuredrecord", - data=json.dumps(body), - content_type="application/fhir+json", - headers=headers, - ) - env = builder.get_environ() - return Request(env) - - @pytest.fixture def mock_request_with_headers(valid_simple_request_payload: Parameters) -> Request: headers = { @@ -80,7 +67,7 @@ def test_raises_value_error_when_ods_from_header_is_missing( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, match='Missing or empty required header "ODS-from"' + BaseError, match='Missing or empty required header "ODS-from"' ): GetStructuredRecordRequest(request=mock_request) @@ -97,7 +84,7 @@ def test_raises_value_error_when_ods_from_header_is_whitespace( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, match='Missing or empty required header "ODS-from"' + BaseError, match='Missing or empty required header "ODS-from"' ): GetStructuredRecordRequest(request=mock_request) @@ -111,7 +98,7 @@ def test_raises_value_error_when_trace_id_header_is_missing( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, + BaseError, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) @@ -129,7 +116,7 @@ def test_raises_value_error_when_trace_id_header_is_whitespace( mock_request = create_mock_request(headers, valid_simple_request_payload) with pytest.raises( - RequestValidationError, + BaseError, match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) diff --git a/gateway-api/src/gateway_api/pds/__init__.py b/gateway-api/src/gateway_api/pds/__init__.py new file mode 100644 index 00000000..7c687699 --- /dev/null +++ b/gateway-api/src/gateway_api/pds/__init__.py @@ -0,0 +1,9 @@ +"""PDS (Personal Demographics Service) client and data structures.""" + +from gateway_api.pds.client import PdsClient +from gateway_api.pds.search_results import PdsSearchResults + +__all__ = [ + "PdsClient", + "PdsSearchResults", +] diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py new file mode 100644 index 00000000..65d00c70 --- /dev/null +++ b/gateway-api/src/gateway_api/pds/client.py @@ -0,0 +1,264 @@ +""" +PDS (Personal Demographics Service) FHIR R4 patient lookup client. + +Contracts enforced by the helper functions: + +* ``Patient.name[]`` records passed to :func:`find_current_name_record` must contain:: + + record["period"]["start"] + record["period"]["end"] + +* ``Patient.generalPractitioner[]`` records passed to :func:`find_current_record` must + contain:: + + record["identifier"]["period"]["start"] + record["identifier"]["period"]["end"] + +If required keys are missing, a ``KeyError`` is raised intentionally. This is treated as +malformed upstream data (or malformed test fixtures) and should be corrected at source. +""" + +import os +import uuid +from datetime import date, datetime, timezone +from typing import cast + +import requests +from fhir import Bundle, BundleEntry, GeneralPractitioner, HumanName, Patient + +from gateway_api.common.error import PdsRequestFailed +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 +# use the stub client +STUB_PDS = os.environ.get("STUB_PDS", "false").lower() == "true" +if not STUB_PDS: + post = requests.post +else: + from stubs.pds.stub import PdsFhirApiStub + + pds = PdsFhirApiStub() + post = pds.post # type: ignore + + +class PdsClient: + """ + Simple client for PDS FHIR R4 patient retrieval. + + The client currently supports one operation: + + * :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``. + + **Usage example**:: + + pds = PdsClient( + auth_token="YOUR_ACCESS_TOKEN", + base_url="https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4", + ) + + result = pds.search_patient_by_nhs_number(9000000009) + + if result: + print(result) + """ + + # URLs for different PDS environments. Requires authentication to use live. + SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4" + INT_URL = "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" + PROD_URL = "https://api.service.nhs.uk/personal-demographics/FHIR/R4" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ignore_dates: bool = False, + ) -> None: + self.auth_token = auth_token + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.ignore_dates = ignore_dates + + def _build_headers( + self, + request_id: str | None = None, + correlation_id: str | None = None, + ) -> dict[str, str]: + """ + Build mandatory and optional headers for a PDS request. + """ + headers = { + "X-Request-ID": request_id or str(uuid.uuid4()), + "Accept": "application/fhir+json", + "Authorization": f"Bearer {self.auth_token}", + } + + if correlation_id: + headers["X-Correlation-ID"] = correlation_id + + return headers + + def search_patient_by_nhs_number( + self, + nhs_number: str, + request_id: str | None = None, + correlation_id: str | None = None, + timeout: int | None = None, + ) -> PdsSearchResults: + """ + 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`. + """ + headers = self._build_headers( + request_id=request_id, + correlation_id=correlation_id, + ) + + url = f"{self.base_url}/Patient/{nhs_number}" + + # This normally calls requests.get, but if STUB_PDS is set it uses the stub. + response = post( + url, # TODO: URL points to sandbox env even when STUB_PDS + # is true, should we change this to point to the stub instead? + headers=headers, + params={}, + timeout=timeout or self.timeout, + ) + + try: + response.raise_for_status() + except requests.HTTPError as err: + raise PdsRequestFailed(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 RuntimeError("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 = cast("Patient", entry.get("resource", {})) + + nhs_number = str(patient.get("id", "")).strip() + if not nhs_number: + raise RuntimeError("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, + ) + + def find_current_gp( + self, + general_practitioners: list[GeneralPractitioner], + today: date | None = None, + ) -> GeneralPractitioner | None: + if today is None: + today = datetime.now(timezone.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(timezone.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 diff --git a/gateway-api/src/gateway_api/pds/search_results.py b/gateway-api/src/gateway_api/pds/search_results.py new file mode 100644 index 00000000..331a476d --- /dev/null +++ b/gateway-api/src/gateway_api/pds/search_results.py @@ -0,0 +1,18 @@ +"""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 new file mode 100644 index 00000000..d255135a --- /dev/null +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -0,0 +1,368 @@ +""" +Unit tests for :mod:`gateway_api.pds_search`. +""" + +from datetime import date +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +from fhir import Patient +from pytest_mock import MockerFixture + +from gateway_api.common.error import PdsRequestFailed +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, +) -> None: + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body + ) + mocker.patch("gateway_api.pds.client.post", return_value=happy_path_response) + + 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 == "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, +) -> None: + gp_less_response_body = happy_path_pds_response_body.copy() + del gp_less_response_body["generalPractitioner"] + gp_less_response = FakeResponse( + status_code=200, headers={}, _json=gp_less_response_body + ) + mocker.patch("gateway_api.pds.client.post", return_value=gp_less_response) + + 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 is None + + +def test_search_patient_by_nhs_number_sends_expected_headers( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body + ) + mocked_post = mocker.patch( + "gateway_api.pds.client.post", return_value=happy_path_response + ) + + request_id = str(uuid4()) + correlation_id = "corr-123" + + client = PdsClient(auth_token) + _ = client.search_patient_by_nhs_number( + "9000000009", + request_id=request_id, + correlation_id=correlation_id, + ) + + expected_headers = { + "Authorization": f"Bearer {auth_token}", + "Accept": "application/fhir+json", + "X-Request-ID": request_id, + "X-Correlation-ID": correlation_id, + } + + assert mocked_post.call_args.kwargs["headers"] == expected_headers + + +def test_search_patient_by_nhs_number_generates_request_id( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + happy_path_response = FakeResponse( + status_code=200, headers={}, _json=happy_path_pds_response_body + ) + mocked_post = mocker.patch( + "gateway_api.pds.client.post", return_value=happy_path_response + ) + + client = PdsClient(auth_token) + + _ = client.search_patient_by_nhs_number("9000000009") + + try: + _ = UUID(mocked_post.call_args.kwargs["headers"]["X-Request-ID"], version=4) + except ValueError: + pytest.fail("X-Request-ID is not a valid UUID4") + + +def test_search_patient_by_nhs_number_not_found_raises_error( + auth_token: str, + mocker: MockerFixture, +) -> None: + not_found_response = FakeResponse( + status_code=404, + headers={}, + _json={"resourceType": "OperationOutcome", "issue": []}, + reason="Not Found", + ) + mocker.patch("gateway_api.pds.client.post", return_value=not_found_response) + pds = PdsClient(auth_token) + + with pytest.raises( + PdsRequestFailed, match="PDS FHIR API request failed: Not Found" + ): + pds.search_patient_by_nhs_number("9900000001") + + +def test_search_patient_by_nhs_number_finds_current_gp_ods_code_when_pds_returns_two( + auth_token: str, + mocker: MockerFixture, + happy_path_pds_response_body: Patient, +) -> None: + old_gp: GeneralPractitioner = { + "id": "1", + "type": "Organization", + "identifier": { + "value": "OLDGP", + "period": {"start": "2010-01-01", "end": "2012-01-01"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + current_gp: GeneralPractitioner = { + "id": "2", + "type": "Organization", + "identifier": { + "value": "CURRGP", + "period": {"start": "2020-01-01", "end": "9999-01-01"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-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.post", 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": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + }, + { + "id": "abcd", + "type": "Organization", + "identifier": { + "value": "b", + "period": {"start": "2021-01-01", "end": "2021-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-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_invalid_body_raises_runtime_error() -> None: + """ + Verify that ``PdsClient._extract_single_search_result`` raises ``RuntimeError`` when + mandatory patient content is missing. + + This test asserts that a ``RuntimeError`` 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* + """ + client = PdsClient( + auth_token="test-token", # noqa: S106 (test token hardcoded) + base_url="https://example.test/personal-demographics/FHIR/R4", + ) + + # 1) Bundle contains no entries. + bundle_no_entries: Any = {"resourceType": "Bundle", "entry": []} + with pytest.raises(RuntimeError): + 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(RuntimeError): + 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": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + }, + { + "id": "1234", + "type": "Organization", + "identifier": { + "value": "GP-NEWER", + "period": {"start": "1901-01-01", "end": "1901-12-31"}, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + }, + ] + + # 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] + + assert pds_ignore.find_current_gp([]) is None diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py deleted file mode 100644 index b21b6ecf..00000000 --- a/gateway-api/src/gateway_api/pds_search.py +++ /dev/null @@ -1,407 +0,0 @@ -""" -PDS (Personal Demographics Service) FHIR R4 patient lookup client. - -Contracts enforced by the helper functions: - -* ``Patient.name[]`` records passed to :func:`find_current_name_record` must contain:: - - record["period"]["start"] - record["period"]["end"] - -* ``Patient.generalPractitioner[]`` records passed to :func:`find_current_record` must - contain:: - - record["identifier"]["period"]["start"] - record["identifier"]["period"]["end"] - -If required keys are missing, a ``KeyError`` is raised intentionally. This is treated as -malformed upstream data (or malformed test fixtures) and should be corrected at source. -""" - -from __future__ import annotations - -import uuid -from collections.abc import Callable -from dataclasses import dataclass -from datetime import date, datetime, timezone -from typing import cast - -import requests -from stubs.stub_pds import PdsFhirApiStub - -# Recursive JSON-like structure typing used for parsed FHIR bodies. -type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] -type ResultStructureDict = dict[str, ResultStructure] -type ResultList = list[ResultStructureDict] - -# Type for stub get method -type GetCallable = Callable[..., requests.Response] - - -class ExternalServiceError(Exception): - """ - Raised when the downstream PDS request fails. - - This module catches :class:`requests.HTTPError` thrown by - ``response.raise_for_status()`` and re-raises it as ``ExternalServiceError`` so - callers are not coupled to ``requests`` exception types. - """ - - -@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. - - :param given_names: Given names from the *current* ``Patient.name`` record, - concatenated with spaces. - :param family_name: Family name from the *current* ``Patient.name`` record. - :param nhs_number: NHS number (``Patient.id``). - :param gp_ods_code: The ODS code of the *current* GP, extracted from - ``Patient.generalPractitioner[].identifier.value`` if a current GP record exists - otherwise ``None``. - """ - - given_names: str - family_name: str - nhs_number: str - gp_ods_code: str | None - - -class PdsClient: - """ - Simple client for PDS FHIR R4 patient retrieval. - - The client currently supports one operation: - - * :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``. - - **Usage example**:: - - pds = PdsClient( - auth_token="YOUR_ACCESS_TOKEN", - end_user_org_ods="A12345", - base_url="https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4", - ) - - result = pds.search_patient_by_nhs_number(9000000009) - - if result: - print(result) - """ - - # URLs for different PDS environments. Requires authentication to use live. - SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4" - INT_URL = "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" - PROD_URL = "https://api.service.nhs.uk/personal-demographics/FHIR/R4" - - def __init__( - self, - auth_token: str, - end_user_org_ods: str, - base_url: str = SANDBOX_URL, - nhsd_session_urid: str | None = None, - timeout: int = 10, - ignore_dates: bool = False, - ) -> None: - """ - Create a PDS client. - - :param auth_token: OAuth2 bearer token (without the ``"Bearer "`` prefix). - :param end_user_org_ods: NHSD End User Organisation ODS code. - :param base_url: Base URL for the PDS API (one of :attr:`SANDBOX_URL`, - :attr:`INT_URL`, :attr:`PROD_URL`). Trailing slashes are stripped. - :param nhsd_session_urid: Optional ``NHSD-Session-URID`` header value. - :param timeout: Default timeout in seconds for HTTP calls. - :param ignore_dates: If ``True`` just get the most recent name or GP record, - ignoring the date ranges. - """ - self.auth_token = auth_token - self.end_user_org_ods = end_user_org_ods - self.base_url = base_url.rstrip("/") - self.nhsd_session_urid = nhsd_session_urid - self.timeout = timeout - self.ignore_dates = ignore_dates - self.stub = PdsFhirApiStub() - - # TODO: Put this back to using the environment variable - # if os.environ.get("STUB_PDS", None): - self.get_method: GetCallable = self.stub.get - # else: - # self.get_method: GetCallable = requests.get - - def _build_headers( - self, - request_id: str | None = None, - correlation_id: str | None = None, - ) -> dict[str, str]: - """ - Build mandatory and optional headers for a PDS request. - - :param request_id: Optional ``X-Request-ID``. If not supplied a new UUID is - generated. - :param correlation_id: Optional ``X-Correlation-ID`` for cross-system tracing. - :return: Dictionary of HTTP headers for the outbound request. - """ - headers = { - "X-Request-ID": request_id or str(uuid.uuid4()), - "NHSD-End-User-Organisation-ODS": self.end_user_org_ods, - "Accept": "application/fhir+json", - } - - # Trying to pass an auth token to the sandbox makes PDS unhappy - if self.base_url != self.SANDBOX_URL: - headers["Authorization"] = f"Bearer {self.auth_token}" - - # NHSD-Session-URID is required in some flows; include only if configured. - if self.nhsd_session_urid: - headers["NHSD-Session-URID"] = self.nhsd_session_urid - - # Correlation ID is used to track the same request across multiple systems. - # Can be safely omitted, mirrored back in response if included. - if correlation_id: - headers["X-Correlation-ID"] = correlation_id - - return headers - - def search_patient_by_nhs_number( - self, - nhs_number: str, - request_id: str | None = None, - correlation_id: str | None = None, - timeout: int | None = None, - ) -> PdsSearchResults | None: - """ - Retrieve a patient by NHS number. - - Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`PdsSearchResults`. - - :param nhs_number: NHS number to search for. - :param request_id: Optional request ID to reuse for retries; if not supplied a - UUID is generated. - :param correlation_id: Optional correlation ID for tracing. - :param timeout: Optional per-call timeout in seconds. If not provided, - :attr:`timeout` is used. - :return: A :class:`PdsSearchResults` instance if a patient can be extracted, - otherwise ``None``. - :raises ExternalServiceError: If the HTTP request returns an error status and - ``raise_for_status()`` raises :class:`requests.HTTPError`. - """ - headers = self._build_headers( - request_id=request_id, - correlation_id=correlation_id, - ) - - url = f"{self.base_url}/Patient/{nhs_number}" - - # This normally calls requests.get, but if STUB_PDS is set it uses the stub. - response = self.get_method( - url, - headers=headers, - params={}, - timeout=timeout or self.timeout, - ) - - try: - # In production, failures surface here (4xx/5xx -> HTTPError). - response.raise_for_status() - except requests.HTTPError as err: - raise ExternalServiceError( - f"PDS request failed: {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: ResultList) -> 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. - - :param general_practitioners: List of ``generalPractitioner`` records from a - Patient resource. - :return: ODS code string if a current record exists, otherwise ``None``. - :raises KeyError: If a record is missing required ``identifier.period`` fields. - """ - if len(general_practitioners) == 0: - return None - - gp = self.find_current_gp(general_practitioners) - if gp is None: - return None - - identifier = cast("ResultStructureDict", gp.get("identifier", {})) - ods_code = str(identifier.get("value", None)) - - # Avoid returning the literal string "None" if identifier.value is absent. - return None if ods_code == "None" else ods_code - - def _extract_single_search_result( - self, body: ResultStructureDict - ) -> PdsSearchResults | None: - """ - 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. - :param body: Parsed JSON body containing either a Patient resource or a Bundle - whose first entry contains a Patient resource under ``resource``. - :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise - ``None``. - """ - # Accept either: - # 1) Patient (GET /Patient/{id}) - # 2) Bundle with Patient in entry[0].resource (search endpoints) - if str(body.get("resourceType", "")) == "Patient": - patient = body - else: - entries: ResultList = cast("ResultList", body.get("entry", [])) - if not entries: - raise RuntimeError("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 = cast("ResultStructureDict", entry.get("resource", {})) - - nhs_number = str(patient.get("id", "")).strip() - if not nhs_number: - raise RuntimeError("PDS patient resource missing NHS number") - - # Select current name record and extract names. - names = cast("ResultList", patient.get("name", [])) - current_name = self.find_current_name_record(names) - - if current_name is not None: - given_names_list = cast("list[str]", current_name.get("given", [])) - family_name = str(current_name.get("family", "")) or "" - given_names_str = " ".join(given_names_list).strip() - else: - given_names_str = "" - family_name = "" - - # Extract GP ODS code if a current GP record exists. - gp_list = cast("ResultList", patient.get("generalPractitioner", [])) - gp_ods_code = self._get_gp_ods_code(gp_list) - - return PdsSearchResults( - given_names=given_names_str, - family_name=family_name, - nhs_number=nhs_number, - gp_ods_code=gp_ods_code, - ) - - def find_current_gp( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: - """ - Select the current record from a ``generalPractitioner`` list. - - A record is "current" if its ``identifier.period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - The list may be in any of the following states: - - * empty - * contains one or more records, none current - * contains one or more records, exactly one current - - :param records: List of ``generalPractitioner`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first record whose ``identifier.period`` covers ``today``, or - ``None`` if no record is current. - :raises KeyError: If required keys are missing for a record being evaluated. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ - if today is None: - today = datetime.now(timezone.utc).date() - - if self.ignore_dates: - if len(records) > 0: - return records[-1] - else: - return None - - for record in records: - identifier = cast("ResultStructureDict", record["identifier"]) - periods = cast("dict[str, str]", identifier["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - - if start <= today <= end: - return record - - return None - - def find_current_name_record( - self, records: ResultList, today: date | None = None - ) -> ResultStructureDict | None: - """ - Select the current record from a ``Patient.name`` list. - - A record is "current" if its ``period`` covers ``today`` (inclusive): - - ``start <= today <= end`` - - Or else if self.ignore_dates is True, the last record in the list is returned. - - :param records: List of ``Patient.name`` records. - :param today: Optional override date, intended for deterministic tests. - If not supplied, the current UTC date is used. - :return: The first name record whose ``period`` covers ``today``, or ``None`` if - no record is current. - :raises KeyError: If required keys (``period.start`` / ``period.end``) are - missing. - :raises ValueError: If ``start`` or ``end`` are not valid ISO date strings. - """ - if today is None: - today = datetime.now(timezone.utc).date() - - if self.ignore_dates: - if len(records) > 0: - return records[-1] - else: - return None - - for record in records: - periods = cast("dict[str, str]", record["period"]) - start_str = periods["start"] - end_str = periods["end"] - - start = date.fromisoformat(start_str) - end = date.fromisoformat(end_str) - - if start <= today <= end: - return record - - return None diff --git a/gateway-api/src/gateway_api/provider/__init__.py b/gateway-api/src/gateway_api/provider/__init__.py new file mode 100644 index 00000000..1cc394f9 --- /dev/null +++ b/gateway-api/src/gateway_api/provider/__init__.py @@ -0,0 +1,7 @@ +"""Provider client for fetching structured patient records from GP systems.""" + +from gateway_api.provider.client import GpProviderClient + +__all__ = [ + "GpProviderClient", +] diff --git a/gateway-api/src/gateway_api/provider_request.py b/gateway-api/src/gateway_api/provider/client.py similarity index 64% rename from gateway-api/src/gateway_api/provider_request.py rename to gateway-api/src/gateway_api/provider/client.py index a628dbcf..d1d1c32e 100644 --- a/gateway-api/src/gateway_api/provider_request.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -22,35 +22,31 @@ The response from the provider FHIR API. """ -from collections.abc import Callable +import os from urllib.parse import urljoin -from requests import HTTPError, Response, post -from stubs.stub_provider import stub_post +from requests import HTTPError, Response + +from gateway_api.common.error import ProviderRequestFailed +from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID + +# TODO: Once stub servers/containers made for PDS, SDS and provider +# we should remove the STUB_PROVIDER environment variable and just +# use the stub client +STUB_PROVIDER = os.environ.get("STUB_PROVIDER", "false").lower() == "true" +if not STUB_PROVIDER: + from requests import post +else: + from stubs.provider.stub import GpProviderStub + + provider_stub = GpProviderStub() + post = provider_stub.post # type: ignore -ARS_INTERACTION_ID = ( - "urn:nhs:names:services:gpconnect:structured" - ":fhir:operation:gpc.getstructuredrecord-1" -) ARS_FHIR_BASE = "FHIR/STU3" FHIR_RESOURCE = "patient" ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" TIMEOUT: int | None = None # None used for quicker dev, adjust as needed -# TODO: Put the environment variable check back in -# if os.environ.get("STUB_PROVIDER", None): -if True: # NOSONAR S5797 (Yes, I know it's always true, this is temporary) - # Direct all requests to the stub provider for steel threading in dev. - # Replace with `from requests import post` for real requests. - PostCallable = Callable[..., Response] - post: PostCallable = stub_post # type: ignore[no-redef] - - -class ExternalServiceError(Exception): - """ - Exception raised when the downstream GPProvider FHIR API request fails. - """ - class GpProviderClient: """ @@ -82,19 +78,11 @@ def __init__( def _build_headers(self, trace_id: str) -> dict[str, str]: """ Build the headers required for the GPProvider FHIR API request. - - Args: - trace_id (str): A unique identifier for the request. - - Returns: - dict[str, str]: A dictionary containing the headers for the request, - including content type, interaction ID, and ASIDs for the provider - and consumer. """ return { "Content-Type": "application/fhir+json", "Accept": "application/fhir+json", - "Ssp-InteractionID": ARS_INTERACTION_ID, + "Ssp-InteractionID": ACCESS_RECORD_STRUCTURED_INTERACTION_ID, "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, "Ssp-TraceID": trace_id, @@ -107,16 +95,6 @@ def access_structured_record( ) -> Response: """ Fetch a structured patient record from the GPProvider FHIR API. - - Args: - trace_id (str): A unique identifier for the request, passed in the headers. - body (str): The request body in FHIR format. - - Returns: - Response: The response from the GPProvider FHIR API. - - Raises: - ExternalServiceError: If the API request fails with an HTTP error. """ headers = self._build_headers(trace_id) @@ -134,8 +112,6 @@ def access_structured_record( try: response.raise_for_status() except HTTPError as err: - raise ExternalServiceError( - f"GPProvider FHIR API request failed:{err.response.reason}" - ) from err + raise ProviderRequestFailed(error_reason=err.response.reason) from err return response diff --git a/gateway-api/src/gateway_api/test_provider_request.py b/gateway-api/src/gateway_api/provider/test_client.py similarity index 82% rename from gateway-api/src/gateway_api/test_provider_request.py rename to gateway-api/src/gateway_api/provider/test_client.py index 6441490a..49bc164b 100644 --- a/gateway-api/src/gateway_api/test_provider_request.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -6,20 +6,17 @@ """ +import json from typing import Any import pytest +from fhir import Parameters from requests import Response from requests.structures import CaseInsensitiveDict -from stubs.stub_provider import GpProviderStub +from stubs.provider.stub import GpProviderStub -from gateway_api import provider_request -from gateway_api.provider_request import ExternalServiceError, GpProviderClient - -ars_interactionId = ( - "urn:nhs:names:services:gpconnect:structured" - ":fhir:operation:gpc.getstructuredrecord-1" -) +from gateway_api.common.error import ProviderRequestFailed +from gateway_api.provider import GpProviderClient, client @pytest.fixture @@ -37,9 +34,6 @@ def mock_request_post( This fixture intercepts calls to `requests.post` and routes them to the stub provider. It also captures the most recent request details, such as headers, body, and URL, for verification in tests. - - Returns: - dict[str, Any]: A dictionary containing the captured request details. """ capture: dict[str, Any] = {} @@ -60,12 +54,13 @@ def _fake_post( trace_id=headers.get("Ssp-TraceID", "dummy-trace-id"), body=data ) - monkeypatch.setattr(provider_request, "post", _fake_post) + monkeypatch.setattr(client, "post", _fake_post) return capture def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method constructs the correct URL @@ -85,7 +80,9 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos consumer_asid=consumer_asid, ) - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) captured_url = mock_request_post.get("url", provider_endpoint) @@ -98,6 +95,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, ) -> None: """ Test that the `access_structured_record` method includes the correct headers @@ -123,10 +121,14 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-TraceID": str(trace_id), "Ssp-From": consumer_asid, "Ssp-To": provider_asid, - "Ssp-InteractionID": ars_interactionId, + "Ssp-InteractionID": ( + "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" + ), } - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) captured_headers = mock_request_post["headers"] @@ -136,6 +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, ) -> None: """ Test that the `access_structured_record` method includes the correct body @@ -149,7 +152,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( provider_endpoint = "https://test.com" trace_id = "some_uuid_value" - request_body = "some_FHIR_request_params" + request_body = json.dumps(valid_simple_request_payload) client = GpProviderClient( provider_endpoint=provider_endpoint, @@ -168,6 +171,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, + valid_simple_request_payload: Parameters, ) -> None: """ Test that the `access_structured_record` method returns the same response @@ -187,9 +191,13 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( consumer_asid=consumer_asid, ) - expected_response = stub.access_record_structured(trace_id, "body") + expected_response = stub.access_record_structured( + trace_id, json.dumps(valid_simple_request_payload) + ) - result = client.access_structured_record(trace_id, "body") + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) assert result.status_code == 200 assert result.content == expected_response.content @@ -199,7 +207,7 @@ def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) ) -> None: """ - Test that the `access_structured_record` method raises an `ExternalServiceError` + Test that the `access_structured_record` method raises an `SdsRequestFailed` when the GPProvider FHIR API request fails with an HTTP error. """ provider_asid = "200000001154" @@ -214,7 +222,7 @@ def test_access_structured_record_raises_external_service_error( ) with pytest.raises( - ExternalServiceError, - match="GPProvider FHIR API request failed:Bad Request", + ProviderRequestFailed, + match="Provider request failed: Bad Request", ): client.access_structured_record(trace_id, "body") diff --git a/gateway-api/src/gateway_api/sds/__init__.py b/gateway-api/src/gateway_api/sds/__init__.py new file mode 100644 index 00000000..8f6e5ec0 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/__init__.py @@ -0,0 +1,7 @@ +from gateway_api.sds.client import SdsClient +from gateway_api.sds.search_results import SdsSearchResults + +__all__ = [ + "SdsClient", + "SdsSearchResults", +] diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py new file mode 100644 index 00000000..0331105c --- /dev/null +++ b/gateway-api/src/gateway_api/sds/client.py @@ -0,0 +1,244 @@ +""" +SDS (Spine Directory Service) FHIR R4 device and endpoint lookup client. + +This module provides a client for querying the Spine Directory Service to retrieve: +- Device records (including ASID - Accredited System ID) +- Endpoint records (including endpoint URLs for routing) +""" + +from __future__ import annotations + +import os +from enum import StrEnum +from typing import Any, cast + +from stubs import SdsFhirApiStub + +from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID +from gateway_api.sds.search_results import SdsSearchResults + +# TODO: Once stub servers/containers made for PDS, SDS and provider +# we should remove the STUB_SDS environment variable and just +# use the stub client +STUB_SDS = os.environ.get("STUB_SDS", "false").lower() == "true" +if not STUB_SDS: + from requests import get +else: + from stubs.stub_sds import SdsFhirApiStub + + sds = SdsFhirApiStub() + get = sds.get # type: ignore + +# Recursive JSON-like structure typing used for parsed FHIR bodies. +type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"] +type ResultStructureDict = dict[str, ResultStructure] +type ResultList = list[ResultStructureDict] + + +class SdsResourceType(StrEnum): + """SDS FHIR resource types.""" + + DEVICE = "Device" + ENDPOINT = "Endpoint" + + +class SdsClient: + """ + Simple client for SDS FHIR R4 device and endpoint retrieval. + + The client supports: + + * :meth:`get_org_details` - Retrieves ASID and endpoint for an organization + + This method returns a :class:`SdsSearchResults` instance when data can be + extracted, otherwise ``None``. + + **Stubbing**: + + For testing, set the environment variable ``$STUB_SDS`` to use the + :class:`SdsFhirApiStub` instead of making real HTTP requests. + + **Usage example**:: + + sds = SdsClient( + base_url="https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4", + timeout=10, + service_interaction_id="urn:nhs:names:services:gpconnect:fhir:rest:read:metadata-1", + ) + + result = sds.get_org_details("A12345") + + if result: + print(f"ASID: {result.asid}, Endpoint: {result.endpoint}") + """ + + # URLs for different SDS environments. Will move to a config file eventually. + SANDBOX_URL = "https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4" + INT_URL = "https://int.api.service.nhs.uk/spine-directory/FHIR/R4" + + # FHIR identifier systems + ODS_SYSTEM = "https://fhir.nhs.uk/Id/ods-organization-code" + INTERACTION_SYSTEM = "https://fhir.nhs.uk/Id/nhsServiceInteractionId" + PARTYKEY_SYSTEM = "https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ASID_SYSTEM = "https://fhir.nhs.uk/Id/nhsSpineASID" + + # Define here so it's neater + + # Default service interaction ID for GP Connect + DEFAULT_SERVICE_INTERACTION_ID = ACCESS_RECORD_STRUCTURED_INTERACTION_ID + + def __init__( + self, + base_url: str = SANDBOX_URL, + timeout: int = 10, + service_interaction_id: str | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.service_interaction_id = ( + service_interaction_id or self.DEFAULT_SERVICE_INTERACTION_ID + ) + self.api_key = self._get_api_key() + + def _build_headers(self, correlation_id: str | None = None) -> dict[str, str]: + """ + Build mandatory and optional headers for an SDS request. + """ + headers = { + "Accept": "application/fhir+json", + "apikey": self.api_key, + } + + if correlation_id: + headers["X-Correlation-Id"] = correlation_id + + return headers + + def get_org_details( + self, + ods_code: str, + correlation_id: str | None = None, + timeout: int | None = None, + get_endpoint: bool = True, + ) -> SdsSearchResults | None: + """ + Retrieve ASID and endpoint for an organization by ODS code. + + This method performs two SDS queries: + 1. Query /Device to get the ASID for the organization + 2. Query /Endpoint to get the endpoint URL (if available) + """ + # Step 1: Get Device to obtain ASID + device_bundle = self._query_sds( + ods_code=ods_code, + correlation_id=correlation_id, + timeout=timeout, + querytype=SdsResourceType.DEVICE, + ) + + device = self._extract_first_entry(device_bundle) + + # TODO: Post-steel-thread handle case where no device is found for ODS code + + asid = self._extract_identifier(device, self.ASID_SYSTEM) + party_key = self._extract_identifier(device, self.PARTYKEY_SYSTEM) + + # Step 2: Get Endpoint to obtain endpoint URL + endpoint_url: str | None = None + + if not get_endpoint: + return SdsSearchResults(asid=asid, endpoint=None) + + endpoint_bundle = self._query_sds( + ods_code=ods_code, + party_key=party_key, + correlation_id=correlation_id, + 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() + + return SdsSearchResults(asid=asid, endpoint=endpoint_url) + + @staticmethod + def _get_api_key() -> str: + """ + Retrieve the API key to use for SDS requests. + + This is a placeholder at present because we don't have a real API key. + Ultimately it will probably obtain the key from AWS secrets + """ + + # TODO: Obtain key from AWS secrets + # DO NOT PUT A REAL KEY HERE, IT WILL BE VISIBLE ON GITHUB + return "test_api_key_DO_NOT_REPLACE_HERE" + + def _query_sds( + self, + ods_code: str, + party_key: str | None = None, + correlation_id: str | None = None, + timeout: int | None = 10, + querytype: SdsResourceType = SdsResourceType.DEVICE, + ) -> ResultStructureDict: + """ + Query SDS /Device or /Endpoint endpoint. + """ + headers = self._build_headers(correlation_id=correlation_id) + url = f"{self.base_url}/{querytype.value}" + + params: dict[str, Any] = { + "organization": f"{self.ODS_SYSTEM}|{ods_code}", + "identifier": [f"{self.INTERACTION_SYSTEM}|{self.service_interaction_id}"], + } + + if party_key is not None: + params["identifier"].append(f"{self.PARTYKEY_SYSTEM}|{party_key}") + + response = get( + url, + headers=headers, + params=params, + timeout=timeout or self.timeout, + ) + + # TODO: Post-steel-thread we probably want a raise_for_status() here + + body = response.json() + return cast("ResultStructureDict", body) + + @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", [])) + + # TODO: Post-steel-thread handle case where bundle contains no entries + + # TODO: consider business logic for handling multiple entries in beta + first_entry = entries[0] + return cast("ResultStructureDict", first_entry.get("resource", {})) + + def _extract_identifier( + self, device: ResultStructureDict, 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: + value = identifier.get("value") + if value: + return str(value).strip() + + return None diff --git a/gateway-api/src/gateway_api/sds/search_results.py b/gateway-api/src/gateway_api/sds/search_results.py new file mode 100644 index 00000000..c99ffe12 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/search_results.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + + +@dataclass +class SdsSearchResults: + """ + Stub SDS search results dataclass. + + Replace this with the real one once it's implemented. + """ + + asid: str | None + endpoint: str | None diff --git a/gateway-api/src/gateway_api/sds/test_client.py b/gateway-api/src/gateway_api/sds/test_client.py new file mode 100644 index 00000000..41a57137 --- /dev/null +++ b/gateway-api/src/gateway_api/sds/test_client.py @@ -0,0 +1,298 @@ +""" +Unit tests for :mod:`gateway_api.sds_search`. +""" + +from __future__ import annotations + +import pytest +from stubs.stub_sds import SdsFhirApiStub + +from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID +from gateway_api.sds import ( + SdsClient, + SdsSearchResults, +) + + +@pytest.fixture +def stub(monkeypatch: pytest.MonkeyPatch) -> SdsFhirApiStub: + stub = SdsFhirApiStub() + monkeypatch.setattr( + "gateway_api.sds.client.get", + lambda *args, **kwargs: stub.get(*args, **kwargs), # NOQA ARG005 (maintain signature) + ) + monkeypatch.setattr("requests.get", stub.get) + + return stub + + +def test_sds_client_get_org_details_success( + stub: SdsFhirApiStub, +) -> None: + """ + Test SdsClient can successfully look up organization details. + + :param stub: SDS stub fixture. + """ + client = SdsClient(base_url=SdsClient.SANDBOX_URL) + + result = client.get_org_details(ods_code="PROVIDER") + + assert result is not None + assert isinstance(result, SdsSearchResults) + assert result.asid == "asid_PROV" + assert result.endpoint == "https://provider.example.com/fhir" + + params = stub.get_params + assert any( + ACCESS_RECORD_STRUCTURED_INTERACTION_ID in str(ident) + for ident in params.get("identifier", []) + ) + + +def test_sds_client_get_org_details_with_endpoint( + stub: SdsFhirApiStub, +) -> None: + """ + Test SdsClient retrieves endpoint when available. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + + # Add a device so we can get an endpoint + stub.upsert_device( + organization_ods="TESTORG", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key="TESTORG-123456", + device={ + "resourceType": "Device", + "id": "test-device-id", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "999999999999", + }, + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "TESTORG-123456", + }, + ], + "owner": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "TESTORG", + } + }, + }, + ) + + stub.upsert_endpoint( + organization_ods="TESTORG", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key="TESTORG-123456", + endpoint={ + "resourceType": "Endpoint", + "id": "test-endpoint-id", + "status": "active", + "address": "https://testorg.example.com/fhir", + "managingOrganization": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "TESTORG", + } + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "TESTORG-123456", + } + ], + }, + ) + + client = SdsClient(base_url=SdsClient.SANDBOX_URL) + result = client.get_org_details(ods_code="TESTORG") + + assert result is not None + assert result.asid == "999999999999" + assert result.endpoint == "https://testorg.example.com/fhir" + + +def test_sds_client_sends_correct_headers( + stub: SdsFhirApiStub, +) -> None: + """ + Test that SdsClient sends X-Correlation-Id and apikey headers when provided. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(base_url=SdsClient.SANDBOX_URL) + + correlation_id = "test-correlation-123" + client.get_org_details(ods_code="PROVIDER", correlation_id=correlation_id) + + # Check that the headers were + assert stub.get_headers["X-Correlation-Id"] == correlation_id + + # In future when _get_api_key calls AWS secrets, this will break. + # That's a good thing, because we'll want to mock that call. + assert stub.get_headers["apikey"] == "test_api_key_DO_NOT_REPLACE_HERE" + + +def test_sds_client_timeout_parameter( + stub: SdsFhirApiStub, +) -> None: + """ + Test that SdsClient passes timeout parameter to requests. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(base_url=SdsClient.SANDBOX_URL, timeout=30) + + client.get_org_details(ods_code="PROVIDER", timeout=60) + + # Check that the custom timeout was passed + assert stub.get_timeout == 60 + + +def test_sds_client_custom_service_interaction_id( + stub: SdsFhirApiStub, +) -> None: + """ + Test that SdsClient uses custom interaction ID when provided. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + custom_interaction = "urn:nhs:names:services:custom:CUSTOM123" + + # Add device with custom interaction ID + stub.upsert_device( + organization_ods="CUSTOMINT", + service_interaction_id=custom_interaction, + party_key=None, + device={ + "resourceType": "Device", + "id": "custom-device", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "777777777777", + } + ], + "owner": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "CUSTOMINT", + } + }, + }, + ) + + client = SdsClient( + base_url=SdsClient.SANDBOX_URL, + service_interaction_id=custom_interaction, + ) + + result = client.get_org_details(ods_code="CUSTOMINT", get_endpoint=False) + + # Verify the custom interaction was used + params = stub.get_params + assert any( + custom_interaction in str(ident) for ident in params.get("identifier", []) + ) + + # Verify we got the result + assert result is not None + assert result.asid == "777777777777" + + +def test_sds_client_builds_correct_device_query_params( + stub: SdsFhirApiStub, +) -> None: + """ + Test that SdsClient builds Device query parameters correctly. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + client = SdsClient(base_url=SdsClient.SANDBOX_URL) + + client.get_org_details(ods_code="PROVIDER") + + params = stub.get_params + + # Check organization parameter + assert ( + params["organization"] + == "https://fhir.nhs.uk/Id/ods-organization-code|PROVIDER" + ) + + # Check identifier list contains interaction ID + identifiers = params["identifier"] + assert isinstance(identifiers, list) + assert any( + "https://fhir.nhs.uk/Id/nhsServiceInteractionId|" in str(ident) + for ident in identifiers + ) + + +def test_sds_client_extract_party_key_from_device( + stub: SdsFhirApiStub, +) -> None: + """ + Test party key extraction and subsequent endpoint lookup. + + :param stub: SDS stub fixture. + :param mock_requests_get: Capture fixture for request details. + """ + # The default seeded PROVIDER device has a party key + client = SdsClient(base_url=SdsClient.SANDBOX_URL) + + stub.upsert_device( + organization_ods="WITHPARTYKEY", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key="WITHPARTYKEY-654321", + device={ + "resourceType": "Device", + "id": "device-with-party-key", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "888888888888", + }, + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "WITHPARTYKEY-654321", + }, + ], + }, + ) + + stub.upsert_endpoint( + organization_ods="WITHPARTYKEY", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key="WITHPARTYKEY-654321", + endpoint={ + "resourceType": "Endpoint", + "id": "endpoint-for-party-key", + "status": "active", + "address": "https://withpartykey.example.com/fhir", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsMhsPartyKey", + "value": "WITHPARTYKEY-654321", + } + ], + }, + ) + + result = client.get_org_details(ods_code="WITHPARTYKEY", get_endpoint=True) + + # Should have found ASID but may not have endpoint depending on seeding + assert result is not None + assert result.asid == "888888888888" + assert result.endpoint == "https://withpartykey.example.com/fhir" diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index fdf77815..36d8c0c7 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -3,21 +3,21 @@ import json import os from collections.abc import Generator +from copy import copy from typing import TYPE_CHECKING 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.controller import Controller -from gateway_api.get_structured_record.request import GetStructuredRecordRequest +from gateway_api.common.common import FlaskResponse if TYPE_CHECKING: - from fhir.parameters import Parameters - -if TYPE_CHECKING: - from fhir.parameters import Parameters + from fhir.operation_outcome import OperationOutcome @pytest.fixture @@ -54,30 +54,41 @@ def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: class TestGetStructuredRecord: - def test_get_structured_record_returns_200_with_bundle( + @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") + def test_valid_get_structured_record_request_returns_bundle( self, - client: FlaskClient[Flask], - monkeypatch: pytest.MonkeyPatch, - valid_simple_request_payload: "Parameters", + get_structured_record_response: Flask, ) -> None: - """Test that successful controller response is returned correctly.""" - from datetime import datetime, timezone - from typing import Any - - from gateway_api.common.common import FlaskResponse - - # Mock the controller to return a successful FlaskResponse with a Bundle - mock_bundle_data: Any = { + expected_body = { "resourceType": "Bundle", "id": "example-patient-bundle", "type": "collection", - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": "2026-02-05T22:45:42.766330+00:00", "entry": [ { - "fullUrl": "http://example.com/Patient/9999999999", + "fullUrl": "https://example.com/Patient/9999999999", "resource": { + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": { + "start": "2020-01-01", + "end": "9999-12-31", + }, + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + }, + } + ], "name": [ - {"family": "Alice", "given": ["Johnson"], "use": "Ally"} + { + "family": "Alice", + "given": ["Johnson"], + "use": "Ally", + "period": {"start": "2020-01-01"}, + } ], "gender": "female", "birthDate": "1990-05-15", @@ -91,115 +102,191 @@ def test_get_structured_record_returns_200_with_bundle( ], } - def mock_run( - self: Controller, # noqa: ARG001 - request: GetStructuredRecordRequest, # noqa: ARG001 - ) -> FlaskResponse: - return FlaskResponse( - status_code=200, - data=json.dumps(mock_bundle_data), - headers={"Content-Type": "application/fhir+json"}, - ) - - monkeypatch.setattr( - "gateway_api.controller.Controller.run", - mock_run, - ) + actual_body = get_structured_record_response.get_json() + assert actual_body == expected_body - response = client.post( - "/patient/$gpc.getstructuredrecord", - json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - }, + @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") + def test_valid_get_structured_record_request_returns_200( + self, + get_structured_record_response: Flask, + ) -> None: + assert get_structured_record_response.status_code == 200 + + @pytest.mark.usefixtures("mock_raise_error_from_controller_run") + def test_get_structured_record_returns_500_when_an_uncaught_exception_is_raised( + self, + get_structured_record_response: Flask, + ) -> None: + actual_status_code = get_structured_record_response.status_code + assert actual_status_code == 500 + + @staticmethod + @pytest.fixture + def missing_headers( + request: pytest.FixtureRequest, valid_headers: dict[str, str] + ) -> dict[str, str]: + invalid_headers = copy(valid_headers) + del invalid_headers[request.param] + return invalid_headers + + @pytest.mark.parametrize( + "missing_headers", + ["ODS-from", "Ssp-TraceID"], + indirect=True, + ) + @pytest.mark.usefixtures("missing_headers") + def test_get_structured_record_returns_400_when_required_header_missing( + self, + get_structured_record_response_from_missing_header: Flask, + ) -> None: + assert get_structured_record_response_from_missing_header.status_code == 400 + + @pytest.mark.parametrize( + "missing_headers", + ["ODS-from", "Ssp-TraceID"], + indirect=True, + ) + @pytest.mark.usefixtures("missing_headers") + def test_get_structured_record_returns_fhir_content_when_missing_header( + self, + get_structured_record_response_from_missing_header: Flask, + ) -> None: + assert ( + "application/fhir+json" + in get_structured_record_response_from_missing_header.content_type ) - assert response.status_code == 200 - data = response.get_json() - assert isinstance(data, dict) - assert data.get("resourceType") == "Bundle" - assert data.get("id") == "example-patient-bundle" - assert data.get("type") == "collection" - assert "entry" in data - assert isinstance(data["entry"], list) - assert len(data["entry"]) > 0 - assert data["entry"][0]["resource"]["resourceType"] == "Patient" - assert data["entry"][0]["resource"]["id"] == "9999999999" - assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" - - def test_get_structured_record_handles_exception( + @pytest.mark.parametrize( + ("missing_headers", "expected_message"), + [ + pytest.param( + "ODS-from", + 'Missing or empty required header "ODS-from"', + ), + pytest.param( + "Ssp-TraceID", + 'Missing or empty required header "Ssp-TraceID"', + ), + ], + indirect=["missing_headers"], + ) + def test_get_structured_record_returns_operation_outcome_when_missing_header( self, - client: FlaskClient[Flask], - monkeypatch: pytest.MonkeyPatch, - valid_simple_request_payload: "Parameters", + get_structured_record_response_from_missing_header: Flask, + expected_message: str, + ) -> None: + expected_body: OperationOutcome = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": expected_message, + } + ], + } + assert ( + expected_body + == get_structured_record_response_from_missing_header.get_json() + ) + + def test_get_structured_record_returns_400_when_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask ) -> None: - """ - Test that exceptions during controller execution are caught and return 500. - """ - - # This is mocking the run method of the Controller - # and therefore self is a Controller - def mock_run_with_exception( - self: Controller, # noqa: ARG001 - request: GetStructuredRecordRequest, # noqa: ARG001 - ) -> None: - raise ValueError("Test exception") - - monkeypatch.setattr( - "gateway_api.controller.Controller.run", - mock_run_with_exception, + assert get_structured_record_response_using_invalid_json_body.status_code == 400 + + def test_get_structured_record_returns_content_type_fhir_json_for_invalid_json_sent( + self, get_structured_record_response_using_invalid_json_body: Flask + ) -> None: + assert ( + "application/fhir+json" + in get_structured_record_response_using_invalid_json_body.content_type ) + 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 = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Invalid JSON body sent in request", + } + ], + } + actual = get_structured_record_response_using_invalid_json_body.get_json() + assert actual == expected + + @staticmethod + @pytest.fixture + def get_structured_record_response( + client: FlaskClient[Flask], + valid_headers: dict[str, str], + valid_simple_request_payload: Parameters, + ) -> Flask: response = client.post( "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - }, + headers=valid_headers, ) - assert response.status_code == 500 + return response - def test_get_structured_record_handles_request_validation_error( - self, + @staticmethod + @pytest.fixture + def get_structured_record_response_from_missing_header( client: FlaskClient[Flask], - valid_simple_request_payload: "Parameters", - ) -> None: - """Test that RequestValidationError returns 400 with error message.""" - # Create a request missing the required ODS-from header + missing_headers: dict[str, str], + valid_simple_request_payload: Parameters, + ) -> Flask: response = client.post( "/patient/$gpc.getstructuredrecord", - json=valid_simple_request_payload, - headers={ - "Ssp-TraceID": "test-trace-id", - # Missing "ODS-from" header to trigger RequestValidationError - }, + data=json.dumps(valid_simple_request_payload), + headers=missing_headers, ) + return response - assert response.status_code == 400 - assert "text/plain" in response.content_type - assert b'Missing or empty required header "ODS-from"' in response.data - - def test_get_structured_record_handles_unexpected_exception_during_init( - self, + @staticmethod + @pytest.fixture + def get_structured_record_response_using_invalid_json_body( client: FlaskClient[Flask], - ) -> None: - """Test that unexpected exceptions during request init return 500.""" - # Send invalid JSON to trigger an exception during request processing + valid_headers: dict[str, str], + ) -> Flask: + invalid_json = "invalid json data" + response = client.post( "/patient/$gpc.getstructuredrecord", - data="invalid json data", - headers={ - "Ssp-TraceID": "test-trace-id", - "ODS-from": "test-ods", - "Content-Type": "application/fhir+json", - }, + data=invalid_json, + headers=valid_headers, + ) + return response + + @staticmethod + @pytest.fixture + def mock_positive_return_value_from_controller_run( + mocker: MockerFixture, + valid_headers: dict[str, str], + valid_simple_response_payload: Bundle, + ) -> None: + postive_response = FlaskResponse( + status_code=200, + data=json.dumps(valid_simple_response_payload), + headers=valid_headers, + ) + mocker.patch( + "gateway_api.controller.Controller.run", return_value=postive_response ) - assert response.status_code == 500 - assert "text/plain" in response.content_type - assert b"Internal Server Error:" in response.data + @staticmethod + @pytest.fixture + def mock_raise_error_from_controller_run( + mocker: MockerFixture, + ) -> None: + internal_error = ValueError("Test exception") + mocker.patch( + "gateway_api.controller.Controller.run", side_effect=internal_error + ) class TestHealthCheck: diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 3fc3ded4..86b035a0 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,666 +1,258 @@ -""" -Unit tests for :mod:`gateway_api.controller`. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from types import SimpleNamespace -from typing import TYPE_CHECKING, Any +"""Unit tests for :mod:`gateway_api.controller`.""" import pytest -from flask import request as flask_request -from requests import Response - -import gateway_api.controller as controller_module -from gateway_api.app import app -from gateway_api.controller import ( - Controller, - SdsSearchResults, +from fhir.bundle import Bundle +from fhir.parameters import Parameters +from pytest_mock import MockerFixture + +from gateway_api.common.error import ( + NoAsidFound, + NoCurrentEndpoint, + NoCurrentProvider, + NoOrganisationFound, ) -from gateway_api.get_structured_record.request import GetStructuredRecordRequest - -if TYPE_CHECKING: - from collections.abc import Generator - - from gateway_api.common.common import json_str - - -# ----------------------------- -# Fake downstream dependencies -# ----------------------------- -def _make_pds_result(gp_ods_code: str | None) -> Any: - """ - Construct a minimal PDS-result-like object for tests. - - The controller only relies on the ``gp_ods_code`` attribute. - - :param gp_ods_code: Provider ODS code to expose on the result. - :returns: An object with a ``gp_ods_code`` attribute. - """ - return SimpleNamespace(gp_ods_code=gp_ods_code) - - -class FakePdsClient: - """ - Test double for :class:`gateway_api.pds_search.PdsClient`. - - The controller instantiates this class and calls ``search_patient_by_nhs_number``. - Tests configure the returned patient details using ``set_patient_details``. - """ - - last_init: dict[str, Any] | None = None - - def __init__(self, **kwargs: Any) -> None: - FakePdsClient.last_init = dict(kwargs) - self._patient_details: Any | None = None - - def set_patient_details(self, value: Any) -> None: - self._patient_details = value - - def search_patient_by_nhs_number( - self, - nhs_number: int, # noqa: ARG002 (unused in fake) - ) -> Any | None: - return self._patient_details - - -class FakeSdsClient: - """ - Test double for :class:`gateway_api.controller.SdsClient`. - - Tests configure per-ODS results using ``set_org_details`` and the controller - retrieves them via ``get_org_details``. - """ - - last_init: dict[str, Any] | None = None - - def __init__( - self, - auth_token: str | None = None, - base_url: str = "test_url", - timeout: int = 10, - ) -> None: - FakeSdsClient.last_init = { - "auth_token": auth_token, - "base_url": base_url, - "timeout": timeout, - } - self.auth_token = auth_token - self.base_url = base_url - self.timeout = timeout - self._org_details_by_ods: dict[str, SdsSearchResults | None] = {} - - def set_org_details( - self, ods_code: str, org_details: SdsSearchResults | None - ) -> None: - self._org_details_by_ods[ods_code] = org_details - - def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - return self._org_details_by_ods.get(ods_code) - - -class FakeGpProviderClient: - """ - Test double for :class:`gateway_api.controller.GpProviderClient`. - - The controller instantiates this class and calls ``access_structured_record``. - Tests configure the returned HTTP response using class-level attributes. - """ - - last_init: dict[str, str] | None = None - last_call: dict[str, str] | None = None - - # Configure per-test. - return_none: bool = False - response_status_code: int = 200 - response_body: bytes = b"ok" - response_headers: dict[str, str] = {"Content-Type": "application/fhir+json"} - - def __init__( - self, provider_endpoint: str, provider_asid: str, consumer_asid: str - ) -> None: - FakeGpProviderClient.last_init = { - "provider_endpoint": provider_endpoint, - "provider_asid": provider_asid, - "consumer_asid": consumer_asid, - } - - def access_structured_record( - self, - trace_id: str, - body: json_str, - ) -> Response | None: - FakeGpProviderClient.last_call = {"trace_id": trace_id, "body": body} - - if FakeGpProviderClient.return_none: - return None - - resp = Response() - resp.status_code = FakeGpProviderClient.response_status_code - resp._content = FakeGpProviderClient.response_body # noqa: SLF001 - resp.encoding = "utf-8" - resp.headers.update(FakeGpProviderClient.response_headers) - resp.url = "https://example.invalid/fake" - return resp - - -@dataclass -class SdsSetup: - """ - Helper dataclass to hold SDS setup data for tests. - """ - - ods_code: str - search_results: SdsSearchResults - - -class sds_factory: - """ - Factory to create a :class:`FakeSdsClient` pre-configured with up to two - organisations. - """ - - def __init__( - self, - org1: SdsSetup | None = None, - org2: SdsSetup | None = None, - ) -> None: - self.org1 = org1 - self.org2 = org2 - - def __call__(self, **kwargs: Any) -> FakeSdsClient: - self.inst = FakeSdsClient(**kwargs) - if self.org1 is not None: - self.inst.set_org_details( - self.org1.ods_code, - SdsSearchResults( - asid=self.org1.search_results.asid, - endpoint=self.org1.search_results.endpoint, - ), - ) - - if self.org2 is not None: - self.inst.set_org_details( - self.org2.ods_code, - SdsSearchResults( - asid=self.org2.search_results.asid, - endpoint=self.org2.search_results.endpoint, - ), - ) - return self.inst - - -class pds_factory: - """ - Factory to create a :class:`FakePdsClient` pre-configured with patient details. - """ - - def __init__(self, ods_code: str | None) -> None: - self.ods_code = ods_code - - def __call__(self, **kwargs: Any) -> FakePdsClient: - self.inst = FakePdsClient(**kwargs) - self.inst.set_patient_details(_make_pds_result(self.ods_code)) - return self.inst - - -@pytest.fixture -def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: - """ - Patch controller dependencies to use test fakes. - """ - monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) - monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) - monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) - - -@pytest.fixture -def controller() -> Controller: - """ - Construct a controller instance configured for unit tests. - """ - return Controller( - pds_base_url="https://pds.example", - sds_base_url="https://sds.example", - nhsd_session_urid="session-123", - timeout=3, - ) - - -@pytest.fixture -def gp_provider_returns_none() -> Generator[None, None, None]: - """ - Configure FakeGpProviderClient to return None and reset after the test. - """ - FakeGpProviderClient.return_none = True - yield - FakeGpProviderClient.return_none = False - - -@pytest.fixture -def get_structured_record_request( - request: pytest.FixtureRequest, -) -> GetStructuredRecordRequest: - # Pass two dicts to this fixture that give dicts to add to - # header and body respectively. - header_update, body_update = request.param - - headers = { - "Ssp-TraceID": "3d7f2a6e-0f4e-4af3-9b7b-2a3d5f6a7b8c", - "ODS-from": "CONSUMER", - } - - headers.update(header_update) - - body = { - "resourceType": "Parameters", - "parameter": [ - { - "valueIdentifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - }, - } - ], - } - - body.update(body_update) - - with app.test_request_context( - path="/patient/$gpc.getstructuredrecord", - method="POST", - headers=headers, - json=body, - ): - return GetStructuredRecordRequest(flask_request) - +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 -# ----------------------------- -# Unit tests -# ----------------------------- - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_200_on_success( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_controller_run_returns_happy_path_response( + mocker: MockerFixture, + valid_simple_request_payload: Parameters, + valid_simple_response_payload: Bundle, ) -> None: - """ - On successful end-to-end call, the controller should return 200 with - expected body/headers. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" + nhs_number = "9000000009" + provider_ods = "ProviderODS" + provider_sds_results = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" + ) + consumer_ods = "ConsumerODS" + consumer_sds_results = SdsSearchResults( + asid="ConsumerASID", endpoint="https://example.consumer.org/endpoint" + ) + 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, ), ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=sds_results, ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - FakeGpProviderClient.response_status_code = 200 - FakeGpProviderClient.response_body = b'{"resourceType":"Bundle"}' - FakeGpProviderClient.response_headers = { - "Content-Type": "application/fhir+json", - "X-Downstream": "gp-provider", - } - - r = controller.run(get_structured_record_request) - - # Check that response from GP provider was passed through. - assert r.status_code == 200 - assert r.data == FakeGpProviderClient.response_body.decode("utf-8") - assert r.headers == FakeGpProviderClient.response_headers - - # Check that GP provider was initialised correctly - assert FakeGpProviderClient.last_init == { - "provider_endpoint": "https://provider.example/ep", - "provider_asid": "asid_PROV", - "consumer_asid": "asid_CONS", - } - - # Check that we passed the trace ID and body to the provider - assert FakeGpProviderClient.last_call == { - "trace_id": get_structured_record_request.trace_id, - "body": get_structured_record_request.request_body, - } - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_pds_patient_not_found( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns no patient record, the controller should return 404. - """ - # FakePdsClient defaults to returning None => RequestError => 404 - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "No PDS patient found for NHS number" in (r.data or "") + provider_response = FakeResponse( + status_code=200, + headers={"Content-Type": "application/fhir+json"}, + _json=valid_simple_response_payload, + ) + mocker.patch( + "gateway_api.provider.GpProviderClient.access_structured_record", + return_value=provider_response, + ) -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_gp_ods_code_missing( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - If PDS returns a patient without a provider (GP) ODS code, return 404. - """ - pds = pds_factory(ods_code="") - monkeypatch.setattr(controller_module, "PdsClient", pds) - - r = controller.run(get_structured_record_request) + controller = Controller() + http_request = create_mock_request( + headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, + body=valid_simple_request_payload, + ) + actual_response = controller.run(GetStructuredRecordRequest(http_request)) - assert r.status_code == 404 - assert "did not contain a current provider ODS code" in (r.data or "") + assert actual_response.status_code == 200 -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_pds_details_returns_provider_ods_code_for_happy_path( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If SDS returns no provider org details, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds = sds_factory() - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + 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, + ) + controller = Controller(pds_base_url="https://example.test/pds", timeout=7) - r = controller.run(get_structured_record_request) + actual = controller._get_pds_details(auth_token, nhs_number) # noqa: SLF001 - assert r.status_code == 404 - assert r.data == "No SDS org found for provider ODS code PROVIDER" + assert actual == "A12345" -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_pds_details_raises_no_current_provider_when_ods_code_missing_in_pds( + mocker: MockerFixture, + auth_token: str, ) -> None: - """ - If provider ASID is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid=" ", endpoint="https://provider.example/ep" - ), + 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, ) - sds = sds_factory(org1=sds_org1) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 404 - assert "did not contain a current ASID" in (r.data or "") + with pytest.raises( + NoCurrentProvider, + match="PDS patient 9000000009 did not contain a current provider ODS code", + ): + _ = controller._get_pds_details(auth_token, nhs_number) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_502_when_gp_provider_returns_none( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, - gp_provider_returns_none: None, # NOQA ARG001 (Fixture handling setup/teardown) +def test_get_sds_details_returns_consumer_and_provider_deatils_for_happy_path( + mocker: MockerFixture, ) -> None: - """ - If GP provider returns no response object, the controller should return 502. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + provider_sds_results = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + consumer_ods = "ConsumerODS" + consumer_sds_results = SdsSearchResults( + asid="ConsumerASID", endpoint="https://example.consumer.org/endpoint" + ) + sds_results = [provider_sds_results, consumer_sds_results] + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=sds_results, ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 502 - assert r.data == "GP provider service error" - assert r.headers is None + expected = ("ConsumerASID", "ProviderASID", "https://example.provider.org/endpoint") + actual = controller._get_sds_details(consumer_ods, provider_ods) # noqa: SLF001 + assert actual == expected -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, -) -> None: - """ - Validate that the controller constructs the PDS client with expected kwargs. - """ - _ = controller.run(get_structured_record_request) # will stop at PDS None => 404 - - assert FakePdsClient.last_init is not None - assert FakePdsClient.last_init["auth_token"] == "PLACEHOLDER_AUTH_TOKEN" # noqa: S105 - assert FakePdsClient.last_init["end_user_org_ods"] == "CONSUMER" - assert FakePdsClient.last_init["base_url"] == "https://pds.example" - assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" - assert FakePdsClient.last_init["timeout"] == 3 - - -@pytest.mark.parametrize( - "get_structured_record_request", - [({}, {"parameter": [{"valueIdentifier": {"value": "1234567890"}}]})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_404_message_includes_nhs_number_from_request_body( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_organisation_found_when_sds_returns_none( + mocker: MockerFixture, ) -> None: - """ - If PDS returns no patient record, error message should include NHS number parsed - from the FHIR Parameters request body. - """ - r = controller.run(get_structured_record_request) + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=None, + ) - assert r.status_code == 404 - assert r.data == "No PDS patient found for NHS number 1234567890" + controller = Controller() + with pytest.raises( + NoOrganisationFound, + match="No SDS org found for provider ODS code ProviderODS", + ): + _ = controller._get_sds_details(consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, + +def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_asid( + mocker: MockerFixture, ) -> None: - """ - If provider endpoint is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults(asid="asid_PROV", endpoint=" "), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + blank_asid_sds_result = SdsSearchResults( + asid=" ", endpoint="https://example.provider.org/endpoint" + ) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=blank_asid_sds_result, ) - sds = sds_factory(org1=sds_org1) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 404 - assert "did not contain a current endpoint" in (r.data or "") + with pytest.raises( + NoAsidFound, + match=( + "SDS result for provider ODS code ProviderODS did not contain " + "a current ASID" + ), + ): + _ = controller._get_sds_details(consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_current_endpoint_when_sds_returns_empty_endpoint( + mocker: MockerFixture, ) -> None: - """ - If SDS returns no consumer org details, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + blank_endpoint_sds_result = SdsSearchResults(asid="ProviderASID", endpoint=" ") + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + return_value=blank_endpoint_sds_result, ) - sds = sds_factory(org1=sds_org1) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 404 - assert r.data == "No SDS org found for consumer ODS code CONSUMER" + with pytest.raises( + NoCurrentEndpoint, + match=( + "SDS result for provider ODS code ProviderODS did " + "not contain a current endpoint" + ), + ): + _ = controller._get_sds_details(consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_org_found_when_sds_returns_none_for_consumer( + mocker: MockerFixture, ) -> None: - """ - If consumer ASID is blank/whitespace, the controller should return 404. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + + happy_path_provider_sds_result = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid=" ", endpoint=None), + none_result_for_consumer = None + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[happy_path_provider_sds_result, none_result_for_consumer], ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) + controller = Controller() - r = controller.run(get_structured_record_request) - - assert r.status_code == 404 - assert "did not contain a current ASID" in (r.data or "") + with pytest.raises( + NoOrganisationFound, + match="No SDS org found for consumer ODS code ConsumerODS", + ): + _ = controller._get_sds_details(consumer_ods, provider_ods) # noqa: SLF001 -@pytest.mark.parametrize( - "get_structured_record_request", - [({"ODS-from": "CONSUMER"}, {})], - indirect=["get_structured_record_request"], -) -def test_call_gp_provider_passthroughs_non_200_gp_provider_response( - patched_deps: Any, # NOQA ARG001 (Fixture patching dependencies) - monkeypatch: pytest.MonkeyPatch, - controller: Controller, - get_structured_record_request: GetStructuredRecordRequest, +def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_consumer_asid( + mocker: MockerFixture, ) -> None: - """ - Validate that non-200 responses from GP provider are passed through. - """ - pds = pds_factory(ods_code="PROVIDER") - sds_org1 = SdsSetup( - ods_code="PROVIDER", - search_results=SdsSearchResults( - asid="asid_PROV", endpoint="https://provider.example/ep" - ), + provider_ods = "ProviderODS" + consumer_ods = "ConsumerODS" + + happy_path_provider_sds_result = SdsSearchResults( + asid="ProviderASID", endpoint="https://example.provider.org/endpoint" ) - sds_org2 = SdsSetup( - ods_code="CONSUMER", - search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + consumer_asid_blank_sds_result = SdsSearchResults( + asid=" ", endpoint="https://example.consumer.org/endpoint" + ) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[happy_path_provider_sds_result, consumer_asid_blank_sds_result], ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) - - monkeypatch.setattr(controller_module, "PdsClient", pds) - monkeypatch.setattr(controller_module, "SdsClient", sds) - - FakeGpProviderClient.response_status_code = 404 - FakeGpProviderClient.response_body = b"Not Found" - FakeGpProviderClient.response_headers = { - "Content-Type": "text/plain", - "X-Downstream": "gp-provider", - } - r = controller.run(get_structured_record_request) + controller = Controller() - assert r.status_code == 404 - assert r.data == "Not Found" - assert r.headers is not None - assert r.headers.get("Content-Type") == "text/plain" - assert r.headers.get("X-Downstream") == "gp-provider" + with pytest.raises( + NoAsidFound, + match=( + "SDS result for consumer ODS code ConsumerODS did not contain " + "a current ASID" + ), + ): + _ = controller._get_sds_details(consumer_ods, provider_ods) # noqa: SLF001 diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py deleted file mode 100644 index a433c9a1..00000000 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ /dev/null @@ -1,637 +0,0 @@ -""" -Unit tests for :mod:`gateway_api.pds_search`. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import date -from typing import TYPE_CHECKING, Any, cast -from uuid import uuid4 - -import pytest -import requests -from stubs.stub_pds import PdsFhirApiStub - -if TYPE_CHECKING: - from requests.structures import CaseInsensitiveDict - -from gateway_api.pds_search import ( - ExternalServiceError, - PdsClient, - ResultList, -) - - -@dataclass -class FakeResponse: - """ - Minimal substitute for :class:`requests.Response` used by tests. - - Only the methods accessed by :class:`gateway_api.pds_search.PdsClient` are - implemented. - - :param status_code: HTTP status code. - :param headers: Response headers (dict or CaseInsensitiveDict). - :param _json: Parsed JSON body returned by :meth:`json`. - """ - - status_code: int - headers: dict[str, str] | CaseInsensitiveDict[str] - _json: dict[str, Any] - reason: str = "" - - def json(self) -> dict[str, Any]: - """ - Return the response JSON body. - - :return: Parsed JSON body. - """ - return self._json - - def raise_for_status(self) -> None: - """ - Emulate :meth:`requests.Response.raise_for_status`. - - :return: ``None``. - :raises requests.HTTPError: If the response status is not 200. - """ - if self.status_code != 200: - err = requests.HTTPError(f"{self.status_code} Error") - # requests attaches a Response to HTTPError.response; the client expects it - err.response = self - raise err - - -@pytest.fixture -def stub() -> PdsFhirApiStub: - """ - Create a stub backend instance. - - :return: A :class:`stubs.stub_pds.PdsFhirApiStub` with strict header validation - enabled. - """ - # Strict header validation helps ensure PdsClient sends X-Request-ID correctly. - return PdsFhirApiStub(strict_headers=True) - - -@pytest.fixture -def mock_requests_get( - monkeypatch: pytest.MonkeyPatch, stub: PdsFhirApiStub -) -> dict[str, Any]: - """ - Patch ``PdsFhirApiStub`` so the PdsClient uses the test stub fixture. - - The fixture returns a "capture" dict recording the most recent request information. - This is used by header-related tests. - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend used to serve GET requests. - :return: A capture dictionary containing the last call details - (url/headers/params/timeout). - """ - capture: dict[str, Any] = {} - - # Wrap the stub's get method to capture call parameters - original_stub_get = stub.get - - def _capturing_get( - url: str, - headers: dict[str, str] | None = None, - params: Any = None, - timeout: Any = None, - ) -> requests.Response: - """ - Wrapper around stub.get that captures parameters. - - :param url: URL passed by the client. - :param headers: Headers passed by the client. - :param params: Query parameters. - :param timeout: Timeout. - :return: Response from the stub. - """ - headers = headers or {} - capture["url"] = url - capture["headers"] = dict(headers) - capture["params"] = params - capture["timeout"] = timeout - - return original_stub_get(url, headers, params, timeout) - - stub.get = _capturing_get # type: ignore[method-assign] - - # Monkeypatch PdsFhirApiStub so PdsClient uses our test stub - import gateway_api.pds_search as pds_module - - monkeypatch.setattr( - pds_module, - "PdsFhirApiStub", - lambda *args, **kwargs: stub, # NOQA ARG005 (maintain signature) - ) - - return capture - - -def _insert_basic_patient( - stub: PdsFhirApiStub, - nhs_number: str, - family: str, - given: list[str], - general_practitioner: list[dict[str, Any]] | None = None, -) -> None: - """ - Insert a basic Patient record into the stub. - - :param stub: Stub backend to insert into. - :param nhs_number: NHS number (10-digit string). - :param family: Family name for the Patient.name record. - :param given: Given names for the Patient.name record. - :param general_practitioner: Optional list stored under - ``Patient.generalPractitioner``. - :return: ``None``. - """ - stub.upsert_patient( - nhs_number=nhs_number, - patient={ - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": family, - "given": given, - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": general_practitioner or [], - }, - version_id=1, - ) - - -def test_search_patient_by_nhs_number_get_patient_success( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify ``GET /Patient/{nhs_number}`` returns 200 and demographics are extracted. - - This test explicitly inserts the patient into the stub and asserts that the client - returns a populated :class:`gateway_api.pds_search.PdsSearchResults`. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture - (ensures patching is active). - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - nhsd_session_urid="test-urid", - ) - - result = client.search_patient_by_nhs_number("9000000009") - - assert result is not None - assert result.nhs_number == "9000000009" - assert result.family_name == "Smith" - assert result.given_names == "Jane" - assert result.gp_ods_code is None - - -def test_search_patient_by_nhs_number_no_current_gp_returns_gp_ods_code_none( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify that ``gp_ods_code`` is ``None`` when no GP record is current. - - The generalPractitioner list may be: - * empty - * non-empty with no current record - * non-empty with exactly one current record - - This test covers the "non-empty, none current" case by - inserting only a historical GP record. - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000018", - family="Taylor", - given=["Ben"], - general_practitioner=[ - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - }, - } - ], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - result = client.search_patient_by_nhs_number("9000000018") - - assert result is not None - assert result.nhs_number == "9000000018" - assert result.family_name == "Taylor" - assert result.given_names == "Ben" - assert result.gp_ods_code is None - - -def test_search_patient_by_nhs_number_sends_expected_headers( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], -) -> None: - """ - Verify that the client sends the expected headers to PDS. - - Asserts that the request contains: - * Authorization header - * NHSD-End-User-Organisation-ODS header - * Accept header - * caller-provided X-Request-ID and X-Correlation-ID headers - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture capturing outbound - headers. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - req_id = str(uuid4()) - corr_id = "corr-123" - - result = client.search_patient_by_nhs_number( - "9000000009", - request_id=req_id, - correlation_id=corr_id, - ) - assert result is not None - - headers = mock_requests_get["headers"] - assert headers["Authorization"] == "Bearer test-token" - assert headers["NHSD-End-User-Organisation-ODS"] == "A12345" - assert headers["Accept"] == "application/fhir+json" - assert headers["X-Request-ID"] == req_id - assert headers["X-Correlation-ID"] == corr_id - - -def test_search_patient_by_nhs_number_generates_request_id( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], -) -> None: - """ - Verify that the client generates an X-Request-ID when not provided. - - The stub is in strict mode, so a missing or invalid X-Request-ID would cause a 400. - This test confirms a request ID is present and looks UUID-like. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture capturing outbound - headers. - :return: ``None``. - """ - _insert_basic_patient( - stub=stub, - nhs_number="9000000009", - family="Smith", - given=["Jane"], - general_practitioner=[], - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - result = client.search_patient_by_nhs_number("9000000009") - assert result is not None - - headers = mock_requests_get["headers"] - assert "X-Request-ID" in headers - assert isinstance(headers["X-Request-ID"], str) - assert len(headers["X-Request-ID"]) >= 32 - - -def test_search_patient_by_nhs_number_not_found_raises_error( - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify that a 404 response results in :class:`ExternalServiceError`. - - The stub returns a 404 OperationOutcome for unknown NHS numbers. The client calls - ``raise_for_status()``, which raises ``requests.HTTPError``; the client wraps that - into :class:`ExternalServiceError`. - - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - pds = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - with pytest.raises(ExternalServiceError): - pds.search_patient_by_nhs_number("9900000001") - - -def test_search_patient_by_nhs_number_extracts_current_gp_ods_code( - stub: PdsFhirApiStub, - mock_requests_get: dict[str, Any], # NOQA ARG001 (Mock not called directly) -) -> None: - """ - Verify that a current GP record is selected and its ODS code returned. - - The test inserts a patient with two GP records: - * one historical (not current) - * one current (period covers today) - - :param monkeypatch: Pytest monkeypatch fixture. - :param stub: Stub backend fixture. - :param mock_requests_get: Patched ``requests.get`` fixture. - :return: ``None``. - """ - stub.upsert_patient( - nhs_number="9000000017", - patient={ - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": "Taylor", - "given": ["Ben", "A."], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": [ - # Old - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - }, - }, - # Current - { - "id": "2", - "type": "Organization", - "identifier": { - "value": "CURRGP", - "period": {"start": "2020-01-01", "end": "9999-01-01"}, - }, - }, - ], - }, - version_id=1, - ) - - client = PdsClient( - auth_token="test-token", # noqa: S106 - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - result = client.search_patient_by_nhs_number("9000000017") - assert result is not None - assert result.nhs_number == "9000000017" - assert result.family_name == "Taylor" - assert result.given_names == "Ben A." - 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. - - :return: ``None``. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_dates = PdsClient("test-token", "A12345", ignore_dates=True) - - records = cast( - "ResultList", - [ - { - "identifier": { - "value": "a", - "period": {"start": "2020-01-01", "end": "2020-12-31"}, - } - }, - { - "identifier": { - "value": "b", - "period": {"start": "2021-01-01", "end": "2021-12-31"}, - } - }, - ], - ) - - 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. - - :return: ``None``. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_date = PdsClient("test-token", "A12345", ignore_dates=True) - - records = cast( - "ResultList", - [ - { - "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_invalid_body_raises_runtime_error() -> None: - """ - Verify that ``PdsClient._extract_single_search_result`` raises ``RuntimeError`` when - mandatory patient content is missing. - - This test asserts that a ``RuntimeError`` 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* - """ - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - end_user_org_ods="A12345", - base_url="https://example.test/personal-demographics/FHIR/R4", - ) - - # 1) Bundle contains no entries. - bundle_no_entries: Any = {"resourceType": "Bundle", "entry": []} - with pytest.raises(RuntimeError): - 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(RuntimeError): - 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 = cast( - "ResultList", - [ - { - "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(cast("ResultList", [])) 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 = cast( - "ResultList", - [ - { - "identifier": { - "value": "GP-OLD", - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - } - }, - { - "identifier": { - "value": "GP-NEWER", - "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_gp(records, today=date(2026, 1, 1)) - assert chosen == records[-1] - - assert pds_ignore.find_current_gp(cast("ResultList", [])) is None diff --git a/gateway-api/stubs/stubs/__init__.py b/gateway-api/stubs/stubs/__init__.py index e69de29b..a51ea8a2 100644 --- a/gateway-api/stubs/stubs/__init__.py +++ b/gateway-api/stubs/stubs/__init__.py @@ -0,0 +1,6 @@ +from .base_stub import StubBase +from .pds.stub import PdsFhirApiStub +from .provider.stub import GpProviderStub +from .stub_sds import SdsFhirApiStub + +__all__ = ["StubBase", "PdsFhirApiStub", "SdsFhirApiStub", "GpProviderStub"] diff --git a/gateway-api/stubs/stubs/base_stub.py b/gateway-api/stubs/stubs/base_stub.py new file mode 100644 index 00000000..b1df9bba --- /dev/null +++ b/gateway-api/stubs/stubs/base_stub.py @@ -0,0 +1,127 @@ +""" +Base class for FHIR API stubs. + +Provides common functionality for creating stub responses. +""" + +from __future__ import annotations + +import json +from abc import abstractmethod +from http.client import responses as http_responses +from typing import Any, Protocol + +from requests import Response +from requests.structures import CaseInsensitiveDict + + +class StubBase: + """ + Base class for FHIR API stubs. + + Provides common functionality for creating HTTP responses and defines + the interface that all stub implementations must provide. + + Recommended to subclass with GetStub or PostStub (or both) + """ + + @staticmethod + def _create_response( + status_code: int, + json_data: dict[str, Any], + additional_headers: dict[str, str] | None = None, + ) -> Response: + """ + Create a :class:`requests.Response` object for the stub. + """ + response = Response() + response.status_code = status_code + headers = {"Content-Type": "application/fhir+json"} + if additional_headers is not None: + headers.update(additional_headers) + response.headers = CaseInsensitiveDict(headers) + response._content = json.dumps(json_data).encode("utf-8") # noqa: SLF001 to customise stub + response.encoding = "utf-8" + # Set a reason phrase for HTTP error handling + response.reason = http_responses.get(status_code, "Unknown") + return response + + +class GetStub(Protocol): + @abstractmethod + def get( + self, url: str, headers: dict[str, str], params: dict[str, Any], timeout: int + ) -> Response: + """ + Handle HTTP GET requests for the stub. + """ + + @property + @abstractmethod + def get_url(self) -> str: + """ + Last URL stub.get was called with. Empty string if not called yet. + """ + + @property + @abstractmethod + def get_headers(self) -> dict[str, str]: + """ + Dict of last headers stub.get was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def get_params(self) -> dict[str, str]: + """ + Dict of last get parameters stub.get was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def get_timeout(self) -> int | None: + """ + Last timeout value stub.get was called with. None if not called yet. + """ + + +class PostStub(Protocol): + @abstractmethod + def post( + self, + url: str, + data: bytes | dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Response: + """ + Handle HTTP POST requests for the stub. + """ + + @property + @abstractmethod + def post_url(self) -> str: + """ + Last URL stub.post was called with. Empty string if not called yet. + """ + + @property + @abstractmethod + def post_headers(self) -> dict[str, str]: + """ + Dict of last headers stub.post was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def post_data(self) -> str: + """ + Last post request body stub.post was called with. Empty if not called yet. + """ + + @property + @abstractmethod + def post_timeout(self) -> int | None: + """ + Last timeout value stub.post was called with. None if not called yet. + """ diff --git a/gateway-api/stubs/stubs/data/__init__.py b/gateway-api/stubs/stubs/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/data/bundles/__init__.py b/gateway-api/stubs/stubs/data/bundles/__init__.py new file mode 100644 index 00000000..d714c29d --- /dev/null +++ b/gateway-api/stubs/stubs/data/bundles/__init__.py @@ -0,0 +1,20 @@ +from typing import Any + +from stubs.data.patients import Patients + + +class Bundles: + @staticmethod + def _wrap_patient_in_bundle(patient: dict[str, Any]) -> dict[str, Any]: + return { + "resourceType": "Bundle", + "type": "collection", + "meta": { + "profile": [ + "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" + ] + }, + "entry": [{"resource": patient}], + } + + ALICE_JONES_9999999999 = _wrap_patient_in_bundle(Patients.ALICE_JONES_9999999999) diff --git a/gateway-api/stubs/stubs/data/patients/__init__.py b/gateway-api/stubs/stubs/data/patients/__init__.py new file mode 100644 index 00000000..a1595f52 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/__init__.py @@ -0,0 +1,28 @@ +import json +import pathlib +from typing import Any + + +def _path_to_here() -> pathlib.Path: + return pathlib.Path(__file__).parent + + +class Patients: + @staticmethod + def load_patient(filename: str) -> dict[str, Any]: + with open(_path_to_here() / filename, encoding="utf-8") as f: + patient: dict[str, Any] = json.load(f) + return patient + + JANE_SMITH_9000000009 = load_patient("jane_smith_9000000009.json") + NO_SDS_RESULT_9000000010 = load_patient("no_sds_result_9000000010.json") + BLANK_ASID_SDS_RESULT_9000000011 = load_patient( + "blank_asid_sds_result_9000000011.json" + ) + INDUCE_PROVIDER_ERROR_9000000012 = load_patient( + "induce_provider_error_9000000012.json" + ) + BLANK_ENDPOINT_SDS_RESULT_9000000013 = load_patient( + "blank_endpoint_sds_result_9000000013.json" + ) + ALICE_JONES_9999999999 = load_patient("alice_jones_9999999999.json") diff --git a/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json new file mode 100644 index 00000000..558a4e30 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9999999999", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + ], + "name": [ + { + "use": "official", + "family": "Jones", + "given": ["Alice"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1980-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01", "end": "9999-12-31"} + } + } + ] +} 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 new file mode 100644 index 00000000..58b47242 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000011", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000011" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "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 new file mode 100644 index 00000000..1e3645b6 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000013", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000013" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "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 new file mode 100644 index 00000000..94ed1c30 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000012", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000012" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json b/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json new file mode 100644 index 00000000..81b0ce5f --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/jane_smith_9000000009.json @@ -0,0 +1,24 @@ +{ + "resourceType": "Patient", + "id": "9000000009", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-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 new file mode 100644 index 00000000..f43198ba --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000010", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000010" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "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 new file mode 100644 index 00000000..6834ebe6 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9000000013", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000013" + } + ], + "name": [ + { + "use": "official", + "family": "Smith", + "given": ["Jane"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "female", + "birthDate": "1970-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "BlankConsumerRequest", + "period": {"start": "2020-01-01"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/pds/__init__.py b/gateway-api/stubs/stubs/pds/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/stub_pds.py b/gateway-api/stubs/stubs/pds/stub.py similarity index 82% rename from gateway-api/stubs/stubs/stub_pds.py rename to gateway-api/stubs/stubs/pds/stub.py index f8249295..c3c01293 100644 --- a/gateway-api/stubs/stubs/stub_pds.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -4,8 +4,6 @@ The stub does **not** implement the full PDS API surface, nor full FHIR validation. """ -from __future__ import annotations - import json import re import uuid @@ -16,6 +14,8 @@ from requests import Response from requests.structures import CaseInsensitiveDict +from stubs.data.patients import Patients + def _create_response( status_code: int, @@ -70,73 +70,20 @@ def __init__(self, strict_headers: bool = True) -> None: # Seed a deterministic example matching the spec's id example. # Tests may overwrite this record via upsert_patient. - self.upsert_patient( - nhs_number="9000000009", - patient={ - "resourceType": "Patient", - "id": "9000000009", - "meta": { - "versionId": "1", - "lastUpdated": "2020-01-01T00:00:00Z", - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9000000009", - } - ], - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "gender": "female", - "birthDate": "1970-01-01", - }, - version_id=1, - ) - - self.upsert_patient( - nhs_number="9999999999", - patient={ - "resourceType": "Patient", - "id": "9999999999", - "meta": { - "versionId": "1", - "lastUpdated": "2020-01-01T00:00:00Z", - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [ - { - "use": "official", - "family": "Jones", - "given": ["Alice"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "gender": "female", - "birthDate": "1980-01-01", - "generalPractitioner": [ - { - "id": "1", - "type": "Organization", - "identifier": { - "value": "A12345", - "period": {"start": "2020-01-01", "end": "9999-12-31"}, - }, - } - ], - }, - version_id=1, - ) + test_patients = [ + ("9999999999", Patients.ALICE_JONES_9999999999), + ("9000000009", Patients.JANE_SMITH_9000000009), + ("9000000010", Patients.NO_SDS_RESULT_9000000010), + ("9000000011", Patients.BLANK_ASID_SDS_RESULT_9000000011), + ("9000000012", Patients.INDUCE_PROVIDER_ERROR_9000000012), + ("9000000013", Patients.BLANK_ENDPOINT_SDS_RESULT_9000000013), + ] + for nhs_number, patient in test_patients: + self.upsert_patient( + nhs_number=nhs_number, + patient=patient, + version_id=1, + ) # --------------------------- # Public API for tests @@ -255,7 +202,7 @@ def get_patient( headers_out["ETag"] = f'W/"{version_id}"' return _create_response(status_code=200, headers=headers_out, json_data=patient) - def get( + def post( self, url: str, headers: dict[str, Any] | None = None, @@ -275,8 +222,6 @@ def get( request_id = headers.get("X-Request-ID") correlation_id = headers.get("X-Correlation-ID") authorization = headers.get("Authorization") - role_id = headers.get("NHSD-Session-URID") - end_user_org_ods = headers.get("NHSD-End-User-Organisation-ODS") return self.get_patient( nhs_number=nhs_number, diff --git a/gateway-api/stubs/stubs/provider/__init__.py b/gateway-api/stubs/stubs/provider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/stubs/stubs/provider/stub.py b/gateway-api/stubs/stubs/provider/stub.py new file mode 100644 index 00000000..0e157505 --- /dev/null +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -0,0 +1,143 @@ +""" +Minimal in-memory stub for a Provider GP System FHIR API, +implementing only accessRecordStructured to read basic +demographic data for a single patient. + +Contract elements for direct provider calls are inferred from +GPConnect documentation: +https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development_retrieve_patient_record.html + - Method: POST + - fhir_base: /FHIR/STU3 + - resource: /Patient + - fhir_operation: $gpc.getstructuredrecord + +Headers: + Ssp-TraceID: Consumer's Trace ID (a GUID or UUID) + Ssp-From: Consumer's ASID + Ssp-To: Provider's ASID + Ssp-InteractionID: + urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1 + +Request Body JSON (FHIR STU3 Parameters resource with patient NHS number. +""" + +import json +from typing import Any + +from requests import Response + +from stubs.base_stub import StubBase +from stubs.data.bundles import Bundles + + +class GpProviderStub(StubBase): + """ + A minimal in-memory stub for a Provider GP System FHIR API, + implementing only accessRecordStructured to read basic + demographic data for a single patient. + + Seeded with an example + FHIR/STU3 Patient resource with only administrative data based on Example 2 + # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 + """ + + def access_record_structured( + self, + trace_id: str, + body: str, # NOQA ARG002 # NOSONAR S1172: unused parameter maintains method signature in stub + ) -> Response: + """ + Simulate accessRecordStructured operation of GPConnect FHIR API. + + returns: + Response: The stub patient bundle wrapped in a Response object. + """ + + if trace_id == "invalid for test": + return self._create_response( + status_code=400, + json_data={ + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Invalid for testing", + } + ], + }, + ) + + try: + nhs_number = json.loads(body)["parameter"][0]["valueIdentifier"]["value"] + except (json.JSONDecodeError, KeyError, IndexError): + return self._create_response( + status_code=400, + json_data={ + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Malformed request body", + } + ], + }, + ) + + if nhs_number == "9999999999": + return self._create_response( + status_code=200, + json_data=Bundles.ALICE_JONES_9999999999, + ) + + return self._create_response( + status_code=404, + json_data={ + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "not-found", + "diagnostics": "Patient not found", + } + ], + }, + ) + + def post( + self, + _url: str, + data: str, + _json: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Response: + """ + Handle HTTP POST requests for the stub. + + :param url: Request URL. + :param headers: Request headers. + :param data: Request body data. + :param timeout: Request timeout in seconds. + :return: A :class:`requests.Response` instance. + """ + trace_id = kwargs.get("headers", {}).get("Ssp-TraceID", "no-trace-id") + return self.access_record_structured(trace_id, data) + + def get( + self, + url: str, + headers: dict[str, str], + params: dict[str, Any], + timeout: int, + ) -> Response: + """ + Handle HTTP GET requests for the stub. + + :param url: Request URL. + :param headers: Request headers. + :param params: Query parameters. + :param timeout: Request timeout in seconds. + :raises NotImplementedError: GET requests are not supported by this stub. + """ + raise NotImplementedError("GET requests are not supported by GpProviderStub") diff --git a/gateway-api/stubs/stubs/stub_provider.py b/gateway-api/stubs/stubs/stub_provider.py deleted file mode 100644 index 2d0c96ba..00000000 --- a/gateway-api/stubs/stubs/stub_provider.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -Minimal in-memory stub for a Provider GP System FHIR API, -implementing only accessRecordStructured to read basic -demographic data for a single patient. - -Contract elements for direct provider calls are inferred from -GPConnect documentation: -https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development_retrieve_patient_record.html - - Method: POST - - fhir_base: /FHIR/STU3 - - resource: /Patient - - fhir_operation: $gpc.getstructuredrecord - -Headers: - Ssp-TraceID: Consumer's Trace ID (a GUID or UUID) - Ssp-From: Consumer's ASID - Ssp-To: Provider's ASID - Ssp-InteractionID: - urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1 - -Request Body JSON (FHIR STU3 Parameters resource with patient NHS number. -""" - -import json -from typing import Any - -from gateway_api.common.common import json_str -from requests import Response -from requests.structures import CaseInsensitiveDict - - -def _create_response( - status_code: int, - headers: dict[str, str] | CaseInsensitiveDict[str], - content: bytes, - reason: str = "", -) -> Response: - """ - Create a :class:`requests.Response` object for the stub. - - :param status_code: HTTP status code. - :param headers: Response headers dictionary. - :param content: Response body as bytes. - :param reason: HTTP reason phrase (e.g., "OK", "Bad Request"). - :return: A :class:`requests.Response` instance. - """ - response = Response() - response.status_code = status_code - response.headers = CaseInsensitiveDict(headers) - response._content = content # noqa: SLF001 - response.reason = reason - response.encoding = "utf-8" - return response - - -class GpProviderStub: - """ - A minimal in-memory stub for a Provider GP System FHIR API, - implementing only accessRecordStructured to read basic - demographic data for a single patient. - - Seeded with an example - FHIR/STU3 Patient resource with only administrative data based on Example 2 - # https://simplifier.net/guide/gp-connect-access-record-structured/Home/Examples/Allergy-examples?version=1.6.2 - """ - - # Example patient resource - patient_bundle = { - "resourceType": "Bundle", - "type": "collection", - "meta": { - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1" - ] - }, - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", - "meta": { - "versionId": "1469448000000", - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], - }, - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "active": True, - "name": [ - { - "use": "official", - "text": "JACKSON Jane (Miss)", - "family": "Jackson", - "given": ["Jane"], - "prefix": ["Miss"], - } - ], - "gender": "female", - "birthDate": "1952-05-31", - } - } - ], - } - - def access_record_structured( - self, - trace_id: str, - body: str, # NOQA ARG002 # NOSONAR S1172: unused parameter maintains method signature in stub - ) -> Response: - """ - Simulate accessRecordStructured operation of GPConnect FHIR API. - - returns: - Response: The stub patient bundle wrapped in a Response object. - """ - - stub_response = _create_response( - status_code=200, - headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - content=json.dumps(self.patient_bundle).encode("utf-8"), - reason="OK", - ) - - if trace_id == "invalid for test": - return _create_response( - status_code=400, - headers=CaseInsensitiveDict({"Content-Type": "application/fhir+json"}), - content=( - b'{"resourceType":"OperationOutcome","issue":[' - b'{"severity":"error","code":"invalid",' - b'"diagnostics":"Invalid for testing"}]}' - ), - reason="Bad Request", - ) - - return stub_response - - -def stub_post( - url: str, # NOQA ARG001 # NOSONAR S1172 (unused in stub) - headers: dict[str, Any], - data: json_str, - timeout: int, # NOQA ARG001 # NOSONAR S1172 (unused in stub) -) -> Response: - """A stubbed requests.post function that routes to the GPProviderStub.""" - _provider_stub = GpProviderStub() - trace_id = headers.get("Ssp-TraceID", "no-trace-id") - return _provider_stub.access_record_structured(trace_id, data) diff --git a/gateway-api/stubs/stubs/stub_sds.py b/gateway-api/stubs/stubs/stub_sds.py new file mode 100644 index 00000000..9d77d432 --- /dev/null +++ b/gateway-api/stubs/stubs/stub_sds.py @@ -0,0 +1,715 @@ +""" +In-memory SDS FHIR R4 API stub. + +The stub does **not** implement the full SDS API surface, nor full FHIR validation. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID + +from stubs.base_stub import GetStub, StubBase + +if TYPE_CHECKING: + from requests import Response + + +class SdsFhirApiStub(StubBase, GetStub): + """ + Minimal in-memory stub for the SDS FHIR API, implementing ``GET /Device`` + and ``GET /Endpoint`` + + Contract elements modelled from the SDS OpenAPI spec: + + * ``/Device`` requires query params: + - ``organization`` (required): ODS code with FHIR identifier prefix + - ``identifier`` (required, repeatable): Service interaction ID and/or party key + - ``manufacturing-organization`` (optional): Manufacturing org ODS code + * ``/Endpoint`` requires query param: + - ``identifier`` (required, repeatable): Service interaction ID and/or party key + - ``organization`` (optional): ODS code with FHIR identifier prefix + * ``X-Correlation-Id`` is optional and echoed back if supplied + * ``apikey`` header is required (but any value accepted in stub mode) + * Returns a FHIR Bundle with ``resourceType: "Bundle"`` and ``type: "searchset"`` + + See: + https://github.com/NHSDigital/spine-directory-service-api + """ + + ODS_SYSTEM = "https://fhir.nhs.uk/Id/ods-organization-code" + INTERACTION_SYSTEM = "https://fhir.nhs.uk/Id/nhsServiceInteractionId" + PARTYKEY_SYSTEM = "https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ASID_SYSTEM = "https://fhir.nhs.uk/Id/nhsSpineASID" + CONNECTION_SYSTEM = ( + "https://terminology.hl7.org/CodeSystem/endpoint-connection-type" + ) + CODING_SYSTEM = "https://terminology.hl7.org/CodeSystem/endpoint-payload-type" + CONNECTION_DISPLAY = "HL7 FHIR" + + def __init__(self) -> None: + """ + Create a new stub instance. + + :param strict_validation: If ``True``, enforce required query parameters and + apikey header. If ``False``, validation is relaxed. + """ + # Internal store: (org_ods, interaction_id, party_key) -> list[device_resource] + # party_key may be None if not specified + self._devices: defaultdict[ + tuple[str, str, str | None], list[dict[str, Any]] + ] = defaultdict(list) + + # Internal store for endpoints: + # (org_ods, interaction_id, party_key) -> list[endpoint_resource] + # org_ods and/or interaction_id may be None since they're optional for + # endpoint queries + self._endpoints: defaultdict[ + tuple[str | None, str | None, str | None], list[dict[str, Any]] + ] = defaultdict(list) + + # Seed some deterministic examples matching common test scenarios + self._seed_default_devices() + self._seed_default_endpoints() + + self._last_headers: dict[str, str] = {} + self._last_params: dict[str, str] = {} + self._last_url: str = "" + self._last_timeout: int | None = None + + @property + def get_headers(self) -> dict[str, str]: + return self._last_headers + + @property + def get_params(self) -> dict[str, str]: + return self._last_params + + @property + def get_url(self) -> str: + return self._last_url + + @property + def get_timeout(self) -> int | None: + return self._last_timeout + + # --------------------------- + # Public API for tests + # --------------------------- + + def upsert_device( + self, + organization_ods: str, + service_interaction_id: str, + party_key: str | None, + device: dict[str, Any], + ) -> None: + """ + Insert or append a Device record in the stub store. + + Multiple devices can be registered for the same query combination (they will + all be returned in the Bundle.entry array). + + :param organization_ods: Organization ODS code. + :param service_interaction_id: Service interaction ID. + :param party_key: Optional MHS party key. + :param device: Device resource dictionary. + """ + key = (organization_ods, service_interaction_id, party_key) + self._devices[key].append(device) + + def clear_devices(self) -> None: + """Clear all Device records from the stub.""" + self._devices.clear() + + def upsert_endpoint( + self, + organization_ods: str | None, + service_interaction_id: str | None, + party_key: str | None, + endpoint: dict[str, Any], + ) -> None: + """ + Insert or append an Endpoint record in the stub store. + + Multiple endpoints can be registered for the same query combination (they will + all be returned in the Bundle.entry array). + + :param organization_ods: Organization ODS code (optional for endpoints). + :param service_interaction_id: Service interaction ID (optional for endpoints). + :param party_key: Optional MHS party key. + :param endpoint: Endpoint resource dictionary. + """ + key = (organization_ods, service_interaction_id, party_key) + self._endpoints[key].append(endpoint) + + def clear_endpoints(self) -> None: + """Clear all Endpoint records from the stub.""" + self._endpoints.clear() + + def get_device_bundle( + self, + url: str, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + headers: dict[str, str], + params: dict[str, Any], + timeout: int | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + ) -> Response: + """ + Implements ``GET /Device``. + + :param url: Request URL (expected to end with /Device). + :param headers: Request headers. Must include ``apikey``. + May include ``X-Correlation-Id``. + :param params: Query parameters dictionary. Must include ``organization`` and + ``identifier`` (list). + :param timeout: Timeout (ignored by the stub). + :return: A :class:`requests.Response` representing either: + * ``200`` with Bundle JSON (may be empty) + * ``400`` with error details for missing/invalid parameters + """ + headers = headers or {} + params = params or {} + + headers_out: dict[str, str] = {} + + # Echo correlation ID if provided + correlation_id = headers.get("X-Correlation-Id") + if correlation_id: + headers_out["X-Correlation-Id"] = correlation_id + + # Validate apikey header + if "apikey" not in headers: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required header: apikey", + ) + + # Always validate required query parameters (not just in strict mode) + organization = params.get("organization") + identifier = params.get("identifier") + + if not organization: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required query parameter: organization", + ) + if not identifier: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required query parameter: identifier", + ) + + # Parse organization ODS code + org_ods = self._extract_param_value(organization, self.ODS_SYSTEM) + + # Parse identifier list (can be string or list) + # if isinstance(identifier, str): + identifier_list = [identifier] if isinstance(identifier, str) else identifier + # else: + # identifier_list = identifier + + service_interaction_id: str | None = None + party_key: str | None = None + + for ident in identifier_list: + if self.INTERACTION_SYSTEM in ident: + service_interaction_id = self._extract_param_value( + ident, self.INTERACTION_SYSTEM + ) + elif self.PARTYKEY_SYSTEM in ident: + party_key = self._extract_param_value(ident, self.PARTYKEY_SYSTEM) + + # Always validate service interaction ID is present + if not service_interaction_id: + return self._error_response( + status_code=400, + headers=headers_out, + message="identifier must include nhsServiceInteractionId", + ) + + # Look up devices + devices = self._lookup_devices( + org_ods=org_ods or "", + service_interaction_id=service_interaction_id or "", + party_key=party_key, + ) + + # Build FHIR Bundle response + bundle = self._build_bundle(devices) + + return self._create_response( + status_code=200, additional_headers=headers_out, json_data=bundle + ) + + def get_endpoint_bundle( + self, + url: str, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + timeout: int | None = None, # noqa: ARG002 # NOSONAR S1172 (ignored in stub) + ) -> Response: + """ + Implements ``GET /Endpoint``. + + :param url: Request URL (expected to end with /Endpoint). + :param headers: Request headers. Must include ``apikey`. + May include ``X-Correlation-Id``. + :param params: Query parameters dictionary. Must include ``identifier`` (list). + ``organization`` is optional. + :param timeout: Timeout (ignored by the stub). + :return: A :class:`requests.Response` representing either: + * ``200`` with Bundle JSON (may be empty) + * ``400`` with error details for missing/invalid parameters + """ + headers = headers or {} + params = params or {} + + headers_out: dict[str, str] = {} + + # Echo correlation ID if provided + correlation_id = headers.get("X-Correlation-Id") + if correlation_id: + headers_out["X-Correlation-Id"] = correlation_id + + # Validate apikey header + if "apikey" not in headers: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required header: apikey", + ) + + # Always validate required query parameters (not just in strict mode) + identifier = params.get("identifier") + organization = params.get("organization") + + if not identifier: + return self._error_response( + status_code=400, + headers=headers_out, + message="Missing required query parameter: identifier", + ) + + # Parse organization ODS code (optional) + org_ods: str | None = None + if organization: + org_ods = self._extract_param_value(organization, self.ODS_SYSTEM) + + # Parse identifier list (can be string or list) + if isinstance(identifier, str): + identifier = [identifier] + + service_interaction_id: str | None = None + party_key: str | None = None + + for ident in identifier or []: + if self.INTERACTION_SYSTEM in ident: + service_interaction_id = self._extract_param_value( + ident, self.INTERACTION_SYSTEM + ) + elif self.PARTYKEY_SYSTEM in ident: + party_key = self._extract_param_value(ident, self.PARTYKEY_SYSTEM) + + # Look up endpoints + endpoints = self._lookup_endpoints( + org_ods=org_ods, + service_interaction_id=service_interaction_id, + party_key=party_key, + ) + + # Build FHIR Bundle response + bundle = self._build_endpoint_bundle(endpoints) + + return self._create_response( + status_code=200, additional_headers=headers_out, json_data=bundle + ) + + def get( + self, + url: str, + headers: dict[str, str], + params: dict[str, Any], + timeout: int = 10, + ) -> Response: + """ + Convenience method matching requests.get signature for easy monkeypatching. + + Routes to the appropriate handler based on the URL path. + + :param url: Request URL. + :param headers: Request headers. + :param params: Query parameters. + :param timeout: Timeout value. + :return: A :class:`requests.Response`. + """ + self._last_url = url + self._last_headers = headers + self._last_params = params + self._last_timeout = timeout + + if "/Endpoint" in url: + return self.get_endpoint_bundle( + url=url, headers=headers, params=params, timeout=timeout + ) + return self.get_device_bundle( + url=url, headers=headers, params=params, timeout=timeout + ) + + # --------------------------- + # Internal helpers + # --------------------------- + + def _seed_default_devices(self) -> None: + """Seed the stub with some default Device records for testing.""" + # Define test device data as a list of parameters + device_data = [ + { + "org_ods": "PROVIDER", + "party_key": "PROVIDER-0000806", + "device_id": "F0F0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_PROV", + "display": "Example NHS Trust", + }, + { + "org_ods": "CONSUMER", + "party_key": "CONSUMER-0000807", + "device_id": "C0C0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_CONS", + "display": "Example Consumer Organisation", + }, + { + "org_ods": "A12345", + "party_key": "A12345-0000808", + "device_id": "A1A1E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_A12345", + "display": "Example GP Practice A12345", + }, + ] + + # Iterate through test data and create devices + for data in device_data: + self.upsert_device( + organization_ods=data["org_ods"], + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key=data["party_key"], + device=self._create_device_resource( + device_id=data["device_id"], + asid=data["asid"], + party_key=data["party_key"], + org_ods=data["org_ods"], + display=data["display"], + ), + ) + + def _seed_default_endpoints(self) -> None: + """Seed the stub with some default Endpoint records for testing.""" + # Define test endpoint data as a list of parameters + endpoint_data = [ + { + "org_ods": "PROVIDER", + "party_key": "PROVIDER-0000806", + "endpoint_id": "E0E0E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_PROV", + "address": "https://provider.example.com/fhir", + }, + { + "org_ods": "CONSUMER", + "party_key": "CONSUMER-0000807", + "endpoint_id": "E1E1E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_CONS", + "address": "https://consumer.example.com/fhir", + }, + { + "org_ods": "A12345", + "party_key": "A12345-0000808", + "endpoint_id": "E2E2E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "asid_A12345", + "address": "https://a12345.example.com/fhir", + }, + ] + + # Iterate through test data and create endpoints + for data in endpoint_data: + self.upsert_endpoint( + organization_ods=data["org_ods"], + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key=data["party_key"], + endpoint=self._create_endpoint_resource( + endpoint_id=data["endpoint_id"], + asid=data["asid"], + party_key=data["party_key"], + org_ods=data["org_ods"], + address=data["address"], + ), + ) + + def _create_device_resource( + self, + device_id: str, + asid: str, + party_key: str, + org_ods: str, + display: str, + ) -> dict[str, Any]: + """Create a Device resource dictionary with the given parameters.""" + return { + "resourceType": "Device", + "id": device_id, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": asid, + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": party_key, + }, + ], + "owner": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": org_ods, + }, + "display": display, + }, + } + + def _create_endpoint_resource( + self, + endpoint_id: str, + asid: str, + party_key: str, + org_ods: str, + address: str, + ) -> dict[str, Any]: + """Create an Endpoint resource dictionary with the given parameters.""" + return { + "resourceType": "Endpoint", + "id": endpoint_id, + "status": "active", + "connectionType": { + "system": self.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": self.CONNECTION_DISPLAY, + }, + "payloadType": [ + { + "coding": [ + { + "system": self.CODING_SYSTEM, + "code": "any", + "display": "Any", + } + ] + } + ], + "address": address, + "managingOrganization": { + "identifier": { + "system": self.ODS_SYSTEM, + "value": org_ods, + } + }, + "identifier": [ + { + "system": self.ASID_SYSTEM, + "value": asid, + }, + { + "system": self.PARTYKEY_SYSTEM, + "value": party_key, + }, + ], + } + + def _lookup_devices( + self, org_ods: str, service_interaction_id: str, party_key: str | None + ) -> list[dict[str, Any]]: + """ + Look up devices matching the query parameters. + + :param org_ods: Organization ODS code. + :param service_interaction_id: Service interaction ID. + :param party_key: Optional party key. + :return: List of matching Device resources. + """ + # Exact match with party key (or None) + key = (org_ods, service_interaction_id, party_key) + if key in self._devices: + return list(self._devices[key]) + + # If no party_key was provided (None), search for any entries with the + # same org+interaction + # This allows querying without knowing the party_key upfront + if party_key is None: + for stored_key, devices in self._devices.items(): + stored_org, stored_interaction, _ = stored_key + if ( + stored_org == org_ods + and stored_interaction == service_interaction_id + ): + return list(devices) + + # If party_key was provided but no exact match, try without party key + if party_key: + key_without_party = (org_ods, service_interaction_id, None) + if key_without_party in self._devices: + return list(self._devices[key_without_party]) + + return [] + + def _lookup_endpoints( + self, + org_ods: str | None, + service_interaction_id: str | None, + party_key: str | None, + ) -> list[dict[str, Any]]: + """ + Look up endpoints matching the query parameters. + + For /Endpoint, the query combinations are more flexible: + - organization + service_interaction_id + party_key + - organization + party_key + - organization + service_interaction_id + - service_interaction_id + party_key + + :param org_ods: Organization ODS code (optional). + :param service_interaction_id: Service interaction ID (optional). + :param party_key: Optional party key. + :return: List of matching Endpoint resources. + """ + results = [] + + # Try to find exact matches and partial matches + for key, endpoints in self._endpoints.items(): + stored_org, stored_interaction, stored_party = key + + # Check if the query parameters match + org_match = org_ods is None or stored_org is None or org_ods == stored_org + interaction_match = ( + service_interaction_id is None + or stored_interaction is None + or service_interaction_id == stored_interaction + ) + party_match = ( + party_key is None or stored_party is None or party_key == stored_party + ) + + # If all specified parameters match, include these endpoints + if org_match and interaction_match and party_match: + # But at least one must be non-None and match + has_match = ( + (org_ods and stored_org and org_ods == stored_org) + or ( + service_interaction_id + and stored_interaction + and service_interaction_id == stored_interaction + ) + or (party_key and stored_party and party_key == stored_party) + ) + if has_match: + results.extend(endpoints) + + return results + + def _build_bundle(self, devices: list[dict[str, Any]]) -> dict[str, Any]: + """ + Build a FHIR Bundle from a list of Device resources. + + :param devices: List of Device resources. + :return: FHIR Bundle dictionary. + """ + entries = [] + for device in devices: + device_id = device.get("id", "unknown") + entries.append( + { + "fullUrl": f"https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Device/{device_id}", + "resource": device, + "search": {"mode": "match"}, + } + ) + + return { + "resourceType": "Bundle", + "type": "searchset", + "total": len(devices), + "entry": entries, + } + + def _build_endpoint_bundle(self, endpoints: list[dict[str, Any]]) -> dict[str, Any]: + """ + Build a FHIR Bundle from a list of Endpoint resources. + + :param endpoints: List of Endpoint resources. + :return: FHIR Bundle dictionary. + """ + entries = [] + for endpoint in endpoints: + endpoint_id = endpoint.get("id", "unknown") + entries.append( + { + "fullUrl": f"https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Endpoint/{endpoint_id}", + "resource": endpoint, + "search": {"mode": "match"}, + } + ) + + return { + "resourceType": "Bundle", + "type": "searchset", + "total": len(endpoints), + "entry": entries, + } + + @staticmethod + def _extract_param_value(param: str, system: str) -> str | None: + """ + Extract the value from a FHIR-style parameter like 'system|value'. + + :param param: Parameter string in format 'system|value'. + :param system: Expected system URL. + :return: The value part, or None if not found. + """ + if not param or "|" not in param: + return None + + parts = param.split("|", 1) + if len(parts) != 2: + return None + + param_system, param_value = parts + if param_system == system: + return param_value.strip() + + return None + + def _error_response( + self, status_code: int, headers: dict[str, str], message: str + ) -> Response: + """ + Build an error response. + + :param status_code: HTTP status code. + :param headers: Response headers. + :param message: Error message. + :return: A :class:`requests.Response` with error details. + """ + body = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": message, + } + ], + } + return self._create_response( + status_code=status_code, additional_headers=dict(headers), json_data=body + ) diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index 3485f224..bf777cbe 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -6,7 +6,7 @@ import requests from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when -from stubs.stub_provider import GpProviderStub +from stubs.data.bundles import Bundles from tests.acceptance.conftest import ResponseContext from tests.conftest import Client @@ -60,6 +60,6 @@ def check_status_code(response_context: ResponseContext, expected_status: int) - @then("the response should contain the patient bundle from the provider") def check_response_matches_provider(response_context: ResponseContext) -> None: assert response_context.response, "Response has not been set." - assert response_context.response.json() == GpProviderStub.patient_bundle, ( + assert response_context.response.json() == Bundles.ALICE_JONES_9999999999, ( "Expected response payload does not match actual response payload." ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 7fef2c54..d9279a8e 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -11,7 +11,7 @@ # Load environment variables from .env file in the workspace root # find_dotenv searches upward from current directory for .env file -load_dotenv(find_dotenv()) +load_dotenv(find_dotenv(usecwd=True)) class Client: @@ -30,7 +30,7 @@ def send_to_get_structured_record_endpoint( url = f"{self.base_url}/patient/$gpc.getstructuredrecord" default_headers = { "Content-Type": "application/fhir+json", - "Ods-from": "test-ods-code", + "Ods-from": "A12345", "Ssp-TraceID": "test-trace-id", } if headers: @@ -68,6 +68,15 @@ def simple_request_payload() -> Parameters: } +@pytest.fixture +def happy_path_headers() -> dict[str, str]: + return { + "Content-Type": "application/fhir+json", + "Ods-from": "A12345", + "Ssp-TraceID": "test-trace-id", + } + + @pytest.fixture(scope="module") def client(base_url: str) -> Client: """Create a test client for the application.""" diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 12c8a5cf..7c93ed79 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -55,10 +55,22 @@ "entry": [ { "resource": { - "active": true, - "birthDate": "1952-05-31", + "birthDate": "1980-01-01", "gender": "female", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "generalPractitioner": [ + { + "id": "1", + "identifier": { + "period": { + "end": "9999-12-31", + "start": "2020-01-01" + }, + "value": "A12345" + }, + "type": "Organization" + } + ], + "id": "9999999999", "identifier": [ { "system": "https://fhir.nhs.uk/Id/nhs-number", @@ -66,21 +78,19 @@ } ], "meta": { - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], - "versionId": "1469448000000" + "lastUpdated": "2020-01-01T00:00:00Z", + "versionId": "1" }, "name": [ { - "family": "Jackson", + "family": "Jones", "given": [ - "Jane" - ], - "prefix": [ - "Miss" + "Alice" ], - "text": "JACKSON Jane (Miss)", + "period": { + "end": "9999-12-31", + "start": "1900-01-01" + }, "use": "official" } ], diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index cf1998c3..7c9bffee 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -34,12 +34,10 @@ def test_get_structured_record(self) -> None: { "resource": { "resourceType": "Patient", - "id": "04603d77-1a4e-4d63-b246-d7504f8bd833", + "id": "9999999999", "meta": { - "versionId": "1469448000000", - "profile": [ - "https://fhir.nhs.uk/STU3/StructureDefinition/CareConnect-GPC-Patient-1" - ], + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z", }, "identifier": [ { @@ -47,18 +45,29 @@ def test_get_structured_record(self) -> None: "value": "9999999999", } ], - "active": True, "name": [ { "use": "official", - "text": "JACKSON Jane (Miss)", - "family": "Jackson", - "given": ["Jane"], - "prefix": ["Miss"], + "family": "Jones", + "given": ["Alice"], + "period": {"start": "1900-01-01", "end": "9999-12-31"}, } ], "gender": "female", - "birthDate": "1952-05-31", + "birthDate": "1980-01-01", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "A12345", + "period": { + "start": "2020-01-01", + "end": "9999-12-31", + }, + }, + } + ], } } ], @@ -128,10 +137,7 @@ def test_get_structured_record(self) -> None: assert body["type"] == "collection" assert len(body["entry"]) == 1 assert body["entry"][0]["resource"]["resourceType"] == "Patient" - assert ( - body["entry"][0]["resource"]["id"] - == "04603d77-1a4e-4d63-b246-d7504f8bd833" - ) + assert body["entry"][0]["resource"]["id"] == "9999999999" assert ( body["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" ) diff --git a/gateway-api/tests/data/patient/pds_fhir_example.json b/gateway-api/tests/data/patient/pds_fhir_example.json new file mode 100644 index 00000000..2c590256 --- /dev/null +++ b/gateway-api/tests/data/patient/pds_fhir_example.json @@ -0,0 +1,390 @@ +{ + "resourceType": "Patient", + "id": "9000000009", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NHSNumberVerificationStatus", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-NHSNumberVerificationStatus", + "version": "1.0.0", + "code": "01", + "display": "Number present and verified" + } + ] + } + } + ] + } + ], + "meta": { + "versionId": "2", + "security": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-Confidentiality", + "code": "U", + "display": "unrestricted" + } + ] + }, + "name": [ + { + "id": "123", + "use": "usual", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "given": [ + "Jane" + ], + "family": "Smith", + "prefix": [ + "Mrs" + ] + } + ], + "gender": "female", + "birthDate": "2010-10-22", + "multipleBirthInteger": 1, + "deceasedDateTime": "2010-10-22T00:00:00+00:00", + "generalPractitioner": [ + { + "id": "254406A3", + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + } + } + } + ], + "managingOrganization": { + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + } + } + }, + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NominatedPharmacy", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y12345" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-PreferredDispenserOrganization", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y23456" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-MedicalApplianceSupplier", + "valueReference": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "Y34567" + } + } + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-DeathNotificationStatus", + "extension": [ + { + "url": "deathNotificationStatus", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-DeathNotificationStatus", + "version": "1.0.0", + "code": "2", + "display": "Formal - death notice received from Registrar of Deaths" + } + ] + } + }, + { + "url": "systemEffectiveDate", + "valueDateTime": "2010-10-22T00:00:00+00:00" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-NHSCommunication", + "extension": [ + { + "url": "language", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-HumanLanguage", + "version": "1.0.0", + "code": "fr", + "display": "French" + } + ] + } + }, + { + "url": "interpreterRequired", + "valueBoolean": true + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-ContactPreference", + "extension": [ + { + "url": "PreferredWrittenCommunicationFormat", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-PreferredWrittenCommunicationFormat", + "code": "12", + "display": "Braille" + } + ] + } + }, + { + "url": "PreferredContactMethod", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-PreferredContactMethod", + "code": "1", + "display": "Letter" + } + ] + } + }, + { + "url": "PreferredContactTimes", + "valueString": "Not after 7pm" + } + ] + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", + "valueAddress": { + "city": "Manchester", + "district": "Greater Manchester", + "country": "GBR" + } + }, + { + "url": "https://fhir.nhs.uk/StructureDefinition/Extension-PDS-RemovalFromRegistration", + "extension": [ + { + "url": "removalFromRegistrationCode", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/PDS-RemovalReasonExitCode", + "code": "SCT", + "display": "Transferred to Scotland" + } + ] + } + }, + { + "url": "effectiveTime", + "valuePeriod": { + "start": "2020-01-01T00:00:00+00:00", + "end": "2021-12-31T00:00:00+00:00" + } + } + ] + } + ], + "telecom": [ + { + "id": "789", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "system": "phone", + "value": "01632960587", + "use": "home" + }, + { + "id": "790", + "period": { + "start": "2019-01-01", + "end": "2022-12-31" + }, + "system": "email", + "value": "jane.smith@example.com", + "use": "home" + }, + { + "id": "OC789", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "system": "other", + "value": "01632960587", + "use": "home", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-OtherContactSystem", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-OtherContactSystem", + "code": "textphone", + "display": "Minicom (Textphone)" + } + } + ] + } + ], + "contact": [ + { + "id": "C123", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "relationship": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0131", + "code": "C", + "display": "Emergency Contact" + } + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "01632960587" + } + ] + } + ], + "address": [ + { + "id": "456", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "use": "home", + "line": [ + "1 Trevelyan Square", + "Boar Lane", + "City Centre", + "Leeds", + "West Yorkshire" + ], + "postalCode": "LS1 6AE", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "PAF" + } + }, + { + "url": "value", + "valueString": "12345678" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "UPRN" + } + }, + { + "url": "value", + "valueString": "123456789012" + } + ] + } + ] + }, + { + "id": "T456", + "period": { + "start": "2020-01-01", + "end": "2021-12-31" + }, + "use": "temp", + "text": "Student Accommodation", + "line": [ + "1 Trevelyan Square", + "Boar Lane", + "City Centre", + "Leeds", + "West Yorkshire" + ], + "postalCode": "LS1 6AE", + "extension": [ + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "PAF" + } + }, + { + "url": "value", + "valueString": "12345678" + } + ] + }, + { + "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-AddressKey", + "extension": [ + { + "url": "type", + "valueCoding": { + "system": "https://fhir.hl7.org.uk/CodeSystem/UKCore-AddressKeyType", + "code": "UPRN" + } + }, + { + "url": "value", + "valueString": "123456789012" + } + ] + } + ] + } + ] +} diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 32151f2d..9bf229ff 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -1,18 +1,22 @@ """Integration tests for the gateway API using pytest.""" import json +from collections.abc import Callable +import pytest from fhir.parameters import Parameters -from stubs.stub_provider import GpProviderStub +from requests import Response +from stubs.data.bundles import Bundles from tests.conftest import Client class TestGetStructuredRecord: def test_happy_path_returns_200( - self, client: Client, simple_request_payload: Parameters + self, + client: Client, + simple_request_payload: Parameters, ) -> None: - """Test that the root endpoint returns a 200 status code.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) @@ -23,17 +27,334 @@ def test_happy_path_returns_correct_message( client: Client, simple_request_payload: Parameters, ) -> None: - """Test that the root endpoint returns the correct message.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) - assert response.json() == GpProviderStub.patient_bundle + assert response.json() == Bundles.ALICE_JONES_9999999999 def test_happy_path_content_type( - self, client: Client, simple_request_payload: Parameters + self, + client: Client, + simple_request_payload: Parameters, ) -> None: - """Test that the response has the correct content type.""" response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) assert "application/fhir+json" in response.headers["Content-Type"] + + def test_empty_request_body_returns_400_status_code( + self, response_from_sending_request_with_empty_body: Response + ) -> None: + assert response_from_sending_request_with_empty_body.status_code == 400 + + def test_empty_request_body_returns_invalid_request_json_message( + self, response_from_sending_request_with_empty_body: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Invalid JSON body sent in request", + } + ], + } + assert response_from_sending_request_with_empty_body.json() == expected + + def test_patient_without_gp_returns_404_status_code( + self, response_from_requesting_patient_without_gp: Response + ) -> None: + assert response_from_requesting_patient_without_gp.status_code == 404 + + def test_patient_without_gp_returns_no_current_provider_message( + self, response_from_requesting_patient_without_gp: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "PDS patient 9000000009 did not contain a " + "current provider ODS code" + ), + } + ], + } + assert response_from_requesting_patient_without_gp.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_no_provider_from_sds_returns_404_status_code( + self, response_when_sds_returns_no_provider: Response + ) -> None: + assert response_when_sds_returns_no_provider.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_no_provider_from_sds_returns_no_organisation_found_error( + self, response_when_sds_returns_no_provider: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "No organisation found for ODS code DoesNotExistInSDS" + ), + } + ], + } + assert response_when_sds_returns_no_provider.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_provider_asid_from_sds_returns_404_status_code( + self, response_when_sds_returns_blank_provider_asid: Response + ) -> None: + assert response_when_sds_returns_blank_provider_asid.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_provider_asid_from_sds_returns_no_asid_found_error( + self, response_when_sds_returns_blank_provider_asid: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No ASID found for ODS code DoesNotExistInSDS"), + } + ], + } + assert response_when_sds_returns_blank_provider_asid.json() == expected + + def test_502_status_code_return_when_provider_returns_error( + self, response_when_provider_returns_error: Response + ) -> None: + assert response_when_provider_returns_error.status_code == 502 + + def test_internal_server_error_message_returned_when_provider_returns_error( + self, response_when_provider_returns_error: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "Provider request failed: Not Found", + } + ], + } + assert response_when_provider_returns_error.json() == expected + + def test_nhs_number_that_does_not_exist_returns_502_status_code( + self, response_when_nhs_number_does_not_exist: Response + ) -> None: + assert response_when_nhs_number_does_not_exist.status_code == 502 + + def test_nhs_number_that_does_not_exist_returns_no_patient_found_error( + self, response_when_nhs_number_does_not_exist: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "PDS FHIR API request failed: Not Found", + } + ], + } + assert response_when_nhs_number_does_not_exist.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_sds_endpoint_blank_returns_404_status_code( + self, response_when_sds_endpoint_blank: Response + ) -> None: + assert response_when_sds_endpoint_blank.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_sds_endpoint_blank_returns_no_current_endpoint_error( + self, response_when_sds_endpoint_blank: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ( + "No current endpoint found for ODS code DoesNotExistInSDS" + ), + } + ], + } + assert response_when_sds_endpoint_blank.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_consumer_is_none_from_sds_returns_404_status_code( + self, response_when_consumer_is_none_from_sds: Response + ) -> None: + assert response_when_consumer_is_none_from_sds.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_consumer_is_none_from_sds_returns_no_organisation_found_error( + self, response_when_consumer_is_none_from_sds: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No SDS org found for consumer ODS code CONSUMER"), + } + ], + } + assert response_when_consumer_is_none_from_sds.json() == expected + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_consumer_asid_from_sds_returns_404_status_code( + self, response_when_blank_consumer_asid_from_sds: Response + ) -> None: + assert response_when_blank_consumer_asid_from_sds.status_code == 404 + + @pytest.mark.xfail( + reason="This test is expected to fail until the SDS stub is updated" + ) + def test_blank_consumer_asid_from_sds_returns_no_asid_found_error( + self, response_when_blank_consumer_asid_from_sds: Response + ) -> None: + expected = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": ("No ASID found for consumer ODS code CONSUMER"), + } + ], + } + assert response_when_blank_consumer_asid_from_sds.json() == expected + + @pytest.fixture + def response_from_sending_request_with_empty_body(self, client: Client) -> Response: + response = client.send_to_get_structured_record_endpoint(payload="") + return response + + @pytest.fixture + def response_from_requesting_patient_without_gp( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_unregistered_patient = "9000000009" + response = get_structured_record_requestor(nhs_number_for_unregistered_patient) + return response + + @pytest.fixture + def response_when_sds_returns_no_provider( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_not_in_sds = "9000000010" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_not_in_sds + ) + return response + + @pytest.fixture + def response_when_sds_returns_blank_provider_asid( + self, client: Client, simple_request_payload: Parameters + ) -> Response: + ods_from_for_consumer_with_blank_provider_asid_in_sds = "BlankProviderAsidInSDS" + headers = {"Ods-From": ods_from_for_consumer_with_blank_provider_asid_in_sds} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=headers + ) + return response + + @pytest.fixture + def response_when_sds_returns_blank_consumer_asid( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_with_blank_consumer_asid_in_sds = "9000000015" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_with_blank_consumer_asid_in_sds + ) + return response + + @pytest.fixture + def response_when_provider_returns_error( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_inducing_error_in_provider = "9000000012" + response = get_structured_record_requestor( + nhs_number_for_inducing_error_in_provider + ) + return response + + @pytest.fixture + def response_when_nhs_number_does_not_exist( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_that_does_not_exist = "1234567890" + response = get_structured_record_requestor(nhs_number_that_does_not_exist) + return response + + @pytest.fixture + def response_when_sds_endpoint_blank( + self, get_structured_record_requestor: Callable[[str], Response] + ) -> Response: + nhs_number_for_patient_with_gp_with_blank_endpoint = "9000000013" + response = get_structured_record_requestor( + nhs_number_for_patient_with_gp_with_blank_endpoint + ) + return response + + @pytest.fixture + def response_when_consumer_is_none_from_sds( + self, client: Client, simple_request_payload: Parameters + ) -> Response: + ods_from_for_consumer_with_none_consumer_in_sds = "ConsumerWithNoneInSDS" + headers = {"Ods-From": ods_from_for_consumer_with_none_consumer_in_sds} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=headers + ) + return response + + @pytest.fixture + def get_structured_record_requestor( + self, + client: Client, + simple_request_payload: Parameters, + ) -> Callable[[str], Response]: + def requestor(nhs_number: str) -> Response: + simple_request_payload["parameter"][0]["valueIdentifier"]["value"] = ( + nhs_number + ) + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + return response + + return requestor diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py new file mode 100644 index 00000000..04b8fc84 --- /dev/null +++ b/gateway-api/tests/integration/test_sds_search.py @@ -0,0 +1,52 @@ +"""Integration tests for SDS (Spine Directory Service) search functionality.""" + +from __future__ import annotations + +from gateway_api.sds import SdsClient + + +class TestSdsIntegration: + """Integration tests for SDS search operations.""" + + def test_get_device_by_ods_code_returns_valid_asid(self) -> None: + """ + Test that querying by ODS code returns a valid ASID. + + :param sds_client: SDS client fixture configured with stub. + """ + sds_client = SdsClient() + result = sds_client.get_org_details(ods_code="PROVIDER") + + assert result is not None + assert result.asid == "asid_PROV" + assert result.endpoint == "https://provider.example.com/fhir" + + def test_consumer_organization_lookup(self) -> None: + """ + Test that CONSUMER organization can be looked up successfully. + + :param sds_client: SDS client fixture configured with stub. + """ + sds_client = SdsClient() + result = sds_client.get_org_details(ods_code="CONSUMER") + + assert result is not None + assert result.asid == "asid_CONS" + assert result.endpoint == "https://consumer.example.com/fhir" + + def test_result_contains_both_asid_and_endpoint_when_available(self) -> None: + """ + Test that results contain both ASID and endpoint when both are available. + + :param sds_client: SDS client fixture configured with stub. + """ + + sds_client = SdsClient() + result = sds_client.get_org_details(ods_code="PROVIDER") + + assert result is not None + # Verify both fields are present and not None + assert hasattr(result, "asid") + assert hasattr(result, "endpoint") + assert result.asid is not None + assert result.endpoint is not None diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 54824a4b..8a0ef354 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -12,6 +12,9 @@ WORKDIR /resources/build/gateway-api ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" ENV FLASK_PORT="8080" +ENV STUB_PDS="true" +ENV STUB_PROVIDER="true" +ENV STUB_SDS="true" ARG COMMIT_VERSION ENV COMMIT_VERSION=$COMMIT_VERSION