From a8b45dff40195cb6e0db2f9f40e5f0c25442e1dc Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:05:39 +0000 Subject: [PATCH 01/54] [GPCAPIM-254]: Lift and shift of lambda functionality in to a Flask app. --- Makefile | 3 +- gateway-api/openapi.yaml | 241 +----------------- gateway-api/poetry.lock | 129 +++++++++- gateway-api/pyproject.toml | 2 + gateway-api/src/gateway_api/app.py | 51 ++++ ...GatewayAPIConsumer-GatewayAPIProvider.json | 12 +- .../tests/contract/test_consumer_contract.py | 2 +- gateway-api/tests/integration/test_main.py | 2 +- infrastructure/images/gateway-api/Dockerfile | 12 +- 9 files changed, 198 insertions(+), 256 deletions(-) create mode 100644 gateway-api/src/gateway_api/app.py diff --git a/Makefile b/Makefile index 3e634518..598014d2 100644 --- a/Makefile +++ b/Makefile @@ -34,9 +34,8 @@ build-gateway-api: dependencies @poetry run mypy --no-namespace-packages . @echo "Packaging dependencies..." @poetry build --format=wheel - @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_1_x86_64 --only-binary=:all: + @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all: # Copy main file separately as it is not included within the package. - @cp lambda_handler.py ./target/gateway-api/ @rm -rf ../infrastructure/images/gateway-api/resources/build/ @mkdir ../infrastructure/images/gateway-api/resources/build/ @cp -r ./target/gateway-api ../infrastructure/images/gateway-api/resources/build/ diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index b6799f7c..df06978c 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -15,7 +15,7 @@ paths: description: Returns a simple hello world message operationId: postHelloWorld requestBody: - required: false + required: true content: application/json: schema: @@ -24,247 +24,12 @@ paths: payload: type: string description: The payload to be processed + example: "Alex" responses: '200': description: Successful response content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - get: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - put: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - patch: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - delete: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - trace: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: + application/json: schema: type: object properties: diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 338577d4..8ec2ddde 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -63,6 +63,18 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -301,7 +313,7 @@ version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -332,11 +344,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\""} [[package]] name = "coverage" @@ -443,6 +456,30 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "flask" +version = "3.1.2" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "fqdn" version = "1.5.1" @@ -668,13 +705,25 @@ files = [ [package.dependencies] arrow = ">=0.15.0" +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -808,7 +857,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -1981,6 +2030,62 @@ files = [ requests = "*" starlette = ">=0.20.1" +[[package]] +name = "types-click" +version = "7.1.8" +description = "Typing stubs for click" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-click-7.1.8.tar.gz", hash = "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"}, + {file = "types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81"}, +] + +[[package]] +name = "types-flask" +version = "1.1.6" +description = "Typing stubs for Flask" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Flask-1.1.6.tar.gz", hash = "sha256:aac777b3abfff9436e6b01f6d08171cf23ea6e5be71cbf773aaabb1c5763e9cf"}, + {file = "types_Flask-1.1.6-py3-none-any.whl", hash = "sha256:6ab8a9a5e258b76539d652f6341408867298550b19b81f0e41e916825fc39087"}, +] + +[package.dependencies] +types-click = "*" +types-Jinja2 = "*" +types-Werkzeug = "*" + +[[package]] +name = "types-jinja2" +version = "2.11.9" +description = "Typing stubs for Jinja2" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Jinja2-2.11.9.tar.gz", hash = "sha256:dbdc74a40aba7aed520b7e4d89e8f0fe4286518494208b35123bcf084d4b8c81"}, + {file = "types_Jinja2-2.11.9-py3-none-any.whl", hash = "sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2"}, +] + +[package.dependencies] +types-MarkupSafe = "*" + +[[package]] +name = "types-markupsafe" +version = "1.1.10" +description = "Typing stubs for MarkupSafe" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-MarkupSafe-1.1.10.tar.gz", hash = "sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1"}, + {file = "types_MarkupSafe-1.1.10-py3-none-any.whl", hash = "sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5"}, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -2008,6 +2113,18 @@ files = [ [package.dependencies] urllib3 = ">=2" +[[package]] +name = "types-werkzeug" +version = "1.0.9" +description = "Typing stubs for Werkzeug" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Werkzeug-1.0.9.tar.gz", hash = "sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c"}, + {file = "types_Werkzeug-1.0.9-py3-none-any.whl", hash = "sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -2083,7 +2200,7 @@ version = "3.1.5" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, @@ -2243,4 +2360,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "67e8839de72625c8f7c4d42aea6ea55afaf9f738aef2267bb4dac2f83a389f8e" +content-hash = "30cdb09db37902c7051aa190c1e4c374dbfa6a14ca0c69131c0295ee33e7338f" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 2242551f..87f86635 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -10,6 +10,8 @@ requires-python = ">3.13,<4.0.0" [tool.poetry.dependencies] clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } +flask = "^3.1.2" +types-flask = "^1.1.6" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py new file mode 100644 index 00000000..b769ad61 --- /dev/null +++ b/gateway-api/src/gateway_api/app.py @@ -0,0 +1,51 @@ +from typing import Any, TypedDict + +from flask import Flask, request + +from gateway_api.handler import User, greet + +app = Flask(__name__) + + +class APIMResponse[T](TypedDict): + """A API Management response including a body with a generic type.""" + + statusCode: int + headers: dict[str, str] + body: T + + +@app.route("/2015-03-31/functions/function/invocations", methods=["POST"]) +def greet_endpoint() -> APIMResponse[str | dict[str, str]]: + """Greet endpoint that replicates the lambda handler functionality.""" + data = request.get_json(force=True) + if "payload" not in data: + return _with_default_headers(status_code=400, body="Name is required") + + name = data["payload"] + if not name: + return _with_default_headers(status_code=400, body="Name cannot be empty") + user = User(name=name) + + try: + return _with_default_headers(status_code=200, body=f"{greet(user)}") + except ValueError: + return _with_default_headers( + status_code=404, body=f"Provided name cannot be found. name={name}" + ) + + +def _with_default_headers[T](status_code: int, body: T) -> APIMResponse[T]: + return APIMResponse( + statusCode=status_code, headers={"Content-Type": "application/json"}, body=body + ) + + +@app.route("/health", methods=["GET"]) +def health_check() -> APIMResponse[dict[str, Any]]: + """Health check endpoint.""" + return _with_default_headers(status_code=200, body={"status": "healthy"}) + + +if __name__ == "__main__": + app.run(host="gateway-api", port=8080) diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 681c19d7..dd9af038 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -36,13 +36,19 @@ }, "response": { "body": { - "content": "{\"statusCode\": 200, \"headers\": {\"Content-Type\": \"application/json\"}, \"body\": \"Hello, World!\"}", - "contentType": "text/plain;charset=utf-8", + "content": { + "body": "Hello, World!", + "headers": { + "Content-Type": "application/json" + }, + "statusCode": 200 + }, + "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "text/plain;charset=utf-8" + "application/json" ] }, "status": 200 diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index ac0d11d1..e8b46c1c 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -36,7 +36,7 @@ def test_get_hello_world(self) -> None: "headers": {"Content-Type": "application/json"}, "body": "Hello, World!", }, - content_type="text/plain;charset=utf-8", + content_type="application/json", ) ) diff --git a/gateway-api/tests/integration/test_main.py b/gateway-api/tests/integration/test_main.py index 18c71e09..0ccb3786 100644 --- a/gateway-api/tests/integration/test_main.py +++ b/gateway-api/tests/integration/test_main.py @@ -19,7 +19,7 @@ def test_hello_world_returns_correct_message(self, client: Client) -> None: def test_hello_world_content_type(self, client: Client) -> None: """Test that the response has the correct content type.""" response = client.send("world") - assert "text/plain" in response.headers["Content-Type"] + assert "application/json" in response.headers["Content-Type"] def test_nonexistent_returns_error(self, client: Client) -> None: """Test that non-existent routes return 404.""" diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 121dc611..a3caaf61 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -1,11 +1,13 @@ # Retrieve the python version from build arguments, deliberately set to "invalid" by default to highlight when no version is provided when building the container. ARG PYTHON_VERSION=invalid -# Use the specified python version to retrieve the required base lambda image. -ARG url=public.ecr.aws/lambda/python:${PYTHON_VERSION} -FROM $url +FROM python:${PYTHON_VERSION}-slim AS gateway-api COPY resources/ /resources -COPY /resources/build/gateway-api ${LAMBDA_TASK_ROOT} +WORKDIR /resources/build/gateway-api + +ENV PYTHONPATH=/resources/build/gateway-api + +ENTRYPOINT ["python"] +CMD ["gateway_api/app.py"] -CMD [ "lambda_handler.handler" ] From 73cdf4dd58d027eaddbb3ec03baf277ca1dabfeb Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:37:35 +0000 Subject: [PATCH 02/54] [GPCAPIM-254]: Lambda is no longer being used; move actions away from it. --- .github/actions/start-app/action.yaml | 50 +++++++++++++++++++++++++++ .github/workflows/stage-2-test.yaml | 16 ++++----- 2 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 .github/actions/start-app/action.yaml diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml new file mode 100644 index 00000000..414e52f6 --- /dev/null +++ b/.github/actions/start-app/action.yaml @@ -0,0 +1,50 @@ +name: "Start local app" +description: "Start Flask app that will handle requests" +inputs: + deploy-command: + description: "Command to start app" + required: false + default: "make deploy" + health-path: + description: "Health probe path to POST" + required: false + default: "/2015-03-31/functions/function/invocations" + max-seconds: + description: "Maximum seconds to wait for readiness" + required: false + default: "60" +python-version: + description: "Python version to install" + required: true +runs: + using: "composite" + steps: + - name: "Start app" + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python-version }} + run: | + set -euo pipefail + echo "Starting app: '${{ inputs.deploy-command }}'" + nohup ${{ inputs.deploy-command }} >/tmp/app.log 2>&1 & + echo $! > /tmp/app.pid + echo "PID: $(cat /tmp/app.pid)" + - name: "Wait for app to be ready" + shell: bash + run: | + set -euo pipefail + BASE_URL="${BASE_URL:-http://localhost:5000}" + HEALTH_URL="${BASE_URL}${{ inputs.health-path }}" + MAX="${{ inputs.max-seconds }}" + echo "Waiting for app at ${HEALTH_URL} (max ${MAX}s)..." + for i in $(seq 1 "${MAX}"); do + if curl -sSf -X POST "${HEALTH_URL}" -d '{}' >/dev/null; then + echo "App is ready" + exit 0 + fi + sleep 1 + done + echo "App did not become ready in time" + echo "---- recent app log ----" + tail -n 200 /tmp/app.log || true + exit 1 diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 66e4a240..32a5fd2b 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -68,8 +68,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run contract tests" @@ -98,8 +98,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run schema validation tests" @@ -128,8 +128,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run integration test" @@ -158,8 +158,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} max-seconds: 90 From 4e1ea55fdb916594a20b43ec0cdeaf51b8fc6a09 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:47:15 +0000 Subject: [PATCH 03/54] [GPCAPIM-254]: Github not picking up unindented input --- .github/actions/start-app/action.yaml | 6 +++--- gateway-api/src/gateway_api/app.py | 6 +++++- infrastructure/images/gateway-api/Dockerfile | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml index 414e52f6..97a88061 100644 --- a/.github/actions/start-app/action.yaml +++ b/.github/actions/start-app/action.yaml @@ -13,9 +13,9 @@ inputs: description: "Maximum seconds to wait for readiness" required: false default: "60" -python-version: - description: "Python version to install" - required: true + python-version: + description: "Python version to install" + required: true runs: using: "composite" steps: diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index b769ad61..ce881c77 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -1,3 +1,4 @@ +import os from typing import Any, TypedDict from flask import Flask, request @@ -48,4 +49,7 @@ def health_check() -> APIMResponse[dict[str, Any]]: if __name__ == "__main__": - app.run(host="gateway-api", port=8080) + host = os.getenv("FLASK_HOST") + if host is None: + raise RuntimeError("FLASK_HOST environment variable is not set.") + app.run(host=host, port=8080) diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index a3caaf61..f3ce577f 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -7,6 +7,7 @@ COPY resources/ /resources WORKDIR /resources/build/gateway-api ENV PYTHONPATH=/resources/build/gateway-api +ENV FLASK_HOST="0.0.0.0" ENTRYPOINT ["python"] CMD ["gateway_api/app.py"] From f89e719e8a6efb366a12092c7a0baa89c3b65f73 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:29:42 +0000 Subject: [PATCH 04/54] [GPCAPIM-254]: Github not picking up unindented input --- gateway-api/src/gateway_api/test_app.py | 122 ++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 gateway-api/src/gateway_api/test_app.py diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py new file mode 100644 index 00000000..23dd2d96 --- /dev/null +++ b/gateway-api/src/gateway_api/test_app.py @@ -0,0 +1,122 @@ +"""Unit tests for the Flask app endpoints.""" + +import pytest +from flask.testing import FlaskClient + +from gateway_api.app import app + + +@pytest.fixture +def client() -> FlaskClient: + """Create a Flask test client.""" + app.config["TESTING"] = True + with app.test_client() as client: + yield client + + +class TestGreetEndpoint: + """Unit tests for the greet_endpoint function.""" + + def test_greet_endpoint_returns_greeting_for_valid_name( + self, client: FlaskClient + ) -> None: + """Test that greet_endpoint returns a greeting for a valid name.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": "Alice"}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 200 + assert data["headers"]["Content-Type"] == "application/json" + assert "Alice" in data["body"] + assert data["body"].endswith("!") + + def test_greet_endpoint_returns_400_when_payload_missing( + self, client: FlaskClient + ) -> None: + """Test that greet_endpoint returns 400 when payload is missing.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 400 + assert data["body"] == "Name is required" + assert data["headers"]["Content-Type"] == "application/json" + + def test_greet_endpoint_returns_400_when_name_is_empty( + self, client: FlaskClient + ) -> None: + """Test that greet_endpoint returns 400 when name is empty.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": ""}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 400 + assert data["body"] == "Name cannot be empty" + assert data["headers"]["Content-Type"] == "application/json" + + def test_greet_endpoint_returns_404_for_nonexistent_user( + self, client: FlaskClient + ) -> None: + """Test that greet_endpoint returns 404 for nonexistent user.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": "nonexistent"}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 404 + assert "cannot be found" in data["body"] + assert "nonexistent" in data["body"] + assert data["headers"]["Content-Type"] == "application/json" + + def test_greet_endpoint_returns_400_when_name_is_none( + self, client: FlaskClient + ) -> None: + """Test that greet_endpoint returns 400 when name is None.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": None}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 400 + assert data["body"] == "Name cannot be empty" + assert data["headers"]["Content-Type"] == "application/json" + + +class TestHealthCheck: + """Unit tests for the health_check function.""" + + def test_health_check_returns_200_and_healthy_status( + self, client: FlaskClient + ) -> None: + """Test that health_check returns 200 with healthy status.""" + response = client.get("/health") + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 200 + assert data["body"]["status"] == "healthy" + assert data["headers"]["Content-Type"] == "application/json" + + def test_health_check_only_accepts_get_method(self, client: FlaskClient) -> None: + """Test that health_check only accepts GET method.""" + response = client.post("/health") + assert response.status_code == 405 # Method Not Allowed + + response = client.put("/health") + assert response.status_code == 405 + + response = client.delete("/health") + assert response.status_code == 405 From c321366bde875fe07402581fae75d7bf1e9ba1b5 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:43:57 +0000 Subject: [PATCH 05/54] [GPCAPIM-254]: Add type hinting --- gateway-api/src/gateway_api/test_app.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 23dd2d96..9ba66301 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -1,13 +1,16 @@ """Unit tests for the Flask app endpoints.""" +from collections.abc import Generator + import pytest +from flask import Flask from flask.testing import FlaskClient from gateway_api.app import app @pytest.fixture -def client() -> FlaskClient: +def client() -> Generator[FlaskClient[Flask], None, None]: """Create a Flask test client.""" app.config["TESTING"] = True with app.test_client() as client: @@ -18,7 +21,7 @@ class TestGreetEndpoint: """Unit tests for the greet_endpoint function.""" def test_greet_endpoint_returns_greeting_for_valid_name( - self, client: FlaskClient + self, client: FlaskClient[Flask] ) -> None: """Test that greet_endpoint returns a greeting for a valid name.""" response = client.post( @@ -34,7 +37,7 @@ def test_greet_endpoint_returns_greeting_for_valid_name( assert data["body"].endswith("!") def test_greet_endpoint_returns_400_when_payload_missing( - self, client: FlaskClient + self, client: FlaskClient[Flask] ) -> None: """Test that greet_endpoint returns 400 when payload is missing.""" response = client.post( @@ -49,7 +52,7 @@ def test_greet_endpoint_returns_400_when_payload_missing( assert data["headers"]["Content-Type"] == "application/json" def test_greet_endpoint_returns_400_when_name_is_empty( - self, client: FlaskClient + self, client: FlaskClient[Flask] ) -> None: """Test that greet_endpoint returns 400 when name is empty.""" response = client.post( @@ -64,7 +67,7 @@ def test_greet_endpoint_returns_400_when_name_is_empty( assert data["headers"]["Content-Type"] == "application/json" def test_greet_endpoint_returns_404_for_nonexistent_user( - self, client: FlaskClient + self, client: FlaskClient[Flask] ) -> None: """Test that greet_endpoint returns 404 for nonexistent user.""" response = client.post( @@ -80,7 +83,7 @@ def test_greet_endpoint_returns_404_for_nonexistent_user( assert data["headers"]["Content-Type"] == "application/json" def test_greet_endpoint_returns_400_when_name_is_none( - self, client: FlaskClient + self, client: FlaskClient[Flask] ) -> None: """Test that greet_endpoint returns 400 when name is None.""" response = client.post( @@ -99,7 +102,7 @@ class TestHealthCheck: """Unit tests for the health_check function.""" def test_health_check_returns_200_and_healthy_status( - self, client: FlaskClient + self, client: FlaskClient[Flask] ) -> None: """Test that health_check returns 200 with healthy status.""" response = client.get("/health") @@ -110,7 +113,9 @@ def test_health_check_returns_200_and_healthy_status( assert data["body"]["status"] == "healthy" assert data["headers"]["Content-Type"] == "application/json" - def test_health_check_only_accepts_get_method(self, client: FlaskClient) -> None: + def test_health_check_only_accepts_get_method( + self, client: FlaskClient[Flask] + ) -> None: """Test that health_check only accepts GET method.""" response = client.post("/health") assert response.status_code == 405 # Method Not Allowed From bf390d8636d70e4b96d8286e30471618d1f342d2 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:28:34 +0000 Subject: [PATCH 06/54] [GPCAPIM-254]: Beginning of /patient/$gpc.getstructuredrecord endpoint. --- gateway-api/openapi.yaml | 132 ++++++++++++++++++ gateway-api/src/gateway_api/app.py | 73 ++++++++++ gateway-api/src/gateway_api/test_app.py | 35 +++++ gateway-api/tests/conftest.py | 39 ++++-- ...GatewayAPIConsumer-GatewayAPIProvider.json | 74 ++++++++++ .../tests/contract/test_consumer_contract.py | 102 ++++++++++++++ 6 files changed, 447 insertions(+), 8 deletions(-) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index df06978c..a318d4d9 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -9,6 +9,138 @@ servers: - url: http://localhost:5000 description: Local development server paths: + /patient/$gpc.getstructuredrecord: + post: + summary: Get structured record + description: Returns a FHIR Bundle containing patient structured record + operationId: getStructuredRecord + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: [application/json] + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + resourceType: + type: string + example: "Parameters" + parameter: + type: array + items: + type: object + properties: + name: + type: string + example: "patientNHSNumber" + valueIdentifier: + type: object + properties: + system: + type: string + example: "https://fhir.nhs.uk/Id/nhs-number" + value: + type: string + example: "9999999999" + responses: + '200': + description: Successful response + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: [application/json] + required: true + content: + application/json: + schema: + type: object + properties: + statusCode: + type: integer + description: Status code of the interaction + example: 200 + headers: + type: object + properties: + Content-Type: + type: string + example: "application/json" + body: + type: object + description: FHIR Bundle containing patient data + properties: + resourceType: + type: string + example: "Bundle" + id: + type: string + example: "example-patient-bundle" + type: + type: string + example: "collection" + timestamp: + type: string + format: date-time + example: "2026-01-12T10:00:00Z" + entry: + type: array + items: + type: object + properties: + fullUrl: + type: string + example: "urn:uuid:123e4567-e89b-12d3-a456-426614174000" + resource: + type: object + properties: + resourceType: + type: string + example: "Patient" + id: + type: string + example: "9999999999" + identifier: + type: array + items: + type: object + properties: + system: + type: string + example: "https://fhir.nhs.uk/Id/nhs-number" + value: + type: string + example: "9999999999" + name: + type: array + items: + type: object + properties: + use: + type: string + example: "official" + family: + type: string + example: "Doe" + given: + type: array + items: + type: string + example: ["John"] + gender: + type: string + example: "male" + birthDate: + type: string + format: date + example: "1985-04-12" /2015-03-31/functions/function/invocations: post: summary: Get hello world message diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index ce881c77..f6fa3fcd 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -16,6 +16,79 @@ class APIMResponse[T](TypedDict): body: T +class Identifier(TypedDict): + """FHIR Identifier type.""" + + system: str + value: str + + +class HumanName(TypedDict): + """FHIR HumanName type.""" + + use: str + family: str + given: list[str] + + +class Patient(TypedDict): + """FHIR Patient resource.""" + + resourceType: str + id: str + identifier: list[Identifier] + name: list[HumanName] + gender: str + birthDate: str + + +class BundleEntry(TypedDict): + """FHIR Bundle entry.""" + + fullUrl: str + resource: Patient + + +class Bundle(TypedDict): + """FHIR Bundle resource.""" + + resourceType: str + id: str + type: str + timestamp: str + entry: list[BundleEntry] + + +@app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) +def get_structured_record() -> Bundle: + """Endpoint to get structured record, replicating lambda handler functionality.""" + bundle: Bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [{"use": "official", "family": "Doe", "given": ["John"]}], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + return bundle + + @app.route("/2015-03-31/functions/function/invocations", methods=["POST"]) def greet_endpoint() -> APIMResponse[str | dict[str, str]]: """Greet endpoint that replicates the lambda handler functionality.""" diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 9ba66301..15d864c4 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -17,6 +17,41 @@ def client() -> Generator[FlaskClient[Flask], None, None]: yield client +class TestGetStructuredRecord: + """Unit tests for the get_structured_record function.""" + + def test_get_structured_record_returns_200_with_bundle( + self, client: FlaskClient[Flask] + ) -> None: + """Test that get_structured_record returns 200 with a bundle.""" + body = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + response = client.post("/patient/$gpc.getstructuredrecord", json=body) + + 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" + + class TestGreetEndpoint: """Unit tests for the greet_endpoint function.""" diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index d5fba218..997b044d 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -21,7 +21,28 @@ def __init__(self, lambda_url: str, timeout: timedelta = timedelta(seconds=1)): self._lambda_url = lambda_url self._timeout = timeout.total_seconds() - def send(self, data: str) -> requests.Response: + def get_structured_record(self, nhs_number: str) -> requests.Response: + """ + Send a request to the get_structured_record endpoint with the given NHS number. + """ + payload = json.dumps( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": nhs_number, + }, + }, + ], + } + ) + url = f"{self._lambda_url}/patient/$gpc.getstructuredrecord" + return self._send(url=url, payload=payload) + + def send(self, message: str) -> requests.Response: """ Send a request to the APIs with some given parameters. Args: @@ -29,7 +50,9 @@ def send(self, data: str) -> requests.Response: Returns: Response object from the request """ - return self._send(data=data, include_payload=True) + payload = json.dumps({"payload": message}) + url = f"{self._lambda_url}/2015-03-31/functions/function/invocations" + return self._send(url=url, payload=payload) def send_without_payload(self) -> requests.Response: """ @@ -37,14 +60,14 @@ def send_without_payload(self) -> requests.Response: Returns: Response object from the request """ - return self._send(data=None, include_payload=False) - - def _send(self, data: str | None, include_payload: bool) -> requests.Response: - json_data = {"payload": data} if include_payload else {} + empty_payload = json.dumps({}) + url = f"{self._lambda_url}/2015-03-31/functions/function/invocations" + return self._send(url=url, payload=empty_payload) + def _send(self, url: str, payload: str) -> requests.Response: return requests.post( - f"{self._lambda_url}/2015-03-31/functions/function/invocations", - data=json.dumps(json_data), + url=url, + data=payload, timeout=self._timeout, ) diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index dd9af038..47af75f5 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -15,6 +15,80 @@ }, "type": "Synchronous/HTTP" }, + { + "description": "a request for structured record", + "pending": false, + "request": { + "body": { + "content": { + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ], + "resourceType": "Parameters" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "method": "POST", + "path": "/patient/$gpc.getstructuredrecord" + }, + "response": { + "body": { + "content": { + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "birthDate": "1985-04-12", + "gender": "male", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + ], + "name": [ + { + "family": "Doe", + "given": [ + "John" + ], + "use": "official" + } + ], + "resourceType": "Patient" + } + } + ], + "id": "example-patient-bundle", + "resourceType": "Bundle", + "timestamp": "2026-01-12T10:00:00Z", + "type": "collection" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, { "description": "a request for the hello world message", "pending": false, diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index e8b46c1c..68b33aaa 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -4,6 +4,8 @@ interactions with the provider (the Flask API). """ +import json + import requests from pact import Pact @@ -59,6 +61,106 @@ def test_get_hello_world(self) -> None: # Write the pact file after the test pact.write_file("tests/contract/pacts") + def test_get_structured_record(self) -> None: + """Test the consumer's expectation of the get structured record endpoint. + + This test defines the contract: when the consumer requests + POST to the /patient/$gpc.getstructuredrecord endpoint, + a 200 response containing a FHIR Bundle is returned. + """ + pact = Pact(consumer="GatewayAPIConsumer", provider="GatewayAPIProvider") + + expected_bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + {"use": "official", "family": "Doe", "given": ["John"]} + ], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + + # Define the expected interaction + ( + pact.upon_receiving("a request for structured record") + .with_body( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + }, + content_type="application/json", + ) + .with_header("Content-Type", "application/json") + .with_request( + method="POST", + path="/patient/$gpc.getstructuredrecord", + ) + .will_respond_with(status=200) + .with_body(expected_bundle, content_type="application/json") + .with_header("Content-Type", "application/json") + ) + + # Start the mock server and execute the test + with pact.serve() as server: + # Make the actual request to the mock provider + response = requests.post( + f"{server.url}/patient/$gpc.getstructuredrecord", + data=json.dumps( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + ), + headers={"Content-Type": "application/json"}, + timeout=10, + ) + + # Verify the response matches expectations + assert response.status_code == 200 + body = response.json() + assert body["resourceType"] == "Bundle" + assert body["id"] == "example-patient-bundle" + assert body["type"] == "collection" + assert len(body["entry"]) == 1 + assert body["entry"][0]["resource"]["resourceType"] == "Patient" + assert body["entry"][0]["resource"]["id"] == "9999999999" + + # Write the pact file after the test + pact.write_file("tests/contract/pacts") + def test_get_nonexistent_route(self) -> None: """Test the consumer's expectation when requesting a non-existent route. From 4c7beef060354cc0dadf8263e6dd941ce999ba15 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:12:53 +0000 Subject: [PATCH 07/54] [GPCAPIM-254]: Handle logic in request-specific class --- gateway-api/pyproject.toml | 3 +- gateway-api/src/fhir/__init__.py | 8 +++ gateway-api/src/fhir/bundle.py | 22 ++++++ gateway-api/src/fhir/human_name.py | 11 +++ gateway-api/src/fhir/identifier.py | 10 +++ gateway-api/src/fhir/patient.py | 17 +++++ gateway-api/src/gateway_api/app.py | 71 ++----------------- .../get_structed_record/__init__.py | 0 .../get_structed_record/request.py | 36 ++++++++++ 9 files changed, 110 insertions(+), 68 deletions(-) create mode 100644 gateway-api/src/fhir/__init__.py create mode 100644 gateway-api/src/fhir/bundle.py create mode 100644 gateway-api/src/fhir/human_name.py create mode 100644 gateway-api/src/fhir/identifier.py create mode 100644 gateway-api/src/fhir/patient.py create mode 100644 gateway-api/src/gateway_api/get_structed_record/__init__.py create mode 100644 gateway-api/src/gateway_api/get_structed_record/request.py diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 87f86635..fa79be03 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -15,7 +15,8 @@ types-flask = "^1.1.6" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, - {include = "stubs", from = "stubs"}] + {include = "stubs", from = "stubs"}, + {include = "fhir", from = "src"}] [tool.coverage.run] relative_files = true diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py new file mode 100644 index 00000000..efc9349c --- /dev/null +++ b/gateway-api/src/fhir/__init__.py @@ -0,0 +1,8 @@ +"""FHIR data types and resources.""" + +from fhir.bundle import Bundle, BundleEntry +from fhir.human_name import HumanName +from fhir.identifier import Identifier +from fhir.patient import Patient + +__all__ = ["Bundle", "BundleEntry", "Identifier", "Patient", "HumanName"] diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py new file mode 100644 index 00000000..afe49f94 --- /dev/null +++ b/gateway-api/src/fhir/bundle.py @@ -0,0 +1,22 @@ +"""FHIR Bundle resource.""" + +from typing import TypedDict + +from fhir.patient import Patient + + +class BundleEntry(TypedDict): + """FHIR Bundle entry.""" + + fullUrl: str + resource: Patient + + +class Bundle(TypedDict): + """FHIR Bundle resource.""" + + resourceType: str + id: str + type: str + timestamp: str + entry: list[BundleEntry] diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py new file mode 100644 index 00000000..bc56d529 --- /dev/null +++ b/gateway-api/src/fhir/human_name.py @@ -0,0 +1,11 @@ +"""FHIR HumanName type.""" + +from typing import TypedDict + + +class HumanName(TypedDict): + """FHIR HumanName type.""" + + use: str + family: str + given: list[str] diff --git a/gateway-api/src/fhir/identifier.py b/gateway-api/src/fhir/identifier.py new file mode 100644 index 00000000..feb62aee --- /dev/null +++ b/gateway-api/src/fhir/identifier.py @@ -0,0 +1,10 @@ +"""FHIR Identifier type.""" + +from typing import TypedDict + + +class Identifier(TypedDict): + """FHIR Identifier type.""" + + system: str + value: str diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py new file mode 100644 index 00000000..e23eb449 --- /dev/null +++ b/gateway-api/src/fhir/patient.py @@ -0,0 +1,17 @@ +"""FHIR Patient resource.""" + +from typing import TypedDict + +from fhir.human_name import HumanName +from fhir.identifier import Identifier + + +class Patient(TypedDict): + """FHIR Patient resource.""" + + resourceType: str + id: str + identifier: list[Identifier] + name: list[HumanName] + gender: str + birthDate: str diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index f6fa3fcd..2d2bf91d 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -1,8 +1,10 @@ import os from typing import Any, TypedDict +from fhir import Bundle from flask import Flask, request +from gateway_api.get_structed_record.request import GetStructuredRecordRequest from gateway_api.handler import User, greet app = Flask(__name__) @@ -16,76 +18,11 @@ class APIMResponse[T](TypedDict): body: T -class Identifier(TypedDict): - """FHIR Identifier type.""" - - system: str - value: str - - -class HumanName(TypedDict): - """FHIR HumanName type.""" - - use: str - family: str - given: list[str] - - -class Patient(TypedDict): - """FHIR Patient resource.""" - - resourceType: str - id: str - identifier: list[Identifier] - name: list[HumanName] - gender: str - birthDate: str - - -class BundleEntry(TypedDict): - """FHIR Bundle entry.""" - - fullUrl: str - resource: Patient - - -class Bundle(TypedDict): - """FHIR Bundle resource.""" - - resourceType: str - id: str - type: str - timestamp: str - entry: list[BundleEntry] - - @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Bundle: """Endpoint to get structured record, replicating lambda handler functionality.""" - bundle: Bundle = { - "resourceType": "Bundle", - "id": "example-patient-bundle", - "type": "collection", - "timestamp": "2026-01-12T10:00:00Z", - "entry": [ - { - "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", - "resource": { - "resourceType": "Patient", - "id": "9999999999", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [{"use": "official", "family": "Doe", "given": ["John"]}], - "gender": "male", - "birthDate": "1985-04-12", - }, - } - ], - } + get_structured_record_request = GetStructuredRecordRequest(request) + bundle = get_structured_record_request.fulfil() return bundle diff --git a/gateway-api/src/gateway_api/get_structed_record/__init__.py b/gateway-api/src/gateway_api/get_structed_record/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/get_structed_record/request.py b/gateway-api/src/gateway_api/get_structed_record/request.py new file mode 100644 index 00000000..4b22296b --- /dev/null +++ b/gateway-api/src/gateway_api/get_structed_record/request.py @@ -0,0 +1,36 @@ +from fhir import Bundle +from flask.wrappers import Request + + +class GetStructuredRecordRequest: + def __init__(self, request: Request) -> None: + self._http_request = request + + def fulfil(self) -> Bundle: + bundle: Bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + {"use": "official", "family": "Doe", "given": ["John"]} + ], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + return bundle From 92485cce779df146de76e15980b216cbd5576dcc Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:16:56 +0000 Subject: [PATCH 08/54] [GPCAPIM-254]: Move to handler class --- gateway-api/src/fhir/__init__.py | 11 +++- gateway-api/src/fhir/parameters.py | 19 +++++++ gateway-api/src/gateway_api/app.py | 5 +- .../get_structed_record/handler.py | 35 ++++++++++++ .../get_structed_record/request.py | 55 +++++++++---------- 5 files changed, 93 insertions(+), 32 deletions(-) create mode 100644 gateway-api/src/fhir/parameters.py create mode 100644 gateway-api/src/gateway_api/get_structed_record/handler.py diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index efc9349c..ea58d5c8 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -3,6 +3,15 @@ from fhir.bundle import Bundle, BundleEntry from fhir.human_name import HumanName from fhir.identifier import Identifier +from fhir.parameters import Parameter, Parameters from fhir.patient import Patient -__all__ = ["Bundle", "BundleEntry", "Identifier", "Patient", "HumanName"] +__all__ = [ + "Bundle", + "BundleEntry", + "HumanName", + "Identifier", + "Parameter", + "Parameters", + "Patient", +] diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py new file mode 100644 index 00000000..eef46ca6 --- /dev/null +++ b/gateway-api/src/fhir/parameters.py @@ -0,0 +1,19 @@ +"""FHIR Parameters resource.""" + +from typing import TypedDict + +from fhir.identifier import Identifier + + +class Parameter(TypedDict): + """FHIR Parameter type.""" + + name: str + valueIdentifier: Identifier + + +class Parameters(TypedDict): + """FHIR Parameters resource.""" + + resourceType: str + parameter: list[Parameter] diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 2d2bf91d..5a449268 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,6 +4,7 @@ from fhir import Bundle from flask import Flask, request +from gateway_api.get_structed_record.handler import GetStructuredRecordHandler from gateway_api.get_structed_record.request import GetStructuredRecordRequest from gateway_api.handler import User, greet @@ -22,8 +23,8 @@ class APIMResponse[T](TypedDict): def get_structured_record() -> Bundle: """Endpoint to get structured record, replicating lambda handler functionality.""" get_structured_record_request = GetStructuredRecordRequest(request) - bundle = get_structured_record_request.fulfil() - return bundle + response = GetStructuredRecordHandler.handle(get_structured_record_request) + return response @app.route("/2015-03-31/functions/function/invocations", methods=["POST"]) diff --git a/gateway-api/src/gateway_api/get_structed_record/handler.py b/gateway-api/src/gateway_api/get_structed_record/handler.py new file mode 100644 index 00000000..5a301bd4 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structed_record/handler.py @@ -0,0 +1,35 @@ +from fhir import Bundle + +from gateway_api.get_structed_record.request import GetStructuredRecordRequest + + +class GetStructuredRecordHandler: + @classmethod + def handle(cls, request: GetStructuredRecordRequest) -> Bundle: + bundle: Bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + {"use": "official", "family": "Doe", "given": ["John"]} + ], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + return bundle diff --git a/gateway-api/src/gateway_api/get_structed_record/request.py b/gateway-api/src/gateway_api/get_structed_record/request.py index 4b22296b..7a0f7311 100644 --- a/gateway-api/src/gateway_api/get_structed_record/request.py +++ b/gateway-api/src/gateway_api/get_structed_record/request.py @@ -1,36 +1,33 @@ -from fhir import Bundle +from typing import TYPE_CHECKING + from flask.wrappers import Request +if TYPE_CHECKING: + from fhir import Parameters + class GetStructuredRecordRequest: def __init__(self, request: Request) -> None: self._http_request = request + self._headers = request.headers + self._request_body: Parameters = request.get_json() + + @property + def trace_id(self) -> str: + trace_id: str = self._headers["Ssp-TraceID"] + return trace_id + + @property + def nhs_number(self) -> str: + nhs_number: str = self._request_body["parameter"][0]["valueIdentifier"]["value"] + return nhs_number + + @property + def consumer_asid(self) -> str: + consumer_asid: str = self._headers["X-Consumer-ASID"] + return consumer_asid - def fulfil(self) -> Bundle: - bundle: Bundle = { - "resourceType": "Bundle", - "id": "example-patient-bundle", - "type": "collection", - "timestamp": "2026-01-12T10:00:00Z", - "entry": [ - { - "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", - "resource": { - "resourceType": "Patient", - "id": "9999999999", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [ - {"use": "official", "family": "Doe", "given": ["John"]} - ], - "gender": "male", - "birthDate": "1985-04-12", - }, - } - ], - } - return bundle + @property + def provider_asid(self) -> str: + provider_asid: str = self._headers["X-Provider-ASID"] + return provider_asid From 8417c726e30941a42ff0c5e4d885b6d1fd903a9c Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:06:09 +0000 Subject: [PATCH 09/54] [GPCAPIM-254]: Update healthcheck endpoint to return simplier body. --- gateway-api/src/gateway_api/app.py | 10 +++++++--- gateway-api/src/gateway_api/test_app.py | 15 ++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 5a449268..240dc9a6 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -1,5 +1,5 @@ import os -from typing import Any, TypedDict +from typing import TypedDict from fhir import Bundle from flask import Flask, request @@ -19,6 +19,10 @@ class APIMResponse[T](TypedDict): body: T +class HealthCheckResponse(TypedDict): + status: str + + @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Bundle: """Endpoint to get structured record, replicating lambda handler functionality.""" @@ -54,9 +58,9 @@ def _with_default_headers[T](status_code: int, body: T) -> APIMResponse[T]: @app.route("/health", methods=["GET"]) -def health_check() -> APIMResponse[dict[str, Any]]: +def health_check() -> HealthCheckResponse: """Health check endpoint.""" - return _with_default_headers(status_code=200, body={"status": "healthy"}) + return {"status": "healthy"} if __name__ == "__main__": diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 15d864c4..00d6a460 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -144,19 +144,12 @@ def test_health_check_returns_200_and_healthy_status( assert response.status_code == 200 data = response.get_json() - assert data["statusCode"] == 200 - assert data["body"]["status"] == "healthy" - assert data["headers"]["Content-Type"] == "application/json" + assert data["status"] == "healthy" + @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "PATCH"]) def test_health_check_only_accepts_get_method( - self, client: FlaskClient[Flask] + self, client: FlaskClient[Flask], method: str ) -> None: """Test that health_check only accepts GET method.""" - response = client.post("/health") - assert response.status_code == 405 # Method Not Allowed - - response = client.put("/health") - assert response.status_code == 405 - - response = client.delete("/health") + response = client.open("/health", method=method) assert response.status_code == 405 From 8317bb7fd54c27984b34c412443301f56abeb1a1 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:11:20 +0000 Subject: [PATCH 10/54] [GPCAPIM-254]: Remove the lambda. --- gateway-api/lambda_handler.py | 38 -------- gateway-api/openapi.yaml | 48 +++------- gateway-api/src/fhir/py.typed | 0 gateway-api/src/gateway_api/app.py | 36 -------- gateway-api/src/gateway_api/test_app.py | 81 ----------------- gateway-api/test_lambda_handler.py | 63 ------------- .../acceptance/features/happy_path.feature | 16 ++++ ...test_hello_world.py => test_happy_path.py} | 8 +- .../tests/acceptance/steps/happy_path.py | 73 +++++++++++++++ gateway-api/tests/conftest.py | 89 ++++++++++++++----- ...GatewayAPIConsumer-GatewayAPIProvider.json | 40 --------- .../tests/contract/test_consumer_contract.py | 48 ---------- .../integration/test_get_structured_record.py | 42 +++++++++ gateway-api/tests/integration/test_main.py | 65 ++++++-------- 14 files changed, 243 insertions(+), 404 deletions(-) delete mode 100644 gateway-api/lambda_handler.py create mode 100644 gateway-api/src/fhir/py.typed delete mode 100644 gateway-api/test_lambda_handler.py create mode 100644 gateway-api/tests/acceptance/features/happy_path.feature rename gateway-api/tests/acceptance/scenarios/{test_hello_world.py => test_happy_path.py} (52%) create mode 100644 gateway-api/tests/acceptance/steps/happy_path.py create mode 100644 gateway-api/tests/integration/test_get_structured_record.py diff --git a/gateway-api/lambda_handler.py b/gateway-api/lambda_handler.py deleted file mode 100644 index 554f6e28..00000000 --- a/gateway-api/lambda_handler.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import TypedDict - -from gateway_api.handler import User, greet - - -class LambdaResponse[T](TypedDict): - """A lambda response including a body with a generic type.""" - - statusCode: int - headers: dict[str, str] - body: T - - -def _with_default_headers[T](status_code: int, body: T) -> LambdaResponse[T]: - return { - "statusCode": status_code, - "headers": {"Content-Type": "application/json"}, - "body": body, - } - - -def handler(event: dict[str, str], context: dict[str, str]) -> LambdaResponse[str]: - print(f"Received event: {event}") - - if "payload" not in event: - return _with_default_headers(status_code=400, body="Name is required") - - name = event["payload"] - if not name: - return _with_default_headers(status_code=400, body="Name cannot be empty") - user = User(name=name) - - try: - return _with_default_headers(status_code=200, body=f"{greet(user)}") - except ValueError: - return _with_default_headers( - status_code=404, body=f"Provided name cannot be found. name={name}" - ) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index a318d4d9..8a52ceab 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -141,48 +141,22 @@ paths: type: string format: date example: "1985-04-12" - /2015-03-31/functions/function/invocations: - post: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - example: "Alex" + /health: + get: + summary: Health check + description: Returns the health status of the API + operationId: healthCheck responses: '200': - description: Successful response + description: Service is healthy content: application/json: schema: type: object properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: + status: type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred + example: "healthy" + required: + - status + diff --git a/gateway-api/src/fhir/py.typed b/gateway-api/src/fhir/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 240dc9a6..f6ef72f3 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -6,57 +6,21 @@ from gateway_api.get_structed_record.handler import GetStructuredRecordHandler from gateway_api.get_structed_record.request import GetStructuredRecordRequest -from gateway_api.handler import User, greet app = Flask(__name__) -class APIMResponse[T](TypedDict): - """A API Management response including a body with a generic type.""" - - statusCode: int - headers: dict[str, str] - body: T - - class HealthCheckResponse(TypedDict): status: str @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Bundle: - """Endpoint to get structured record, replicating lambda handler functionality.""" get_structured_record_request = GetStructuredRecordRequest(request) response = GetStructuredRecordHandler.handle(get_structured_record_request) return response -@app.route("/2015-03-31/functions/function/invocations", methods=["POST"]) -def greet_endpoint() -> APIMResponse[str | dict[str, str]]: - """Greet endpoint that replicates the lambda handler functionality.""" - data = request.get_json(force=True) - if "payload" not in data: - return _with_default_headers(status_code=400, body="Name is required") - - name = data["payload"] - if not name: - return _with_default_headers(status_code=400, body="Name cannot be empty") - user = User(name=name) - - try: - return _with_default_headers(status_code=200, body=f"{greet(user)}") - except ValueError: - return _with_default_headers( - status_code=404, body=f"Provided name cannot be found. name={name}" - ) - - -def _with_default_headers[T](status_code: int, body: T) -> APIMResponse[T]: - return APIMResponse( - statusCode=status_code, headers={"Content-Type": "application/json"}, body=body - ) - - @app.route("/health", methods=["GET"]) def health_check() -> HealthCheckResponse: """Health check endpoint.""" diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 00d6a460..a382ccc1 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -52,87 +52,6 @@ def test_get_structured_record_returns_200_with_bundle( assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" -class TestGreetEndpoint: - """Unit tests for the greet_endpoint function.""" - - def test_greet_endpoint_returns_greeting_for_valid_name( - self, client: FlaskClient[Flask] - ) -> None: - """Test that greet_endpoint returns a greeting for a valid name.""" - response = client.post( - "/2015-03-31/functions/function/invocations", - json={"payload": "Alice"}, - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["statusCode"] == 200 - assert data["headers"]["Content-Type"] == "application/json" - assert "Alice" in data["body"] - assert data["body"].endswith("!") - - def test_greet_endpoint_returns_400_when_payload_missing( - self, client: FlaskClient[Flask] - ) -> None: - """Test that greet_endpoint returns 400 when payload is missing.""" - response = client.post( - "/2015-03-31/functions/function/invocations", - json={}, - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["statusCode"] == 400 - assert data["body"] == "Name is required" - assert data["headers"]["Content-Type"] == "application/json" - - def test_greet_endpoint_returns_400_when_name_is_empty( - self, client: FlaskClient[Flask] - ) -> None: - """Test that greet_endpoint returns 400 when name is empty.""" - response = client.post( - "/2015-03-31/functions/function/invocations", - json={"payload": ""}, - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["statusCode"] == 400 - assert data["body"] == "Name cannot be empty" - assert data["headers"]["Content-Type"] == "application/json" - - def test_greet_endpoint_returns_404_for_nonexistent_user( - self, client: FlaskClient[Flask] - ) -> None: - """Test that greet_endpoint returns 404 for nonexistent user.""" - response = client.post( - "/2015-03-31/functions/function/invocations", - json={"payload": "nonexistent"}, - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["statusCode"] == 404 - assert "cannot be found" in data["body"] - assert "nonexistent" in data["body"] - assert data["headers"]["Content-Type"] == "application/json" - - def test_greet_endpoint_returns_400_when_name_is_none( - self, client: FlaskClient[Flask] - ) -> None: - """Test that greet_endpoint returns 400 when name is None.""" - response = client.post( - "/2015-03-31/functions/function/invocations", - json={"payload": None}, - ) - - assert response.status_code == 200 - data = response.get_json() - assert data["statusCode"] == 400 - assert data["body"] == "Name cannot be empty" - assert data["headers"]["Content-Type"] == "application/json" - - class TestHealthCheck: """Unit tests for the health_check function.""" diff --git a/gateway-api/test_lambda_handler.py b/gateway-api/test_lambda_handler.py deleted file mode 100644 index df38367d..00000000 --- a/gateway-api/test_lambda_handler.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from lambda_handler import handler - - -class TestHandler: - """Unit tests for the Lambda handler function.""" - - @pytest.mark.parametrize( - ("name", "expected_greeting"), - [ - ("Alice", "Hello, Alice!"), - ("Bob", "Hello, Bob!"), - ("John Doe", "Hello, John Doe!"), - ("user123", "Hello, user123!"), - ], - ids=["simple_name_alice", "simple_name_bob", "name_with_space", "alphanumeric"], - ) - def test_handler_success(self, name: str, expected_greeting: str) -> None: - """Test handler returns 200 with greeting for valid names.""" - # Arrange - event = {"payload": name} - context: dict[str, str] = {} - - # Act - response = handler(event, context) - - # Assert - assert response["statusCode"] == 200 - assert response["body"] == expected_greeting - assert response["headers"] == {"Content-Type": "application/json"} - - @pytest.mark.parametrize( - ("event", "expected_status", "expected_body"), - [ - ({"other_key": "value"}, 400, "Name is required"), - ({"payload": ""}, 400, "Name cannot be empty"), - ({"payload": None}, 400, "Name cannot be empty"), - ( - {"payload": "nonexistent"}, - 404, - "Provided name cannot be found. name=nonexistent", - ), - ], - ids=[ - "missing_payload_key", - "empty_payload", - "none_payload", - "nonexistent_user", - ], - ) - def test_handler_error_cases( - self, event: dict[str, str], expected_status: int, expected_body: str - ) -> None: - """Test handler returns appropriate error responses for invalid or - nonexistent input. - """ - # Act - response = handler(event, {}) - - # Assert - assert response["statusCode"] == expected_status - assert response["body"] == expected_body - assert response["headers"] == {"Content-Type": "application/json"} diff --git a/gateway-api/tests/acceptance/features/happy_path.feature b/gateway-api/tests/acceptance/features/happy_path.feature new file mode 100644 index 00000000..b4f5b757 --- /dev/null +++ b/gateway-api/tests/acceptance/features/happy_path.feature @@ -0,0 +1,16 @@ +Feature: Gateway API Hello World + As an API consumer + I want to interact with the Gateway API + So that I can verify it responds correctly to valid and invalid requests + + Background: The API is running + Given the API is running new + + Scenario: Get structured record request + When I send a valid Parameters resource to the endpoint + Then the response status code should be 200 + And the response should contain a valid Bundle resource + + Scenario: Accessing a non-existent endpoint returns a 404 + When I send a valid Parameters resource to a nonexistent endpoint + Then the response status code should be 404 diff --git a/gateway-api/tests/acceptance/scenarios/test_hello_world.py b/gateway-api/tests/acceptance/scenarios/test_happy_path.py similarity index 52% rename from gateway-api/tests/acceptance/scenarios/test_hello_world.py rename to gateway-api/tests/acceptance/scenarios/test_happy_path.py index 93ed4a1a..e175a034 100644 --- a/gateway-api/tests/acceptance/scenarios/test_hello_world.py +++ b/gateway-api/tests/acceptance/scenarios/test_happy_path.py @@ -4,16 +4,16 @@ from pytest_bdd import scenario -from tests.acceptance.steps.hello_world_steps import * # noqa: F403,S2208 - Required to import all hello world steps. +from tests.acceptance.steps.happy_path import * # noqa: F403,S2208 - Required to import all happy path steps. -@scenario("hello_world.feature", "Get hello world message") -def test_hello_world() -> None: +@scenario("happy_path.feature", "Get structured record request") +def test_structured_record_request() -> None: # No body required here as this method simply provides a binding to the BDD step pass -@scenario("hello_world.feature", "Accessing a non-existent endpoint returns a 404") +@scenario("happy_path.feature", "Accessing a non-existent endpoint returns a 404") def test_nonexistent_route() -> None: # No body required here as this method simply provides a binding to the BDD step pass diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py new file mode 100644 index 00000000..b1f64404 --- /dev/null +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -0,0 +1,73 @@ +"""Step definitions for Gateway API hello world feature.""" + +import json +from datetime import timedelta + +import requests +from fhir.bundle import Bundle +from fhir.parameters import Parameters +from pytest_bdd import given, parsers, then, when + +from tests.acceptance.conftest import ResponseContext +from tests.conftest import Client + + +@given("the API is running new") +def check_api_is_running(client: Client) -> None: + response = client.send_health_check() + assert response.status_code == 200 + + +@when("I send a valid Parameters resource to the endpoint") +def send_get_request( + client: Client, + response_context: ResponseContext, + simple_request_payload: Parameters, +) -> None: + response_context.response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + + +@when("I send a valid Parameters resource to a nonexistent endpoint") +def send_to_nonexistent_endpoint( + client: Client, + response_context: ResponseContext, + simple_request_payload: Parameters, +) -> None: + nonexistent_endpoint = f"{client.base_url}/nonexistent" + response_context.response = requests.post( + url=nonexistent_endpoint, + data=json.dumps(simple_request_payload), + timeout=timedelta(seconds=1).total_seconds(), + ) + + +@then( + parsers.cfparse( + "the response status code should be {expected_status:d}", + extra_types={"expected_status": int}, + ) +) +def check_status_code(response_context: ResponseContext, expected_status: int) -> None: + assert response_context.response is not None, "Response has not been set." + assert response_context.response.status_code == expected_status, ( + f"Expected status {expected_status}, " + f"got {response_context.response.status_code}" + ) + + +@then("the response should contain a valid Bundle resource") +def check_response_contains( + response_context: ResponseContext, expected_response_payload: Bundle +) -> None: + """Verify the response contains the expected text. + + Args: + context: Behave context containing the response + expected_text: Text that should be in the response + """ + assert response_context.response, "Response has not been set." + assert response_context.response.json() == expected_response_payload, ( + "Expected response payload does not match actual response payload." + ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 997b044d..a8f6759c 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -8,6 +8,8 @@ import pytest import requests from dotenv import find_dotenv, load_dotenv +from fhir.bundle import Bundle +from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root # find_dotenv searches upward from current directory for .env file @@ -17,30 +19,31 @@ class Client: """A simple HTTP client for testing purposes.""" - def __init__(self, lambda_url: str, timeout: timedelta = timedelta(seconds=1)): - self._lambda_url = lambda_url + def __init__(self, base_url: str, timeout: timedelta = timedelta(seconds=1)): + self.base_url = base_url self._timeout = timeout.total_seconds() - def get_structured_record(self, nhs_number: str) -> requests.Response: + def send_to_get_structured_record_endpoint(self, payload: str) -> requests.Response: """ Send a request to the get_structured_record endpoint with the given NHS number. """ - payload = json.dumps( - { - "resourceType": "Parameters", - "parameter": [ - { - "name": "patientNHSNumber", - "valueIdentifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": nhs_number, - }, - }, - ], - } + url = f"{self.base_url}/patient/$gpc.getstructuredrecord" + headers = {"Content-Type": "application/json"} + return requests.post( + url=url, + data=payload, + headers=headers, + timeout=self._timeout, ) - url = f"{self._lambda_url}/patient/$gpc.getstructuredrecord" - return self._send(url=url, payload=payload) + + def send_health_check(self) -> requests.Response: + """ + Send a health check request to the API. + Returns: + Response object from the request + """ + url = f"{self.base_url}/health" + return requests.get(url=url, timeout=self._timeout) def send(self, message: str) -> requests.Response: """ @@ -51,7 +54,7 @@ def send(self, message: str) -> requests.Response: Response object from the request """ payload = json.dumps({"payload": message}) - url = f"{self._lambda_url}/2015-03-31/functions/function/invocations" + url = f"{self.base_url}/2015-03-31/functions/function/invocations" return self._send(url=url, payload=payload) def send_without_payload(self) -> requests.Response: @@ -61,7 +64,7 @@ def send_without_payload(self) -> requests.Response: Response object from the request """ empty_payload = json.dumps({}) - url = f"{self._lambda_url}/2015-03-31/functions/function/invocations" + url = f"{self.base_url}/2015-03-31/functions/function/invocations" return self._send(url=url, payload=empty_payload) def _send(self, url: str, payload: str) -> requests.Response: @@ -72,10 +75,54 @@ def _send(self, url: str, payload: str) -> requests.Response: ) +@pytest.fixture +def simple_request_payload() -> Parameters: + return { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + + +@pytest.fixture +def expected_response_payload() -> Bundle: + return { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [{"use": "official", "family": "Doe", "given": ["John"]}], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + + @pytest.fixture(scope="module") def client(base_url: str) -> Client: """Create a test client for the application.""" - return Client(lambda_url=base_url) + return Client(base_url=base_url) @pytest.fixture(scope="module") diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 47af75f5..43863082 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -88,46 +88,6 @@ "status": 200 }, "type": "Synchronous/HTTP" - }, - { - "description": "a request for the hello world message", - "pending": false, - "request": { - "body": { - "content": { - "payload": "World" - }, - "contentType": "application/json", - "encoded": false - }, - "headers": { - "Content-Type": [ - "application/json" - ] - }, - "method": "POST", - "path": "/2015-03-31/functions/function/invocations" - }, - "response": { - "body": { - "content": { - "body": "Hello, World!", - "headers": { - "Content-Type": "application/json" - }, - "statusCode": 200 - }, - "contentType": "application/json", - "encoded": false - }, - "headers": { - "Content-Type": [ - "application/json" - ] - }, - "status": 200 - }, - "type": "Synchronous/HTTP" } ], "metadata": { diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index 68b33aaa..12e51a17 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -13,54 +13,6 @@ class TestConsumerContract: """Consumer contract tests to define expected API behavior.""" - def test_get_hello_world(self) -> None: - """Test the consumer's expectation of the hello world endpoint. - - This test defines the contract: when the consumer requests - GET/PUT/POST/PATCH/TRACE/DELETE to the - /2015-03-31/functions/function/invocations endpoint, with a payload of "World", - a 200 response containing "Hello, World!" is returned. - """ - pact = Pact(consumer="GatewayAPIConsumer", provider="GatewayAPIProvider") - - # Define the expected interaction - ( - pact.upon_receiving("a request for the hello world message") - .with_body({"payload": "World"}) - .with_request( - method="POST", - path="/2015-03-31/functions/function/invocations", - ) - .will_respond_with(status=200) - .with_body( - { - "statusCode": 200, - "headers": {"Content-Type": "application/json"}, - "body": "Hello, World!", - }, - content_type="application/json", - ) - ) - - # Start the mock server and execute the test - with pact.serve() as server: - # Make the actual request to the mock provider - response = requests.post( - f"{server.url}/2015-03-31/functions/function/invocations", - json={"payload": "World"}, - timeout=10, - ) - - # Verify the response matches expectations - assert response.status_code == 200 - body = response.json() - assert body["body"] == "Hello, World!" - assert body["statusCode"] == 200 - assert body["headers"] == {"Content-Type": "application/json"} - - # Write the pact file after the test - pact.write_file("tests/contract/pacts") - def test_get_structured_record(self) -> None: """Test the consumer's expectation of the get structured record endpoint. diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py new file mode 100644 index 00000000..b32460eb --- /dev/null +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -0,0 +1,42 @@ +"""Integration tests for the gateway API using pytest.""" + +import json + +from fhir.bundle import Bundle +from fhir.parameters import Parameters + +from tests.conftest import Client + + +class TestGetStructuredRecord: + """Test suite for the hello world endpoint.""" + + def test_happy_path_returns_200( + self, client: Client, simple_request_payload: Parameters + ) -> None: + """Test that the root endpoint returns a 200 status code.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert response.status_code == 200 + + def test_happy_path_returns_correct_message( + self, + client: Client, + simple_request_payload: Parameters, + expected_response_payload: Bundle, + ) -> None: + """Test that the root endpoint returns the correct message.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert response.json() == expected_response_payload + + def test_happy_path_content_type( + self, client: Client, simple_request_payload: Parameters + ) -> None: + """Test that the response has the correct content type.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert "application/json" in response.headers["Content-Type"] diff --git a/gateway-api/tests/integration/test_main.py b/gateway-api/tests/integration/test_main.py index 0ccb3786..49f22d9a 100644 --- a/gateway-api/tests/integration/test_main.py +++ b/gateway-api/tests/integration/test_main.py @@ -1,49 +1,42 @@ """Integration tests for the gateway API using pytest.""" +import json + +from fhir.bundle import Bundle +from fhir.parameters import Parameters + from tests.conftest import Client -class TestHelloWorld: +class TestGetStructuredRecord: """Test suite for the hello world endpoint.""" - def test_hello_world_returns_200(self, client: Client) -> None: + def test_happy_path_returns_200( + self, client: Client, simple_request_payload: Parameters + ) -> None: """Test that the root endpoint returns a 200 status code.""" - response = client.send("world") + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) assert response.status_code == 200 - def test_hello_world_returns_correct_message(self, client: Client) -> None: + def test_happy_path_returns_correct_message( + self, + client: Client, + simple_request_payload: Parameters, + expected_response_payload: Bundle, + ) -> None: """Test that the root endpoint returns the correct message.""" - response = client.send("World") - assert response.json()["body"] == "Hello, World!" - - def test_hello_world_content_type(self, client: Client) -> None: + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert response.json() == expected_response_payload + + def test_happy_path_content_type( + self, client: Client, simple_request_payload: Bundle + ) -> None: """Test that the response has the correct content type.""" - response = client.send("world") + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) assert "application/json" in response.headers["Content-Type"] - - def test_nonexistent_returns_error(self, client: Client) -> None: - """Test that non-existent routes return 404.""" - response = client.send("nonexistent") - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Provided name cannot be found. name=nonexistent" - - status_code = response.json().get("statusCode") - assert status_code == 404 - - def test_no_payload_returns_error(self, client: Client) -> None: - """Test that an error is returned when no payload is provided.""" - response = client.send_without_payload() - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Name is required" - - def test_empty_name_returns_error(self, client: Client) -> None: - """Test that an error is returned when an empty name is provided.""" - response = client.send("") - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Name cannot be empty" From 5e060c5a5adb1ce678ed60b599400eabe7ebe3eb Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:18:39 +0000 Subject: [PATCH 11/54] [GPCAPIM-254]: Clean up github actions. --- .github/actions/start-app/action.yaml | 4 +- .../actions/start-local-lambda/action.yaml | 50 ------------------- 2 files changed, 2 insertions(+), 52 deletions(-) delete mode 100644 .github/actions/start-local-lambda/action.yaml diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml index 97a88061..ec2afd77 100644 --- a/.github/actions/start-app/action.yaml +++ b/.github/actions/start-app/action.yaml @@ -8,7 +8,7 @@ inputs: health-path: description: "Health probe path to POST" required: false - default: "/2015-03-31/functions/function/invocations" + default: "/health" max-seconds: description: "Maximum seconds to wait for readiness" required: false @@ -38,7 +38,7 @@ runs: MAX="${{ inputs.max-seconds }}" echo "Waiting for app at ${HEALTH_URL} (max ${MAX}s)..." for i in $(seq 1 "${MAX}"); do - if curl -sSf -X POST "${HEALTH_URL}" -d '{}' >/dev/null; then + if curl -sSf -X GET "${HEALTH_URL}" >/dev/null; then echo "App is ready" exit 0 fi diff --git a/.github/actions/start-local-lambda/action.yaml b/.github/actions/start-local-lambda/action.yaml deleted file mode 100644 index 49d77405..00000000 --- a/.github/actions/start-local-lambda/action.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: "Start local Lambda environment" -description: "Start a local AWS Lambda environment for testing" -inputs: - deploy-command: - description: "Command to start local Lambda" - required: false - default: "make deploy" - health-path: - description: "Health probe path to POST" - required: false - default: "/2015-03-31/functions/function/invocations" - max-seconds: - description: "Maximum seconds to wait for readiness" - required: false - default: "60" -python-version: - description: "Python version to install" - required: true -runs: - using: "composite" - steps: - - name: "Start local Lambda environment" - shell: bash - env: - PYTHON_VERSION: ${{ inputs.python-version }} - run: | - set -euo pipefail - echo "Starting local Lambda: '${{ inputs.deploy-command }}'" - nohup ${{ inputs.deploy-command }} >/tmp/lambda.log 2>&1 & - echo $! > /tmp/lambda.pid - echo "PID: $(cat /tmp/lambda.pid)" - - name: "Wait for Lambda to be ready" - shell: bash - run: | - set -euo pipefail - BASE_URL="${BASE_URL:-http://localhost:5000}" - HEALTH_URL="${BASE_URL}${{ inputs.health-path }}" - MAX="${{ inputs.max-seconds }}" - echo "Waiting for Lambda at ${HEALTH_URL} (max ${MAX}s)..." - for i in $(seq 1 "${MAX}"); do - if curl -sSf -X POST "${HEALTH_URL}" -d '{}' >/dev/null; then - echo "Lambda is ready" - exit 0 - fi - sleep 1 - done - echo "Lambda did not become ready in time" - echo "---- recent lambda log ----" - tail -n 200 /tmp/lambda.log || true - exit 1 From 5c5c82cb72ed822e3c74fc853118b714ba0ec2ec Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:16:03 +0000 Subject: [PATCH 12/54] [GPCAPIM-254]: Clean up. --- .github/actions/start-app/action.yaml | 4 +- gateway-api/src/fhir/bundle.py | 4 -- gateway-api/src/fhir/human_name.py | 2 - gateway-api/src/fhir/identifier.py | 2 - gateway-api/src/fhir/parameters.py | 4 -- gateway-api/src/fhir/patient.py | 2 - gateway-api/src/gateway_api/app.py | 11 ++- .../get_structed_record/__init__.py | 6 ++ .../get_structed_record/request.py | 8 ++- gateway-api/src/gateway_api/handler.py | 17 ----- gateway-api/src/gateway_api/test_app.py | 13 ++-- gateway-api/src/gateway_api/test_handler.py | 54 -------------- .../tests/acceptance/steps/happy_path.py | 8 +-- .../acceptance/steps/hello_world_steps.py | 70 ------------------- gateway-api/tests/conftest.py | 30 -------- .../integration/test_get_structured_record.py | 2 - gateway-api/tests/integration/test_main.py | 42 ----------- infrastructure/images/gateway-api/Dockerfile | 1 + 18 files changed, 29 insertions(+), 251 deletions(-) delete mode 100644 gateway-api/src/gateway_api/handler.py delete mode 100644 gateway-api/src/gateway_api/test_handler.py delete mode 100644 gateway-api/tests/acceptance/steps/hello_world_steps.py delete mode 100644 gateway-api/tests/integration/test_main.py diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml index ec2afd77..0f6d6d20 100644 --- a/.github/actions/start-app/action.yaml +++ b/.github/actions/start-app/action.yaml @@ -6,7 +6,7 @@ inputs: required: false default: "make deploy" health-path: - description: "Health probe path to POST" + description: "Health check path" required: false default: "/health" max-seconds: @@ -26,7 +26,7 @@ runs: run: | set -euo pipefail echo "Starting app: '${{ inputs.deploy-command }}'" - nohup ${{ inputs.deploy-command }} >/tmp/app.log 2>&1 & + nohup ${{ inputs.deploy-command }} > /tmp/app.log 2>&1 & echo $! > /tmp/app.pid echo "PID: $(cat /tmp/app.pid)" - name: "Wait for app to be ready" diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py index afe49f94..5fbc9a3b 100644 --- a/gateway-api/src/fhir/bundle.py +++ b/gateway-api/src/fhir/bundle.py @@ -6,15 +6,11 @@ class BundleEntry(TypedDict): - """FHIR Bundle entry.""" - fullUrl: str resource: Patient class Bundle(TypedDict): - """FHIR Bundle resource.""" - resourceType: str id: str type: str diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py index bc56d529..2a73deb0 100644 --- a/gateway-api/src/fhir/human_name.py +++ b/gateway-api/src/fhir/human_name.py @@ -4,8 +4,6 @@ class HumanName(TypedDict): - """FHIR HumanName type.""" - use: str family: str given: list[str] diff --git a/gateway-api/src/fhir/identifier.py b/gateway-api/src/fhir/identifier.py index feb62aee..4e59908d 100644 --- a/gateway-api/src/fhir/identifier.py +++ b/gateway-api/src/fhir/identifier.py @@ -4,7 +4,5 @@ class Identifier(TypedDict): - """FHIR Identifier type.""" - system: str value: str diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py index eef46ca6..30b7cce8 100644 --- a/gateway-api/src/fhir/parameters.py +++ b/gateway-api/src/fhir/parameters.py @@ -6,14 +6,10 @@ class Parameter(TypedDict): - """FHIR Parameter type.""" - name: str valueIdentifier: Identifier class Parameters(TypedDict): - """FHIR Parameters resource.""" - resourceType: str parameter: list[Parameter] diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py index e23eb449..33d0ce41 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -7,8 +7,6 @@ class Patient(TypedDict): - """FHIR Patient resource.""" - resourceType: str id: str identifier: list[Identifier] diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index f6ef72f3..ba1ca820 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,8 +4,10 @@ from fhir import Bundle from flask import Flask, request -from gateway_api.get_structed_record.handler import GetStructuredRecordHandler -from gateway_api.get_structed_record.request import GetStructuredRecordRequest +from gateway_api.get_structed_record import ( + GetStructuredRecordHandler, + GetStructuredRecordRequest, +) app = Flask(__name__) @@ -29,6 +31,9 @@ def health_check() -> HealthCheckResponse: if __name__ == "__main__": host = os.getenv("FLASK_HOST") + port = os.getenv("FLASK_PORT") if host is None: raise RuntimeError("FLASK_HOST environment variable is not set.") - app.run(host=host, port=8080) + if port is None: + raise RuntimeError("FLASK_PORT environment variable is not set.") + app.run(host=host, port=int(port)) diff --git a/gateway-api/src/gateway_api/get_structed_record/__init__.py b/gateway-api/src/gateway_api/get_structed_record/__init__.py index e69de29b..9861ad49 100644 --- a/gateway-api/src/gateway_api/get_structed_record/__init__.py +++ b/gateway-api/src/gateway_api/get_structed_record/__init__.py @@ -0,0 +1,6 @@ +"""Get Structured Record module.""" + +from gateway_api.get_structed_record.handler import GetStructuredRecordHandler +from gateway_api.get_structed_record.request import GetStructuredRecordRequest + +__all__ = ["GetStructuredRecordHandler", "GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structed_record/request.py b/gateway-api/src/gateway_api/get_structed_record/request.py index 7a0f7311..9f054b76 100644 --- a/gateway-api/src/gateway_api/get_structed_record/request.py +++ b/gateway-api/src/gateway_api/get_structed_record/request.py @@ -7,6 +7,10 @@ class GetStructuredRecordRequest: + interaction_id: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" + resource: str = "patient" + fhir_operation: str = "$gpc.getstructuredrecord" + def __init__(self, request: Request) -> None: self._http_request = request self._headers = request.headers @@ -24,10 +28,10 @@ def nhs_number(self) -> str: @property def consumer_asid(self) -> str: - consumer_asid: str = self._headers["X-Consumer-ASID"] + consumer_asid: str = self._headers["Ssp-from"] return consumer_asid @property def provider_asid(self) -> str: - provider_asid: str = self._headers["X-Provider-ASID"] + provider_asid: str = self._headers["Ssp-to"] return provider_asid diff --git a/gateway-api/src/gateway_api/handler.py b/gateway-api/src/gateway_api/handler.py deleted file mode 100644 index a3f66b94..00000000 --- a/gateway-api/src/gateway_api/handler.py +++ /dev/null @@ -1,17 +0,0 @@ -from clinical_data_common import get_hello - - -class User: - def __init__(self, name: str): - self._name = name - - @property - def name(self) -> str: - return self._name - - -def greet(user: User) -> str: - if user.name == "nonexistent": - raise ValueError("nonexistent user provided.") - hello = get_hello() - return f"{hello}{user.name}!" diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index a382ccc1..f05537f9 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -1,6 +1,7 @@ """Unit tests for the Flask app endpoints.""" from collections.abc import Generator +from typing import TYPE_CHECKING import pytest from flask import Flask @@ -8,23 +9,22 @@ from gateway_api.app import app +if TYPE_CHECKING: + from fhir.parameters import Parameters + @pytest.fixture def client() -> Generator[FlaskClient[Flask], None, None]: - """Create a Flask test client.""" app.config["TESTING"] = True with app.test_client() as client: yield client class TestGetStructuredRecord: - """Unit tests for the get_structured_record function.""" - def test_get_structured_record_returns_200_with_bundle( self, client: FlaskClient[Flask] ) -> None: - """Test that get_structured_record returns 200 with a bundle.""" - body = { + body: Parameters = { "resourceType": "Parameters", "parameter": [ { @@ -53,12 +53,9 @@ def test_get_structured_record_returns_200_with_bundle( class TestHealthCheck: - """Unit tests for the health_check function.""" - def test_health_check_returns_200_and_healthy_status( self, client: FlaskClient[Flask] ) -> None: - """Test that health_check returns 200 with healthy status.""" response = client.get("/health") assert response.status_code == 200 diff --git a/gateway-api/src/gateway_api/test_handler.py b/gateway-api/src/gateway_api/test_handler.py deleted file mode 100644 index f2092af7..00000000 --- a/gateway-api/src/gateway_api/test_handler.py +++ /dev/null @@ -1,54 +0,0 @@ -import pytest - -from gateway_api.handler import User, greet - - -class TestUser: - """Test suite for the User class.""" - - @pytest.mark.parametrize( - "name", - [ - "Alice", - "Bob", - "", - "O'Brien", - ], - ) - def test_user_initialization(self, name: str) -> None: - """Test that a User can be initialized with various names.""" - user = User(name) - assert user.name == name - - def test_user_name_is_immutable(self) -> None: - """Test that the name property cannot be directly modified.""" - user = User("Charlie") - with pytest.raises(AttributeError): - user.name = "David" # type: ignore[misc] - - -class TestGreet: - """Test suite for the greet function.""" - - @pytest.mark.parametrize( - ("name", "expected_greeting"), - [ - ("Alice", "Hello, Alice!"), - ("Bob", "Hello, Bob!"), - ("", "Hello, !"), - ("O'Brien", "Hello, O'Brien!"), - ("Nonexistent", "Hello, Nonexistent!"), - ("nonexistent ", "Hello, nonexistent !"), - ], - ) - def test_greet_with_valid_users(self, name: str, expected_greeting: str) -> None: - """Test that greet returns the correct greeting for various valid users.""" - user = User(name) - result = greet(user) - assert result == expected_greeting - - def test_greet_with_nonexistent_user_raises_value_error(self) -> None: - """Test that greet raises ValueError for nonexistent user.""" - user = User("nonexistent") - with pytest.raises(ValueError, match="nonexistent user provided."): - greet(user) diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index b1f64404..016ccb82 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -1,4 +1,4 @@ -"""Step definitions for Gateway API hello world feature.""" +"""Step definitions for Gateway API happy path feature.""" import json from datetime import timedelta @@ -61,12 +61,6 @@ def check_status_code(response_context: ResponseContext, expected_status: int) - def check_response_contains( response_context: ResponseContext, expected_response_payload: Bundle ) -> None: - """Verify the response contains the expected text. - - Args: - context: Behave context containing the response - expected_text: Text that should be in the response - """ assert response_context.response, "Response has not been set." assert response_context.response.json() == expected_response_payload, ( "Expected response payload does not match actual response payload." diff --git a/gateway-api/tests/acceptance/steps/hello_world_steps.py b/gateway-api/tests/acceptance/steps/hello_world_steps.py deleted file mode 100644 index a7439725..00000000 --- a/gateway-api/tests/acceptance/steps/hello_world_steps.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Step definitions for Gateway API hello world feature.""" - -from pytest_bdd import given, parsers, then, when - -from tests.acceptance.conftest import ResponseContext -from tests.conftest import Client - - -@given("the API is running") -def step_api_is_running(client: Client) -> None: - """Verify the API test client is available. - - Args: - client: Test client from conftest.py - """ - response = client.send("test") - assert response.text is not None - assert response.status_code == 200 - - -@when(parsers.cfparse('I send "{message}" to the endpoint')) -def step_send_get_request( - client: Client, message: str, response_context: ResponseContext -) -> None: - """Send a GET request to the specified endpoint. - - Args: - client: Test client - endpoint: The API endpoint path to request - """ - response_context.response = client.send(message) - - -# fmt: off -@then(parsers.cfparse("the response status code should be {expected_status:d}",extra_types={"expected_status": int})) # noqa: E501 - BDD steps must be declared on a singular line. -# fmt: on -def step_check_status_code( - response_context: ResponseContext, expected_status: int -) -> None: - """Verify the response status code matches expected value. - - Args: - context: Behave context containing the response - expected_status: Expected HTTP status code - """ - assert response_context.response, "Response has not been set." - - data = response_context.response.json() - - assert data["statusCode"] == expected_status, ( - f"Expected status {expected_status}, " - f"got {response_context.response.status_code}" - ) - - -@then(parsers.cfparse('the response should contain "{expected_text}"')) -def step_check_response_contains( - response_context: ResponseContext, expected_text: str -) -> None: - """Verify the response contains the expected text. - - Args: - context: Behave context containing the response - expected_text: Text that should be in the response - """ - assert response_context.response, "Response has not been set." - - assert expected_text in response_context.response.text, ( - f"Expected '{expected_text}' in response, got: {response_context.response.text}" - ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index a8f6759c..7c982b8c 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -1,6 +1,5 @@ """Pytest configuration and shared fixtures for gateway API tests.""" -import json import os from datetime import timedelta from typing import cast @@ -45,35 +44,6 @@ def send_health_check(self) -> requests.Response: url = f"{self.base_url}/health" return requests.get(url=url, timeout=self._timeout) - def send(self, message: str) -> requests.Response: - """ - Send a request to the APIs with some given parameters. - Args: - data: The data to send in the request payload - Returns: - Response object from the request - """ - payload = json.dumps({"payload": message}) - url = f"{self.base_url}/2015-03-31/functions/function/invocations" - return self._send(url=url, payload=payload) - - def send_without_payload(self) -> requests.Response: - """ - Send a request to the APIs without a payload. - Returns: - Response object from the request - """ - empty_payload = json.dumps({}) - url = f"{self.base_url}/2015-03-31/functions/function/invocations" - return self._send(url=url, payload=empty_payload) - - def _send(self, url: str, payload: str) -> requests.Response: - return requests.post( - url=url, - data=payload, - timeout=self._timeout, - ) - @pytest.fixture def simple_request_payload() -> Parameters: diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index b32460eb..d0f8dc99 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -9,8 +9,6 @@ class TestGetStructuredRecord: - """Test suite for the hello world endpoint.""" - def test_happy_path_returns_200( self, client: Client, simple_request_payload: Parameters ) -> None: diff --git a/gateway-api/tests/integration/test_main.py b/gateway-api/tests/integration/test_main.py deleted file mode 100644 index 49f22d9a..00000000 --- a/gateway-api/tests/integration/test_main.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Integration tests for the gateway API using pytest.""" - -import json - -from fhir.bundle import Bundle -from fhir.parameters import Parameters - -from tests.conftest import Client - - -class TestGetStructuredRecord: - """Test suite for the hello world endpoint.""" - - def test_happy_path_returns_200( - self, client: Client, simple_request_payload: Parameters - ) -> None: - """Test that the root endpoint returns a 200 status code.""" - response = client.send_to_get_structured_record_endpoint( - json.dumps(simple_request_payload) - ) - assert response.status_code == 200 - - def test_happy_path_returns_correct_message( - self, - client: Client, - simple_request_payload: Parameters, - expected_response_payload: Bundle, - ) -> None: - """Test that the root endpoint returns the correct message.""" - response = client.send_to_get_structured_record_endpoint( - json.dumps(simple_request_payload) - ) - assert response.json() == expected_response_payload - - def test_happy_path_content_type( - self, client: Client, simple_request_payload: Bundle - ) -> 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/json" in response.headers["Content-Type"] diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index f3ce577f..67cbb1d3 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /resources/build/gateway-api ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" +ENV FLASK_PORT="8080" ENTRYPOINT ["python"] CMD ["gateway_api/app.py"] From 33326b58905a9efeef0f32a698731fb0c22cfa43 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:16:55 +0000 Subject: [PATCH 13/54] [GPCAPIM-254]: Unit tests no loonger exist in the top level. --- scripts/tests/run-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/run-test.sh b/scripts/tests/run-test.sh index d2c3177c..7d7fd4e1 100755 --- a/scripts/tests/run-test.sh +++ b/scripts/tests/run-test.sh @@ -25,7 +25,7 @@ cd "$(git rev-parse --show-toplevel)" # Determine test path based on test type if [[ "$TEST_TYPE" = "unit" ]]; then - TEST_PATH="test_*.py src/*/test_*.py" + TEST_PATH="src/*/test_*.py" else TEST_PATH="tests/${TEST_TYPE}/" fi From a78aa6bf56b4931bb964d61233de29c4ffb8daba Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:46:21 +0000 Subject: [PATCH 14/54] [GPCAPIM-254]: Correct content-type header. --- gateway-api/openapi.yaml | 12 ++++++------ gateway-api/tests/conftest.py | 2 +- gateway-api/tests/contract/test_consumer_contract.py | 10 +++++----- .../tests/integration/test_get_structured_record.py | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index 8a52ceab..e91ff7bd 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -19,12 +19,12 @@ paths: name: Content-Type schema: type: string - enum: [application/json] + enum: [application/fhir+json] required: true requestBody: required: true content: - application/json: + application/fhir+json: schema: type: object properties: @@ -56,10 +56,10 @@ paths: name: Content-Type schema: type: string - enum: [application/json] + enum: [application/fhir+json] required: true content: - application/json: + application/fhir+json: schema: type: object properties: @@ -72,7 +72,7 @@ paths: properties: Content-Type: type: string - example: "application/json" + example: "application/fhir+json" body: type: object description: FHIR Bundle containing patient data @@ -150,7 +150,7 @@ paths: '200': description: Service is healthy content: - application/json: + application/fhir+json: schema: type: object properties: diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index 7c982b8c..5facb089 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -27,7 +27,7 @@ def send_to_get_structured_record_endpoint(self, payload: str) -> requests.Respo Send a request to the get_structured_record endpoint with the given NHS number. """ url = f"{self.base_url}/patient/$gpc.getstructuredrecord" - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/fhir+json"} return requests.post( url=url, data=payload, diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index 12e51a17..2f828234 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -65,16 +65,16 @@ def test_get_structured_record(self) -> None: }, ], }, - content_type="application/json", + content_type="application/fhir+json", ) - .with_header("Content-Type", "application/json") + .with_header("Content-Type", "application/fhir+json") .with_request( method="POST", path="/patient/$gpc.getstructuredrecord", ) .will_respond_with(status=200) - .with_body(expected_bundle, content_type="application/json") - .with_header("Content-Type", "application/json") + .with_body(expected_bundle, content_type="application/fhir+json") + .with_header("Content-Type", "application/fhir+json") ) # Start the mock server and execute the test @@ -96,7 +96,7 @@ def test_get_structured_record(self) -> None: ], } ), - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/fhir+json"}, timeout=10, ) diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index d0f8dc99..0215d840 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -37,4 +37,4 @@ def test_happy_path_content_type( response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) ) - assert "application/json" in response.headers["Content-Type"] + assert "application/fhir+json" in response.headers["Content-Type"] From 2f4f3479bc7657ffe208847d8b882d66fa278c4e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:47:22 +0000 Subject: [PATCH 15/54] [GPCAPIM-254]: Handle response object, rather than just pass back dict. --- gateway-api/src/fhir/__init__.py | 3 ++ gateway-api/src/fhir/operation_outcome.py | 14 ++++++++ gateway-api/src/gateway_api/app.py | 8 ++--- .../get_structed_record/handler.py | 9 +++-- .../get_structed_record/request.py | 36 ++++++++++++++++--- 5 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 gateway-api/src/fhir/operation_outcome.py diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index ea58d5c8..4ad915ee 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -3,6 +3,7 @@ from fhir.bundle import Bundle, BundleEntry from fhir.human_name import HumanName from fhir.identifier import Identifier +from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue from fhir.parameters import Parameter, Parameters from fhir.patient import Patient @@ -11,6 +12,8 @@ "BundleEntry", "HumanName", "Identifier", + "OperationOutcome", + "OperationOutcomeIssue", "Parameter", "Parameters", "Patient", diff --git a/gateway-api/src/fhir/operation_outcome.py b/gateway-api/src/fhir/operation_outcome.py new file mode 100644 index 00000000..d25765f5 --- /dev/null +++ b/gateway-api/src/fhir/operation_outcome.py @@ -0,0 +1,14 @@ +"""FHIR OperationOutcome resource.""" + +from typing import TypedDict + + +class OperationOutcomeIssue(TypedDict): + severity: str + code: str + diagnostics: str + + +class OperationOutcome(TypedDict): + resourceType: str + issue: list[OperationOutcomeIssue] diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index ba1ca820..8858888e 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -1,8 +1,8 @@ import os from typing import TypedDict -from fhir import Bundle from flask import Flask, request +from flask.wrappers import Response from gateway_api.get_structed_record import ( GetStructuredRecordHandler, @@ -17,10 +17,10 @@ class HealthCheckResponse(TypedDict): @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) -def get_structured_record() -> Bundle: +def get_structured_record() -> Response: get_structured_record_request = GetStructuredRecordRequest(request) - response = GetStructuredRecordHandler.handle(get_structured_record_request) - return response + GetStructuredRecordHandler.handle(get_structured_record_request) + return get_structured_record_request.build_response() @app.route("/health", methods=["GET"]) diff --git a/gateway-api/src/gateway_api/get_structed_record/handler.py b/gateway-api/src/gateway_api/get_structed_record/handler.py index 5a301bd4..eb692fae 100644 --- a/gateway-api/src/gateway_api/get_structed_record/handler.py +++ b/gateway-api/src/gateway_api/get_structed_record/handler.py @@ -1,11 +1,14 @@ -from fhir import Bundle +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fhir import Bundle from gateway_api.get_structed_record.request import GetStructuredRecordRequest class GetStructuredRecordHandler: @classmethod - def handle(cls, request: GetStructuredRecordRequest) -> Bundle: + def handle(cls, request: GetStructuredRecordRequest) -> None: bundle: Bundle = { "resourceType": "Bundle", "id": "example-patient-bundle", @@ -32,4 +35,4 @@ def handle(cls, request: GetStructuredRecordRequest) -> Bundle: } ], } - return bundle + request.set_positive_response(bundle) diff --git a/gateway-api/src/gateway_api/get_structed_record/request.py b/gateway-api/src/gateway_api/get_structed_record/request.py index 9f054b76..3d6ecdaa 100644 --- a/gateway-api/src/gateway_api/get_structed_record/request.py +++ b/gateway-api/src/gateway_api/get_structed_record/request.py @@ -1,9 +1,9 @@ -from typing import TYPE_CHECKING +import json -from flask.wrappers import Request - -if TYPE_CHECKING: - from fhir import Parameters +from fhir import OperationOutcome, Parameters +from fhir.bundle import Bundle +from fhir.operation_outcome import OperationOutcomeIssue +from flask.wrappers import Request, Response class GetStructuredRecordRequest: @@ -15,6 +15,8 @@ def __init__(self, request: Request) -> None: self._http_request = request self._headers = request.headers self._request_body: Parameters = request.get_json() + self._response_body: Bundle | OperationOutcome | None = None + self._status_code: int | None = None @property def trace_id(self) -> str: @@ -35,3 +37,27 @@ def consumer_asid(self) -> str: def provider_asid(self) -> str: provider_asid: str = self._headers["Ssp-to"] return provider_asid + + def build_response(self) -> Response: + return Response( + response=json.dumps(self._response_body), + status=self._status_code, + mimetype="application/fhir+json", + ) + + def set_positive_response(self, bundle: Bundle) -> None: + self._status_code = 200 + self._response_body = bundle + + def set_negative_response(self, error: str) -> None: + self._status_code = 500 + self._response_body = OperationOutcome( + resourceType="OperationOutcome", + issue=[ + OperationOutcomeIssue( + severity="error", + code="exception", + diagnostics=error, + ) + ], + ) From d6d1018b0834098c9f12b67aa6af67f8b8dd2588 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:53:26 +0000 Subject: [PATCH 16/54] [GPCAPIM-254]: Correct content-type header for healthcheck. --- gateway-api/openapi.yaml | 2 +- .../pacts/GatewayAPIConsumer-GatewayAPIProvider.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index e91ff7bd..96b3f30e 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -150,7 +150,7 @@ paths: '200': description: Service is healthy content: - application/fhir+json: + application/json: schema: type: object properties: diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 43863082..6d60fef5 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -32,12 +32,12 @@ ], "resourceType": "Parameters" }, - "contentType": "application/json", + "contentType": "application/fhir+json", "encoded": false }, "headers": { "Content-Type": [ - "application/json" + "application/fhir+json" ] }, "method": "POST", @@ -77,12 +77,12 @@ "timestamp": "2026-01-12T10:00:00Z", "type": "collection" }, - "contentType": "application/json", + "contentType": "application/fhir+json", "encoded": false }, "headers": { "Content-Type": [ - "application/json" + "application/fhir+json" ] }, "status": 200 From 976dd3a6af0ed9dfe8ff88cb6cae1f2ed6c86f73 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:12:51 +0000 Subject: [PATCH 17/54] [GPCAPIM-254]: Add error hanlding in app. --- gateway-api/src/gateway_api/app.py | 7 +++-- gateway-api/src/gateway_api/conftest.py | 20 +++++++++++++++ gateway-api/src/gateway_api/test_app.py | 34 +++++++++++++++---------- 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 gateway-api/src/gateway_api/conftest.py diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 8858888e..885d3350 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -18,8 +18,11 @@ class HealthCheckResponse(TypedDict): @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Response: - get_structured_record_request = GetStructuredRecordRequest(request) - GetStructuredRecordHandler.handle(get_structured_record_request) + try: + get_structured_record_request = GetStructuredRecordRequest(request) + GetStructuredRecordHandler.handle(get_structured_record_request) + except Exception as e: + get_structured_record_request.set_negative_response(str(e)) return get_structured_record_request.build_response() diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py new file mode 100644 index 00000000..4042053d --- /dev/null +++ b/gateway-api/src/gateway_api/conftest.py @@ -0,0 +1,20 @@ +"""Pytest configuration and shared fixtures for gateway API tests.""" + +import pytest +from fhir.parameters import Parameters + + +@pytest.fixture +def simple_request_payload() -> Parameters: + return { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index f05537f9..f61fbc0a 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -22,21 +22,11 @@ def client() -> Generator[FlaskClient[Flask], None, None]: class TestGetStructuredRecord: def test_get_structured_record_returns_200_with_bundle( - self, client: FlaskClient[Flask] + self, client: FlaskClient[Flask], simple_request_payload: "Parameters" ) -> None: - body: Parameters = { - "resourceType": "Parameters", - "parameter": [ - { - "name": "patientNHSNumber", - "valueIdentifier": { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - }, - }, - ], - } - response = client.post("/patient/$gpc.getstructuredrecord", json=body) + response = client.post( + "/patient/$gpc.getstructuredrecord", json=simple_request_payload + ) assert response.status_code == 200 data = response.get_json() @@ -51,6 +41,22 @@ def test_get_structured_record_returns_200_with_bundle( assert data["entry"][0]["resource"]["id"] == "9999999999" assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" + def test_get_structured_record_handles_exception( + self, + client: FlaskClient[Flask], + monkeypatch: pytest.MonkeyPatch, + simple_request_payload: "Parameters", + ) -> None: + monkeypatch.setattr( + "gateway_api.get_structed_record.GetStructuredRecordHandler.handle", + Exception(), + ) + + response = client.post( + "/patient/$gpc.getstructuredrecord", json=simple_request_payload + ) + assert response.status_code == 500 + class TestHealthCheck: def test_health_check_returns_200_and_healthy_status( From 859e54a9b76b345dbc80d55db398c521651fc024 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:42:45 +0000 Subject: [PATCH 18/54] Revert "[GPCAPIM-254]: Force new deployment of ecs task in preview environment." This reverts commit f1a3fad6ece7dd5b714bbb0cb1d4f9c2b98861f6. --- infrastructure/environments/preview/main.tf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/infrastructure/environments/preview/main.tf b/infrastructure/environments/preview/main.tf index f28bba5a..b3152e05 100644 --- a/infrastructure/environments/preview/main.tf +++ b/infrastructure/environments/preview/main.tf @@ -209,5 +209,9 @@ resource "aws_ecs_service" "branch" { container_port = var.container_port } + lifecycle { + ignore_changes = [task_definition] + } + depends_on = [aws_lb_listener_rule.branch] } From cd6e96be5c77d94da5c3e5fe094627891ef5af3d Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:50:40 +0000 Subject: [PATCH 19/54] [GPCAPIM-254]: Make it clear which version is deployed in the health endpoint. --- Makefile | 4 +++- gateway-api/src/gateway_api/app.py | 14 ++++++++++++-- infrastructure/images/gateway-api/Dockerfile | 5 +++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 598014d2..40b5f7fe 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ IMAGE_REPOSITORY := ${ECR_URL} endif IMAGE_NAME := ${IMAGE_REPOSITORY}:${IMAGE_TAG} +COMMIT_VERSION := $(shell git rev-parse --short HEAD) +BUILD_DATE := $(shell date -u +"%Y%m%d") # ============================================================================== # Example CI/CD targets are: dependencies, build, publish, deploy, clean, etc. @@ -45,7 +47,7 @@ build-gateway-api: dependencies .PHONY: build build: build-gateway-api # Build the project artefact @Pipeline @echo "Building Docker x86 image using Docker. Utilising python version: ${PYTHON_VERSION} ..." - @$(docker) buildx build --platform linux/amd64 --load --provenance=false --build-arg PYTHON_VERSION=${PYTHON_VERSION} -t ${IMAGE_NAME} infrastructure/images/gateway-api + @$(docker) buildx build --platform linux/amd64 --load --provenance=false --build-arg PYTHON_VERSION=${PYTHON_VERSION} --build-arg COMMIT_VERSION=${COMMIT_VERSION} --build-arg BUILD_DATE=${BUILD_DATE} -t ${IMAGE_NAME} infrastructure/images/gateway-api @echo "Docker image '${IMAGE_NAME}' built successfully!" publish: # Publish the project artefact @Pipeline diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 885d3350..1065861e 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -14,6 +14,7 @@ class HealthCheckResponse(TypedDict): status: str + version: str @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) @@ -29,14 +30,23 @@ def get_structured_record() -> Response: @app.route("/health", methods=["GET"]) def health_check() -> HealthCheckResponse: """Health check endpoint.""" - return {"status": "healthy"} + version: str = "unkonwn" + + commit_version: str | None = os.getenv("COMMIT_VERSION") + build_date: str | None = os.getenv("BUILD_DATE") + if commit_version and build_date: + version = f"{build_date}.{commit_version}" + + return {"status": "healthy", "version": version} if __name__ == "__main__": host = os.getenv("FLASK_HOST") - port = os.getenv("FLASK_PORT") if host is None: raise RuntimeError("FLASK_HOST environment variable is not set.") + port = os.getenv("FLASK_PORT") if port is None: raise RuntimeError("FLASK_PORT environment variable is not set.") + print(f"Starting Gateway API on {host}:{port}") + print(f"Version: {os.getenv('COMMIT_VERSION')}") app.run(host=host, port=int(port)) diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 67cbb1d3..ffcd00df 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -10,6 +10,11 @@ ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" ENV FLASK_PORT="8080" +ARG COMMIT_VERSION +ENV COMMIT_VERSION=$COMMIT_VERSION +ARG BUILD_DATE +ENV BUILD_DATE=$BUILD_DATE + ENTRYPOINT ["python"] CMD ["gateway_api/app.py"] From 00f676a7d436f9ee4c18354e58937e6ab2f58ebb Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:58:42 +0000 Subject: [PATCH 20/54] [GPCAPIM-254]: Correct module name. --- gateway-api/src/gateway_api/app.py | 2 +- gateway-api/src/gateway_api/get_structed_record/__init__.py | 6 ------ .../src/gateway_api/get_structured_record/__init__.py | 6 ++++++ .../handler.py | 2 +- .../request.py | 0 gateway-api/src/gateway_api/test_app.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) delete mode 100644 gateway-api/src/gateway_api/get_structed_record/__init__.py create mode 100644 gateway-api/src/gateway_api/get_structured_record/__init__.py rename gateway-api/src/gateway_api/{get_structed_record => get_structured_record}/handler.py (93%) rename gateway-api/src/gateway_api/{get_structed_record => get_structured_record}/request.py (100%) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 1065861e..94bb8b07 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -4,7 +4,7 @@ from flask import Flask, request from flask.wrappers import Response -from gateway_api.get_structed_record import ( +from gateway_api.get_structured_record import ( GetStructuredRecordHandler, GetStructuredRecordRequest, ) diff --git a/gateway-api/src/gateway_api/get_structed_record/__init__.py b/gateway-api/src/gateway_api/get_structed_record/__init__.py deleted file mode 100644 index 9861ad49..00000000 --- a/gateway-api/src/gateway_api/get_structed_record/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Get Structured Record module.""" - -from gateway_api.get_structed_record.handler import GetStructuredRecordHandler -from gateway_api.get_structed_record.request import GetStructuredRecordRequest - -__all__ = ["GetStructuredRecordHandler", "GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py new file mode 100644 index 00000000..c279cb73 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -0,0 +1,6 @@ +"""Get Structured Record module.""" + +from gateway_api.get_structured_record.handler import GetStructuredRecordHandler +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +__all__ = ["GetStructuredRecordHandler", "GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structed_record/handler.py b/gateway-api/src/gateway_api/get_structured_record/handler.py similarity index 93% rename from gateway-api/src/gateway_api/get_structed_record/handler.py rename to gateway-api/src/gateway_api/get_structured_record/handler.py index eb692fae..15479f28 100644 --- a/gateway-api/src/gateway_api/get_structed_record/handler.py +++ b/gateway-api/src/gateway_api/get_structured_record/handler.py @@ -3,7 +3,7 @@ if TYPE_CHECKING: from fhir import Bundle -from gateway_api.get_structed_record.request import GetStructuredRecordRequest +from gateway_api.get_structured_record.request import GetStructuredRecordRequest class GetStructuredRecordHandler: diff --git a/gateway-api/src/gateway_api/get_structed_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py similarity index 100% rename from gateway-api/src/gateway_api/get_structed_record/request.py rename to gateway-api/src/gateway_api/get_structured_record/request.py diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index f61fbc0a..97c9c3e2 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -48,7 +48,7 @@ def test_get_structured_record_handles_exception( simple_request_payload: "Parameters", ) -> None: monkeypatch.setattr( - "gateway_api.get_structed_record.GetStructuredRecordHandler.handle", + "gateway_api.get_structured_record.GetStructuredRecordHandler.handle", Exception(), ) From 170a3cb597333ca03b3195806dd6d30eaf439a60 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:06:14 +0000 Subject: [PATCH 21/54] [GPCAPIM-254]: Use tech radars preferred alpine and run thorugh non-root user. --- infrastructure/images/gateway-api/Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index ffcd00df..54824a4b 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -1,6 +1,9 @@ # Retrieve the python version from build arguments, deliberately set to "invalid" by default to highlight when no version is provided when building the container. ARG PYTHON_VERSION=invalid -FROM python:${PYTHON_VERSION}-slim AS gateway-api +FROM python:${PYTHON_VERSION}-alpine3.23 AS gateway-api + +RUN addgroup -S nonroot \ + && adduser -S gateway_api_user -G nonroot COPY resources/ /resources @@ -15,6 +18,7 @@ ENV COMMIT_VERSION=$COMMIT_VERSION ARG BUILD_DATE ENV BUILD_DATE=$BUILD_DATE +USER gateway_api_user ENTRYPOINT ["python"] CMD ["gateway_api/app.py"] From 6c0cbf0290f9fb158a2842c1273c0a75bf30dbb4 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:07:08 +0000 Subject: [PATCH 22/54] [GPCAPIM-254]: APIM handles CSRF through its auth design; We don't have to. --- gateway-api/src/gateway_api/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 94bb8b07..5e6234b6 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -10,6 +10,9 @@ ) app = Flask(__name__) +# This is a RESTful API, behind the proxy on APIM, which itself handles CSRF. +# We shall not handle CSRF +app.config["WTF_CSRF_ENABLED"] = False class HealthCheckResponse(TypedDict): From 0ed18347edae84e8857a98fb17b47095d5f0efc6 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:42:52 +0000 Subject: [PATCH 23/54] [GPCAPIM-254]: Reduce fragility of code by pushing environment variable getting in to their own testable methods. --- gateway-api/src/gateway_api/app.py | 24 ++++++++++++++------ gateway-api/src/gateway_api/test_app.py | 29 ++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 5e6234b6..d9a6a756 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -20,6 +20,20 @@ class HealthCheckResponse(TypedDict): version: str +def get_app_host() -> str: + host = os.getenv("FLASK_HOST") + if host is None: + raise RuntimeError("FLASK_HOST environment variable is not set.") + return host + + +def get_app_port() -> int: + port = os.getenv("FLASK_PORT") + if port is None: + raise RuntimeError("FLASK_PORT environment variable is not set.") + return int(port) + + @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Response: try: @@ -44,12 +58,8 @@ def health_check() -> HealthCheckResponse: if __name__ == "__main__": - host = os.getenv("FLASK_HOST") - if host is None: - raise RuntimeError("FLASK_HOST environment variable is not set.") - port = os.getenv("FLASK_PORT") - if port is None: - raise RuntimeError("FLASK_PORT environment variable is not set.") + host = get_app_host() + port = get_app_port() print(f"Starting Gateway API on {host}:{port}") print(f"Version: {os.getenv('COMMIT_VERSION')}") - app.run(host=host, port=int(port)) + app.run(host=host, port=port) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 97c9c3e2..5ccb583a 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -1,5 +1,6 @@ """Unit tests for the Flask app endpoints.""" +import os from collections.abc import Generator from typing import TYPE_CHECKING @@ -7,7 +8,7 @@ from flask import Flask from flask.testing import FlaskClient -from gateway_api.app import app +from gateway_api.app import app, get_app_host, get_app_port if TYPE_CHECKING: from fhir.parameters import Parameters @@ -20,6 +21,32 @@ def client() -> Generator[FlaskClient[Flask], None, None]: yield client +class TestAppInitialization: + def test_get_app_host_returns_set_host_name(self) -> None: + os.environ["FLASK_HOST"] = "host_is_set" + + actual = get_app_host() + assert actual == "host_is_set" + + def test_get_app_host_raises_runtime_error_if_host_name_not_set(self) -> None: + del os.environ["FLASK_HOST"] + + with pytest.raises(RuntimeError): + _ = get_app_host() + + def test_get_app_port_returns_set_port_number(self) -> None: + os.environ["FLASK_PORT"] = "8080" + + actual = get_app_port() + assert actual == 8080 + + def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: + del os.environ["FLASK_PORT"] + + with pytest.raises(RuntimeError): + _ = get_app_port() + + class TestGetStructuredRecord: def test_get_structured_record_returns_200_with_bundle( self, client: FlaskClient[Flask], simple_request_payload: "Parameters" From e0243b54356d7ab0d300771b3cf3b0bac8f6fb19 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:00:07 +0000 Subject: [PATCH 24/54] [GPCAPIM-254]: Reduce fragility of code by ensuring headers are correctly read. --- .../get_structured_record/test_request.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 gateway-api/src/gateway_api/get_structured_record/test_request.py diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py new file mode 100644 index 00000000..316960c8 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -0,0 +1,57 @@ +import pytest +from flask import Request + +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + + +class MockRequest: + def __init__(self, headers: dict[str, str]) -> None: + self.headers = headers + + def get_json(self) -> dict[str, str]: + return {} + + +@pytest.fixture +def mock_request_with_headers() -> MockRequest: + headers = { + "Ssp-TraceID": "test-trace-id", + "Ssp-from": "test-consumer-asid", + "Ssp-to": "test-provider-asid", + } + return MockRequest(headers) + + +class TestGetStructuredRecordRequest: + def test_trace_id_is_pulled_from_ssp_traceid_header( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.trace_id + expected = "test-trace-id" + assert actual == expected + + def test_consumer_asid_is_pulled_from_ssp_from_header( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.consumer_asid + expected = "test-consumer-asid" + assert actual == expected + + def test_provider_asid_is_pulled_from_ssp_to_header( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.provider_asid + expected = "test-provider-asid" + assert actual == expected From 2c80997e2cb4ac7edcf2a99b65458bb4325b57d7 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:09:52 +0000 Subject: [PATCH 25/54] [GPCAPIM-254]: Reduce fragility of code by ensuring NHS number is correctly read. --- .../get_structured_record/test_request.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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 316960c8..3ad3b12d 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -1,25 +1,27 @@ import pytest +from fhir.parameters import Parameters from flask import Request from gateway_api.get_structured_record.request import GetStructuredRecordRequest class MockRequest: - def __init__(self, headers: dict[str, str]) -> None: + def __init__(self, headers: dict[str, str], body: Parameters) -> None: self.headers = headers + self.body = body def get_json(self) -> dict[str, str]: return {} @pytest.fixture -def mock_request_with_headers() -> MockRequest: +def mock_request_with_headers(simple_request_payload: Parameters) -> MockRequest: headers = { "Ssp-TraceID": "test-trace-id", "Ssp-from": "test-consumer-asid", "Ssp-to": "test-provider-asid", } - return MockRequest(headers) + return MockRequest(headers, simple_request_payload) class TestGetStructuredRecordRequest: @@ -55,3 +57,14 @@ def test_provider_asid_is_pulled_from_ssp_to_header( actual = get_structured_record_request.provider_asid expected = "test-provider-asid" assert actual == expected + + def test_nhs_number_is_pulled_from_request_body( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.nhs_number + expected = "9999999999" + assert actual == expected From 8e85261a160bce28251326fc96532ce877133661 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:18:15 +0000 Subject: [PATCH 26/54] [GPCAPIM-254]: CSRF alert will be disabled in SonarQube; we do not need to recognise it in the app. --- gateway-api/src/gateway_api/app.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index d9a6a756..429b65f3 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -10,9 +10,6 @@ ) app = Flask(__name__) -# This is a RESTful API, behind the proxy on APIM, which itself handles CSRF. -# We shall not handle CSRF -app.config["WTF_CSRF_ENABLED"] = False class HealthCheckResponse(TypedDict): From 82653afbf561df8ff8c9e6ffb2580500fdf5cb63 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:31:25 +0000 Subject: [PATCH 27/54] [GPCAPIM-254]: Mock Request.geT_json() method by return the request_body as a dict. --- .../src/gateway_api/get_structured_record/test_request.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 3ad3b12d..ba198d0e 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 @@ -10,8 +10,8 @@ def __init__(self, headers: dict[str, str], body: Parameters) -> None: self.headers = headers self.body = body - def get_json(self) -> dict[str, str]: - return {} + def get_json(self) -> Parameters: + return self.body @pytest.fixture From 34d02bbcae8a0032058435c700b860093e5becec Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:32:31 +0000 Subject: [PATCH 28/54] [GPCAPIM-254]: Pytest, by default, runs all test_*.py files. Passing such a restrictive path reduces the ability to place test files within module directories. --- scripts/tests/run-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/run-test.sh b/scripts/tests/run-test.sh index 7d7fd4e1..8d1cefec 100755 --- a/scripts/tests/run-test.sh +++ b/scripts/tests/run-test.sh @@ -25,7 +25,7 @@ cd "$(git rev-parse --show-toplevel)" # Determine test path based on test type if [[ "$TEST_TYPE" = "unit" ]]; then - TEST_PATH="src/*/test_*.py" + TEST_PATH="src" else TEST_PATH="tests/${TEST_TYPE}/" fi From 09ff41d4c1ac4c4ba05688b770681f595704b739 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:36:30 +0000 Subject: [PATCH 29/54] [GPCAPIM-254]: Move print lines in to test covered functions to stop SonarQube complaining. --- gateway-api/src/gateway_api/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 429b65f3..0ff77f41 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -21,6 +21,7 @@ def get_app_host() -> str: host = os.getenv("FLASK_HOST") if host is None: raise RuntimeError("FLASK_HOST environment variable is not set.") + print(f"Starting Flask app on host: {host}") return host @@ -28,6 +29,7 @@ def get_app_port() -> int: port = os.getenv("FLASK_PORT") if port is None: raise RuntimeError("FLASK_PORT environment variable is not set.") + print(f"Starting Flask app on port: {port}") return int(port) @@ -57,6 +59,5 @@ def health_check() -> HealthCheckResponse: if __name__ == "__main__": host = get_app_host() port = get_app_port() - print(f"Starting Gateway API on {host}:{port}") print(f"Version: {os.getenv('COMMIT_VERSION')}") app.run(host=host, port=port) From 1b3c02f577ffd48037a3d73d23c23622f6ca47ee Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:43:36 +0000 Subject: [PATCH 30/54] [GPCAPIM-254]: Make the behaviour of the payload more explicit. --- gateway-api/src/gateway_api/conftest.py | 2 +- .../src/gateway_api/get_structured_record/test_request.py | 4 ++-- gateway-api/src/gateway_api/test_app.py | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 4042053d..05307c86 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -5,7 +5,7 @@ @pytest.fixture -def simple_request_payload() -> Parameters: +def valid_simple_request_payload() -> Parameters: return { "resourceType": "Parameters", "parameter": [ 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 ba198d0e..730cea38 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 @@ -15,13 +15,13 @@ def get_json(self) -> Parameters: @pytest.fixture -def mock_request_with_headers(simple_request_payload: Parameters) -> MockRequest: +def mock_request_with_headers(valid_simple_request_payload: Parameters) -> MockRequest: headers = { "Ssp-TraceID": "test-trace-id", "Ssp-from": "test-consumer-asid", "Ssp-to": "test-provider-asid", } - return MockRequest(headers, simple_request_payload) + return MockRequest(headers, valid_simple_request_payload) class TestGetStructuredRecordRequest: diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 5ccb583a..18a4b0f2 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -49,10 +49,10 @@ def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: class TestGetStructuredRecord: def test_get_structured_record_returns_200_with_bundle( - self, client: FlaskClient[Flask], simple_request_payload: "Parameters" + self, client: FlaskClient[Flask], valid_simple_request_payload: "Parameters" ) -> None: response = client.post( - "/patient/$gpc.getstructuredrecord", json=simple_request_payload + "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload ) assert response.status_code == 200 @@ -72,7 +72,7 @@ def test_get_structured_record_handles_exception( self, client: FlaskClient[Flask], monkeypatch: pytest.MonkeyPatch, - simple_request_payload: "Parameters", + valid_simple_request_payload: "Parameters", ) -> None: monkeypatch.setattr( "gateway_api.get_structured_record.GetStructuredRecordHandler.handle", @@ -80,7 +80,7 @@ def test_get_structured_record_handles_exception( ) response = client.post( - "/patient/$gpc.getstructuredrecord", json=simple_request_payload + "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload ) assert response.status_code == 500 From fda4de37ed0f9c1b5910521adcc663a504ac3d23 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:47:58 +0000 Subject: [PATCH 31/54] [GPCAPIM-254]: Constants should be UPPERCASE for clarity. --- .../src/gateway_api/get_structured_record/request.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 3d6ecdaa..fefa3c4d 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -7,9 +7,9 @@ class GetStructuredRecordRequest: - interaction_id: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" - resource: str = "patient" - fhir_operation: str = "$gpc.getstructuredrecord" + INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" + RESOURCE: str = "patient" + FHIR_OPERATION: str = "$gpc.getstructuredrecord" def __init__(self, request: Request) -> None: self._http_request = request From d2a2617d2709b71fca34b03d940d17c2d5c4547c Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:50:27 +0000 Subject: [PATCH 32/54] [GPCAPIM-254]: No longer SSP-from/SSP-to headers; moving towards ODS-from header. --- .../get_structured_record/request.py | 11 +++-------- .../get_structured_record/test_request.py | 18 +++--------------- 2 files changed, 6 insertions(+), 23 deletions(-) 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 fefa3c4d..141c3cda 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -29,14 +29,9 @@ def nhs_number(self) -> str: return nhs_number @property - def consumer_asid(self) -> str: - consumer_asid: str = self._headers["Ssp-from"] - return consumer_asid - - @property - def provider_asid(self) -> str: - provider_asid: str = self._headers["Ssp-to"] - return provider_asid + def ods_from(self) -> str: + ods_from: str = self._headers["ODS-from"] + return 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 730cea38..fc7b4b00 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 @@ -18,8 +18,7 @@ def get_json(self) -> Parameters: def mock_request_with_headers(valid_simple_request_payload: Parameters) -> MockRequest: headers = { "Ssp-TraceID": "test-trace-id", - "Ssp-from": "test-consumer-asid", - "Ssp-to": "test-provider-asid", + "ODS-from": "test-ods", } return MockRequest(headers, valid_simple_request_payload) @@ -43,19 +42,8 @@ def test_consumer_asid_is_pulled_from_ssp_from_header( request=mock_request_with_headers ) - actual = get_structured_record_request.consumer_asid - expected = "test-consumer-asid" - assert actual == expected - - def test_provider_asid_is_pulled_from_ssp_to_header( - self, mock_request_with_headers: Request - ) -> None: - get_structured_record_request = GetStructuredRecordRequest( - request=mock_request_with_headers - ) - - actual = get_structured_record_request.provider_asid - expected = "test-provider-asid" + actual = get_structured_record_request.ods_from + expected = "test-ods" assert actual == expected def test_nhs_number_is_pulled_from_request_body( From 4fbda5630108ca5bb90d58604abf3801bb030ab1 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:01:02 +0000 Subject: [PATCH 33/54] [GPCAPIM-254]: It's a numbers game. --- gateway-api/src/gateway_api/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 0ff77f41..8174fe17 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -57,7 +57,6 @@ def health_check() -> HealthCheckResponse: if __name__ == "__main__": - host = get_app_host() - port = get_app_port() + host, port = get_app_host(), get_app_port() print(f"Version: {os.getenv('COMMIT_VERSION')}") app.run(host=host, port=port) From 47d4592cb29cfebc59ff550945b4e476888d9bbb Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:16:48 +0000 Subject: [PATCH 34/54] [GPCAPIM-254]: Correct name of test. --- .../src/gateway_api/get_structured_record/test_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fc7b4b00..7ff082c5 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 @@ -35,7 +35,7 @@ def test_trace_id_is_pulled_from_ssp_traceid_header( expected = "test-trace-id" assert actual == expected - def test_consumer_asid_is_pulled_from_ssp_from_header( + def test_ods_is_pulled_from_ssp_from_header( self, mock_request_with_headers: Request ) -> None: get_structured_record_request = GetStructuredRecordRequest( From 3238cd556823f71bdf115a8c9f22726ea63aa9cc Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:19:37 +0000 Subject: [PATCH 35/54] [GPCAPIM-254]: Correct step name. --- gateway-api/tests/acceptance/features/happy_path.feature | 2 +- gateway-api/tests/acceptance/steps/happy_path.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway-api/tests/acceptance/features/happy_path.feature b/gateway-api/tests/acceptance/features/happy_path.feature index b4f5b757..a2afa5b5 100644 --- a/gateway-api/tests/acceptance/features/happy_path.feature +++ b/gateway-api/tests/acceptance/features/happy_path.feature @@ -4,7 +4,7 @@ Feature: Gateway API Hello World So that I can verify it responds correctly to valid and invalid requests Background: The API is running - Given the API is running new + Given the API is running Scenario: Get structured record request When I send a valid Parameters resource to the endpoint diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index 016ccb82..e9c813c8 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -12,7 +12,7 @@ from tests.conftest import Client -@given("the API is running new") +@given("the API is running") def check_api_is_running(client: Client) -> None: response = client.send_health_check() assert response.status_code == 200 From 76ee8f2bfb7b52f3696abf3638dc9ceb315ad046 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:52:23 +0000 Subject: [PATCH 36/54] Change return type to flask response --- .../src/gateway_api/common/__init__.py | 0 gateway-api/src/gateway_api/common/common.py | 49 ++++ gateway-api/src/gateway_api/common/py.typed | 0 .../src/gateway_api/common/test_common.py | 29 ++ gateway-api/src/gateway_api/controller.py | 207 +++++++++++++ .../src/gateway_api/test_controller.py | 274 ++++++++++++++++++ 6 files changed, 559 insertions(+) create mode 100644 gateway-api/src/gateway_api/common/__init__.py create mode 100644 gateway-api/src/gateway_api/common/common.py create mode 100644 gateway-api/src/gateway_api/common/py.typed create mode 100644 gateway-api/src/gateway_api/common/test_common.py create mode 100644 gateway-api/src/gateway_api/controller.py create mode 100644 gateway-api/src/gateway_api/test_controller.py diff --git a/gateway-api/src/gateway_api/common/__init__.py b/gateway-api/src/gateway_api/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py new file mode 100644 index 00000000..64162aff --- /dev/null +++ b/gateway-api/src/gateway_api/common/common.py @@ -0,0 +1,49 @@ +import re +from dataclasses import dataclass + + +@dataclass +class FlaskResponse: + status_code: int + data: str | None = None + headers: dict[str, str] | None = None + + +def validate_nhs_number(value: str | int) -> bool: + # TODO: Un-AI all these docstrings + """ + Validate an NHS number using the NHS modulus-11 check digit algorithm. + + Algorithm summary: + - NHS number is 10 digits: d1..d9 + check digit d10 + - Compute: total = d1*10 + d2*9 + ... + d9*2 + - remainder = total % 11 + - check = 11 - remainder + - If check == 11 => check digit must be 0 + - If check == 10 => check digit must be 10 (impossible as digit) => invalid + - If remainder == 1 => check would be 10 => invalid + - Else check digit must match d10 + """ + str_value = str(value) # Just in case they passed an integer + digits = re.sub(r"\D", "", str_value or "") + + if len(digits) != 10: + return False + if not digits.isdigit(): + return False + + first_nine = [int(ch) for ch in digits[:9]] + provided_check_digit = int(digits[9]) + + weights = list(range(10, 1, -1)) + total = sum(d * w for d, w in zip(first_nine, weights, strict=True)) + + remainder = total % 11 + check = 11 - remainder + + if check == 11: + check = 0 + if check == 10: + return False # invalid NHS number + + return check == provided_check_digit diff --git a/gateway-api/src/gateway_api/common/py.typed b/gateway-api/src/gateway_api/common/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py new file mode 100644 index 00000000..dd8cc537 --- /dev/null +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -0,0 +1,29 @@ +# tests/test_common.py + +import pytest +from src.gateway_api.common import common + + +@pytest.mark.parametrize( + "value", + [ + "9434765919", + "943 476 5919", # spaces allowed (non-digits stripped) + 9434765919, # int input supported + ], +) +def test_validate_nhs_number_valid(value): + assert common.validate_nhs_number(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", # empty + "123", # too short + "12345678901", # too long + "abc", # no digits after stripping + ], +) +def test_validate_nhs_number_invalid_length_or_non_numeric(value): + assert common.validate_nhs_number(value) is False diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py new file mode 100644 index 00000000..f6b73ace --- /dev/null +++ b/gateway-api/src/gateway_api/controller.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + import requests + +from src.gateway_api.common.common import FlaskResponse, validate_nhs_number +from src.gateway_api.pds_search import PdsClient, SearchResults + + +class DownstreamServiceError(RuntimeError): + """Raised when a downstream dependency (PDS/SDS/GP Connect) fails.""" + + +@dataclass +class SdsSearchResults: + """ + Stub SDS search results dataclass. + Replace this with the real one once it's implemented. + """ + + asid: str + + +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 | None = None, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + + def get_asid(self, ods_code: str) -> SdsSearchResults | None: + # Placeholder implementation + return SdsSearchResults(asid=f"asid_{ods_code}") + + +class GpConnectClient: + """ + Stub GP Connect client for obtaining patient records. + Replace this with the real one once it's implemented. + """ + + SANDBOX_URL = "https://example.invalid/gpconnect" + + def __init__( + self, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + self.base_url = base_url + self.timeout = timeout + + def get_patient_records( + self, + nhs_number: str, # NOSONAR S1172 (ignore in stub) + asid: str, # NOSONAR S1172 (ignore in stub) + auth_token: str, # NOSONAR S1172 (ignore in stub) + ) -> requests.Response | None: + # Placeholder implementation + return None + + +class Controller: + """ + Orchestrates calls to PDS -> SDS -> GP Connect. + + Entry point: + - call_gp_connect(nhs_number, auth_token) -> requests.Response + """ + + def __init__( + self, + # PDS configuration + pds_end_user_org_ods: str, + pds_base_url: str = PdsClient.SANDBOX_URL, + nhsd_session_urid: str | None = None, + timeout: int = 10, + sds_base_url: str = "https://example.invalid/sds", + gp_connect_base_url: str = "https://example.invalid/gpconnect", + ) -> None: + self.pds_end_user_org_ods = pds_end_user_org_ods + 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.sds_client = SdsClient(base_url=sds_base_url, timeout=timeout) + self.gp_connect_client = GpConnectClient( + base_url=gp_connect_base_url, timeout=timeout + ) + + def call_gp_connect( + self, + nhs_number: str | int, + auth_token: str, + ) -> FlaskResponse: + """ + 1) Call PDS to obtain the patient's GP ODS code. + 2) Call SDS to obtain ASID (using ODS code + auth token). + 3) Call GP Connect to obtain patient records + + """ + nhs_number_int = _coerce_nhs_number_to_int(nhs_number) + nhs_number_str = str(nhs_number_int) + + # --- PDS: find patient and extract GP ODS code --- + pds = PdsClient( + auth_token=auth_token, + end_user_org_ods=self.pds_end_user_org_ods, + base_url=self.pds_base_url, + nhsd_session_urid=self.nhsd_session_urid, + timeout=self.timeout, + ) + + pds_result: SearchResults | None = pds.search_patient_by_nhs_number( + nhs_number_int + ) + + if pds_result is None: + return FlaskResponse( + status_code=404, + data=f"No PDS patient found for NHS number {nhs_number_str}", + ) + + ods_code = (pds_result.gp_ods_code or "").strip() + if not ods_code: + return FlaskResponse( + status_code=404, + data=( + f"PDS patient {nhs_number_str} did not contain a current " + "GP ODS code" + ), + ) + + # --- SDS: Get ASID for given GP practice --- + sds = SdsClient( + auth_token=auth_token, + base_url=self.sds_base_url, + timeout=self.timeout, + ) + + sds_result: SdsSearchResults | None = sds.get_asid(ods_code) + + if sds_result is None: + return FlaskResponse( + status_code=404, + data=f"No ASID found for ODS code {ods_code}", + ) + + asid = (sds_result.asid or "").strip() + if not asid: + return FlaskResponse( + status_code=404, + data=( + f"SDS result for ODS code {ods_code} did not contain a current ASID" + ), + ) + + # --- Call GP Connect with given NHS number and ASID --- + response = self.gp_connect_client.get_patient_records( + nhs_number=nhs_number_str, + asid=asid, + auth_token=auth_token, + ) + return FlaskResponse( + status_code=response.status_code if response else 502, + data=response.text if response else "GP Connect service error", + headers=dict(response.headers) if response else None, + ) + + +def _coerce_nhs_number_to_int(value: str | int) -> int: + """ + Coerce NHS number to int with basic validation. + NHS numbers are 10 digits, but leading zeros are not typically used. + Adjust validation as needed for your domain rules. + """ + try: + stripped = cast("str", value).strip().replace(" ", "") + except AttributeError: + nhs_number_int = cast("int", value) + else: + if not stripped.isdigit(): + raise ValueError("NHS number must be numeric") + nhs_number_int = int(stripped) + + if len(str(nhs_number_int)) != 10: + # If you need to accept test numbers of different length, relax this. + raise ValueError("NHS number must be 10 digits") + + if not validate_nhs_number(nhs_number_int): + raise ValueError("NHS number is invalid") + + return nhs_number_int diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py new file mode 100644 index 00000000..e359ba97 --- /dev/null +++ b/gateway-api/src/gateway_api/test_controller.py @@ -0,0 +1,274 @@ +# tests/test_controller.py +from types import SimpleNamespace + +import pytest +from src.gateway_api.controller import controller + + +class FakeResponse: + def __init__(self, status_code: int, text: str, headers=None): + self.status_code = status_code + self.text = text + self.headers = headers or {} + + +class FakePdsClient: + last_init = None + _patient_details = None + + def __init__(self, **kwargs): + # Controller constructs PdsClient with these kwargs + FakePdsClient.last_init = kwargs + self._result = kwargs.pop("_result", None) + + def set_patient_details(self, value): + self._patient_details = value + + def search_patient_by_nhs_number(self, nhs_number_int: int): + # Patched per-test via class attribute + return self._patient_details + + +class FakeSdsClient: + _asid_details = None + + def __init__(self, auth_token=None, base_url=None, timeout=10): + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + + def set_asid_details(self, value): + self._asid_details = value + + def get_asid(self, ods_code: str): + return self._asid_details + + +class FakeGpConnectClient: + _patient_records = None + + def __init__(self, base_url=None, timeout=10): + self.base_url = base_url + self.timeout = timeout + self.last_call = None + + def set_patient_records(self, value): + self._patient_records = value + + def get_patient_records(self, nhs_number: str, asid: str, auth_token: str): + self.last_call = { + "nhs_number": nhs_number, + "asid": asid, + "auth_token": auth_token, + } + return self._patient_records + + +@pytest.fixture +def patched_deps(monkeypatch): + # Patch dependency classes in the controller module namespace. + monkeypatch.setattr(controller, "PdsClient", FakePdsClient) + monkeypatch.setattr(controller, "SdsClient", FakeSdsClient) + monkeypatch.setattr(controller, "GpConnectClient", FakeGpConnectClient) + + +def _make_controller(): + return controller.Controller( + pds_end_user_org_ods="ORG1", + pds_base_url="https://pds.example", + nhsd_session_urid="session-123", + timeout=3, + sds_base_url="https://sds.example", + gp_connect_base_url="https://gp.example", + ) + + +def test__coerce_nhs_number_to_int_accepts_spaces_and_validates(monkeypatch): + # Use real validator logic by default; 9434765919 is algorithmically valid. + assert controller._coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa SLF001 (testing) + + +@pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) +def test__coerce_nhs_number_to_int_rejects_bad_inputs(value): + with pytest.raises(ValueError): # noqa PT011 (Raises several different ValueErrors) + controller._coerce_nhs_number_to_int(value) # noqa SLF001 (testing) + + +def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false(monkeypatch): + monkeypatch.setattr(controller, "validate_nhs_number", lambda _: False) + with pytest.raises(ValueError, match="invalid"): + controller._coerce_nhs_number_to_int("9434765919") # noqa SLF001 (testing) + + +def test_call_gp_connect_returns_404_when_pds_patient_not_found( + patched_deps, monkeypatch +): + monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + + c = _make_controller() + + # Configure FakePdsClient instance return value to None. + def pds_init_side_effect(**kwargs): + inst = FakePdsClient(**kwargs) + return inst + + monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + + r = c.call_gp_connect("9434765919", "token-abc") + assert r.status_code == 404 + assert "No PDS patient found" in (r.data or "") + + +def test_call_gp_connect_returns_404_when_gp_ods_code_missing( + patched_deps, monkeypatch +): + monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + + c = _make_controller() + + def pds_init_side_effect(**kwargs): + inst = FakePdsClient(**kwargs) + inst.set_patient_details(SimpleNamespace(gp_ods_code=" ")) + return inst + + monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + + r = c.call_gp_connect(9434765919, "token-abc") + assert r.status_code == 404 + assert "did not contain a current GP ODS code" in (r.data or "") + + +def test_call_gp_connect_returns_404_when_sds_returns_none(patched_deps, monkeypatch): + monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + + c = _make_controller() + + def pds_init_side_effect(**kwargs): + inst = FakePdsClient(**kwargs) + inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) + return inst + + def sds_init_side_effect(**kwargs): + inst = FakeSdsClient(**kwargs) + return inst + + monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + + r = c.call_gp_connect("9434765919", "token-abc") + assert r.status_code == 404 + assert r.data == "No ASID found for ODS code A12345" + + +def test_call_gp_connect_returns_404_when_sds_asid_blank(patched_deps, monkeypatch): + monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + + c = _make_controller() + + def pds_init_side_effect(**kwargs): + inst = FakePdsClient(**kwargs) + inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) + return inst + + def sds_init_side_effect(**kwargs): + inst = FakeSdsClient(**kwargs) + inst.set_asid_details(controller.SdsSearchResults(asid=" ")) + return inst + + monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + + r = c.call_gp_connect("9434765919", "token-abc") + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +def test_call_gp_connect_returns_502_when_gp_connect_returns_none( + patched_deps, monkeypatch +): + monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + + c = _make_controller() + + def pds_init_side_effect(**kwargs): + inst = FakePdsClient(**kwargs) + inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) + return inst + + def sds_init_side_effect(**kwargs): + inst = FakeSdsClient(**kwargs) + inst.set_asid_details(controller.SdsSearchResults(asid="asid_A12345")) + return inst + + monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + + r = c.call_gp_connect("9434765919", "token-abc") + assert r.status_code == 502 + assert r.data == "GP Connect service error" + assert r.headers is None + + +def test_call_gp_connect_happy_path_maps_status_text_headers_and_strips_asid( + patched_deps, monkeypatch +): + monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + + c = _make_controller() + + def pds_init_side_effect(**kwargs): + inst = FakePdsClient(**kwargs) + inst.set_patient_details(SimpleNamespace(gp_ods_code=" A12345 ")) + return inst + + def sds_init_side_effect(**kwargs): + inst = FakeSdsClient(**kwargs) + inst.set_asid_details(controller.SdsSearchResults(asid=" asid_A12345 ")) + return inst + + c.gp_connect_client.set_patient_records( + FakeResponse( + status_code=200, + text="ok", + headers={"Content-Type": "application/fhir+json"}, + ) + ) + monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + + r = c.call_gp_connect("943 476 5919", "token-abc") + assert r.status_code == 200 + assert r.data == "ok" + assert r.headers == {"Content-Type": "application/fhir+json"} + + # Verify GP Connect called with coerced NHS number string and stripped ASID + assert c.gp_connect_client.last_call == { + "nhs_number": "9434765919", + "asid": "asid_A12345", + "auth_token": "token-abc", + } + + +def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( + patched_deps, monkeypatch +): + monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + + c = _make_controller() + + def pds_init_side_effect(**kwargs): + inst = FakePdsClient( + **kwargs + ) # stop early (404) so we only assert constructor args + return inst + + monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + + _ = c.call_gp_connect("9434765919", "token-abc") + + # These are the kwargs Controller passes into PdsClient() + assert FakePdsClient.last_init["auth_token"] == "token-abc" # noqa S105 (fake test credentials) + assert FakePdsClient.last_init["end_user_org_ods"] == "ORG1" + assert FakePdsClient.last_init["base_url"] == "https://pds.example" + assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" + assert FakePdsClient.last_init["timeout"] == 3 From 45dce95157c637c25d9a13aae4df1c9153382368 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:07:20 +0000 Subject: [PATCH 37/54] Refactor things to make ruff happy --- gateway-api/src/gateway_api/common/common.py | 2 + .../src/gateway_api/common/test_common.py | 7 +- gateway-api/src/gateway_api/controller.py | 255 +++++++++++++----- 3 files changed, 199 insertions(+), 65 deletions(-) diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py index 64162aff..ead64f21 100644 --- a/gateway-api/src/gateway_api/common/common.py +++ b/gateway-api/src/gateway_api/common/common.py @@ -1,6 +1,8 @@ import re from dataclasses import dataclass +type json_str = str + @dataclass class FlaskResponse: diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py index dd8cc537..dc1a44a7 100644 --- a/gateway-api/src/gateway_api/common/test_common.py +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -1,7 +1,8 @@ # tests/test_common.py import pytest -from src.gateway_api.common import common + +from gateway_api.common import common @pytest.mark.parametrize( @@ -12,7 +13,7 @@ 9434765919, # int input supported ], ) -def test_validate_nhs_number_valid(value): +def test_validate_nhs_number_valid(value: str) -> None: assert common.validate_nhs_number(value) is True @@ -25,5 +26,5 @@ def test_validate_nhs_number_valid(value): "abc", # no digits after stripping ], ) -def test_validate_nhs_number_invalid_length_or_non_numeric(value): +def test_validate_nhs_number_invalid_length_or_non_numeric(value: str) -> None: assert common.validate_nhs_number(value) is False diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index f6b73ace..1a657c28 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -1,19 +1,31 @@ from __future__ import annotations +import json from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: import requests -from src.gateway_api.common.common import FlaskResponse, validate_nhs_number -from src.gateway_api.pds_search import PdsClient, SearchResults +from gateway_api.common.common import FlaskResponse, json_str, validate_nhs_number +from gateway_api.pds_search import PdsClient, SearchResults class DownstreamServiceError(RuntimeError): """Raised when a downstream dependency (PDS/SDS/GP Connect) fails.""" +@dataclass +class RequestError(Exception): + """Raised (and handled) when there is a problem with the incoming request.""" + + status_code: int + message: str + + def __str__(self) -> str: + return self.message + + @dataclass class SdsSearchResults: """ @@ -22,6 +34,7 @@ class SdsSearchResults: """ asid: str + endpoint: str | None class SdsClient: @@ -42,9 +55,11 @@ def __init__( self.base_url = base_url self.timeout = timeout - def get_asid(self, ods_code: str) -> SdsSearchResults | None: + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: # Placeholder implementation - return SdsSearchResults(asid=f"asid_{ods_code}") + return SdsSearchResults( + asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" + ) class GpConnectClient: @@ -57,17 +72,19 @@ class GpConnectClient: def __init__( self, - base_url: str = SANDBOX_URL, - timeout: int = 10, + provider_endpoint: str, # Obtain from ODS + provider_asid: str, + consumer_asid: str, ) -> None: - self.base_url = base_url - self.timeout = timeout + self.provider_endpoint = provider_endpoint + self.provider_asid = provider_asid + self.consumer_asid = consumer_asid - def get_patient_records( + def access_structured_record( self, - nhs_number: str, # NOSONAR S1172 (ignore in stub) - asid: str, # NOSONAR S1172 (ignore in stub) - auth_token: str, # NOSONAR S1172 (ignore in stub) + trace_id: str, # NOSONAR S1172 (ignore in stub) + body: json_str, # NOSONAR S1172 (ignore in stub) + nhsnumber: str, # NOSONAR S1172 (ignore in stub) ) -> requests.Response | None: # Placeholder implementation return None @@ -78,103 +95,217 @@ class Controller: Orchestrates calls to PDS -> SDS -> GP Connect. Entry point: - - call_gp_connect(nhs_number, auth_token) -> requests.Response + - call_gp_connect(request_body_json, headers, auth_token) -> requests.Response """ + # TODO: Un-AI the docstrings and comments + + gp_connect_client: GpConnectClient | None + def __init__( self, - # PDS configuration - pds_end_user_org_ods: str, pds_base_url: str = PdsClient.SANDBOX_URL, nhsd_session_urid: str | None = None, timeout: int = 10, sds_base_url: str = "https://example.invalid/sds", - gp_connect_base_url: str = "https://example.invalid/gpconnect", ) -> None: - self.pds_end_user_org_ods = pds_end_user_org_ods 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.sds_client = SdsClient(base_url=sds_base_url, timeout=timeout) - self.gp_connect_client = GpConnectClient( - base_url=gp_connect_base_url, timeout=timeout - ) - - def call_gp_connect( - self, - nhs_number: str | int, - auth_token: str, - ) -> FlaskResponse: - """ - 1) Call PDS to obtain the patient's GP ODS code. - 2) Call SDS to obtain ASID (using ODS code + auth token). - 3) Call GP Connect to obtain patient records + self.gp_connect_client = None + + def _get_details_from_body(self, request_body: json_str) -> int: + # --- Extract NHS number from request body --- + try: + body: Any = json.loads(request_body) + except (TypeError, json.JSONDecodeError): + raise RequestError( + status_code=400, + message='Request body must be valid JSON with an "nhs-number" field', + ) from None + + if not hasattr(body, "getitem"): # Must be a dict-like object + raise RequestError( + status_code=400, + message='Request body must be a JSON object with an "nhs-number" field', + ) from None + + nhs_number_value = body.get("nhs-number") + if nhs_number_value is None: + raise RequestError( + status_code=400, + message='Missing required field "nhs-number" in JSON request body', + ) from None + + try: + nhs_number_int = _coerce_nhs_number_to_int(nhs_number_value) + except ValueError: + raise RequestError( + status_code=400, + message=( + f'Could not coerce NHS number "{nhs_number_value}" to an integer' + ), + ) from None - """ - nhs_number_int = _coerce_nhs_number_to_int(nhs_number) - nhs_number_str = str(nhs_number_int) + return nhs_number_int - # --- PDS: find patient and extract GP ODS code --- + def _get_pds_details( + self, auth_token: str, consumer_ods: str, nhs_number: int + ) -> str: + # --- PDS: find patient and extract GP ODS code (provider ODS) --- pds = PdsClient( auth_token=auth_token, - end_user_org_ods=self.pds_end_user_org_ods, + end_user_org_ods=consumer_ods, base_url=self.pds_base_url, nhsd_session_urid=self.nhsd_session_urid, timeout=self.timeout, ) - pds_result: SearchResults | None = pds.search_patient_by_nhs_number( - nhs_number_int - ) + pds_result: SearchResults | None = pds.search_patient_by_nhs_number(nhs_number) if pds_result is None: - return FlaskResponse( + raise RequestError( status_code=404, - data=f"No PDS patient found for NHS number {nhs_number_str}", + message=f"No PDS patient found for NHS number {nhs_number}", ) - ods_code = (pds_result.gp_ods_code or "").strip() - if not ods_code: - return FlaskResponse( + if pds_result.gp_ods_code: + provider_ods_code = pds_result.gp_ods_code + else: + raise RequestError( status_code=404, - data=( - f"PDS patient {nhs_number_str} did not contain a current " - "GP ODS code" + message=( + f"PDS patient {nhs_number} did not contain a current " + "provider ODS code" ), ) - # --- SDS: Get ASID for given GP practice --- + return provider_ods_code + + def _get_sds_details( + self, auth_token: str, consumer_ods: str, provider_ods: str + ) -> tuple[str, str, str]: + # --- SDS: Get provider details (ASID + endpoint) for provider ODS --- sds = SdsClient( auth_token=auth_token, base_url=self.sds_base_url, timeout=self.timeout, ) - sds_result: SdsSearchResults | None = sds.get_asid(ods_code) + provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) + if provider_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for provider ODS code {provider_ods}", + ) - if sds_result is None: - return FlaskResponse( + provider_asid = (provider_details.asid or "").strip() + if not provider_asid: + raise RequestError( status_code=404, - data=f"No ASID found for ODS code {ods_code}", + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current ASID" + ), ) - asid = (sds_result.asid or "").strip() - if not asid: - return FlaskResponse( + provider_endpoint = (provider_details.endpoint or "").strip() + if not provider_endpoint: + raise RequestError( status_code=404, - data=( - f"SDS result for ODS code {ods_code} did not contain a current ASID" + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current endpoint" ), ) - # --- Call GP Connect with given NHS number and ASID --- - response = self.gp_connect_client.get_patient_records( - nhs_number=nhs_number_str, - asid=asid, - auth_token=auth_token, + # --- SDS: Get consumer details (ASID) for consumer ODS --- + consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) + if consumer_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for consumer ODS code {consumer_ods}", + ) + + consumer_asid = (consumer_details.asid or "").strip() + if not consumer_asid: + raise RequestError( + status_code=404, + message=( + f"SDS result for consumer ODS code {consumer_ods} did not contain " + "a current ASID" + ), + ) + + return consumer_asid, provider_asid, provider_endpoint + + def call_gp_connect( + self, + request_body: json_str, + headers: dict[str, str], + auth_token: str, + ) -> FlaskResponse: + """ + Expects a JSON request body containing an "nhs-number" field. + Also expects HTTP headers (from Flask) and extracts "Ods-from" as consumer_ods. + + 1) Call PDS to obtain the patient's GP (provider) ODS code. + 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. + 3) Call SDS using consumer ODS to obtain consumer ASID. + 4) Call GP Connect to obtain patient records + """ + + try: + nhs_number = self._get_details_from_body(request_body) + except RequestError as err: + return FlaskResponse( + status_code=err.status_code, + data=str(err), + ) + + # --- Extract consumer ODS from headers --- + consumer_ods = headers.get("Ods-from", "").strip() + if not consumer_ods: + return FlaskResponse( + status_code=400, + data='Missing required header "Ods-from"', + ) + + trace_id = headers.get("X-Request-ID") + if trace_id is None: + return FlaskResponse( + status_code=400, data="Missing required header: X-Request-ID" + ) + + try: + provider_ods = self._get_pds_details(auth_token, consumer_ods, nhs_number) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + try: + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + auth_token, consumer_ods, provider_ods + ) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + # --- Call GP Connect with correct parameters --- + # (If these are dynamic per-request, reinitialise the client accordingly.) + self.gp_connect_client = GpConnectClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + response = self.gp_connect_client.access_structured_record( + trace_id=trace_id, + body=request_body, + nhsnumber=str(nhs_number), ) + return FlaskResponse( status_code=response.status_code if response else 502, data=response.text if response else "GP Connect service error", From 9c928d7f2c875c810e59da7e6081ffefb5cdfffc Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:28:17 +0000 Subject: [PATCH 38/54] Pass the request body & multiple SDS calls --- .../src/gateway_api/common/test_common.py | 39 ++- gateway-api/src/gateway_api/controller.py | 8 +- gateway-api/src/gateway_api/pds_search.py | 18 +- .../src/gateway_api/test_controller.py | 225 ++++++++++-------- .../src/gateway_api/test_pds_search.py | 2 +- 5 files changed, 161 insertions(+), 131 deletions(-) diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py index dc1a44a7..b399f8dd 100644 --- a/gateway-api/src/gateway_api/common/test_common.py +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -1,30 +1,23 @@ # tests/test_common.py -import pytest - from gateway_api.common import common -@pytest.mark.parametrize( - "value", - [ - "9434765919", - "943 476 5919", # spaces allowed (non-digits stripped) - 9434765919, # int input supported - ], -) -def test_validate_nhs_number_valid(value: str) -> None: - assert common.validate_nhs_number(value) is True +def test_flask_response_defaults() -> None: + r = common.FlaskResponse(status_code=200) + assert r.status_code == 200 + assert r.data is None + assert r.headers is None + + +def test_validate_nhs_number_accepts_valid_number_with_separators() -> None: + assert common.validate_nhs_number("943 476 5919") is True + assert common.validate_nhs_number("943-476-5919") is True + assert common.validate_nhs_number(9434765919) is True -@pytest.mark.parametrize( - "value", - [ - "", # empty - "123", # too short - "12345678901", # too long - "abc", # no digits after stripping - ], -) -def test_validate_nhs_number_invalid_length_or_non_numeric(value: str) -> None: - assert common.validate_nhs_number(value) is False +def test_validate_nhs_number_rejects_wrong_length_and_bad_check_digit() -> None: + assert common.validate_nhs_number("") is False + assert common.validate_nhs_number("943476591") is False # 9 digits + assert common.validate_nhs_number("94347659190") is False # 11 digits + assert common.validate_nhs_number("9434765918") is False # wrong check digit diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 1a657c28..39ac3567 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -8,7 +8,7 @@ import requests from gateway_api.common.common import FlaskResponse, json_str, validate_nhs_number -from gateway_api.pds_search import PdsClient, SearchResults +from gateway_api.pds_search import PdsClient, PdsSearchResults class DownstreamServiceError(RuntimeError): @@ -105,9 +105,9 @@ 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, timeout: int = 10, - sds_base_url: str = "https://example.invalid/sds", ) -> None: self.pds_base_url = pds_base_url self.sds_base_url = sds_base_url @@ -164,7 +164,9 @@ def _get_pds_details( timeout=self.timeout, ) - pds_result: SearchResults | None = pds.search_patient_by_nhs_number(nhs_number) + pds_result: PdsSearchResults | None = pds.search_patient_by_nhs_number( + nhs_number + ) if pds_result is None: raise RequestError( diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index cddcc056..4a17cc62 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -44,7 +44,7 @@ class ExternalServiceError(Exception): @dataclass -class SearchResults: +class PdsSearchResults: """ A single extracted patient record. @@ -74,7 +74,7 @@ class PdsClient: * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` - This method returns a :class:`SearchResults` instance when a patient can be + This method returns a :class:`PdsSearchResults` instance when a patient can be extracted, otherwise ``None``. **Usage example**:: @@ -164,12 +164,12 @@ def search_patient_by_nhs_number( request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, - ) -> SearchResults | None: + ) -> PdsSearchResults | None: """ Retrieve a patient by NHS number. Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`SearchResults`. + resource on success, then extracts a single :class:`PdsSearchResults`. :param nhs_number: NHS number to search for. :param request_id: Optional request ID to reuse for retries; if not supplied a @@ -177,7 +177,7 @@ def search_patient_by_nhs_number( :param correlation_id: Optional correlation ID for tracing. :param timeout: Optional per-call timeout in seconds. If not provided, :attr:`timeout` is used. - :return: A :class:`SearchResults` instance if a patient can be extracted, + :return: A :class:`PdsSearchResults` instance if a patient can be extracted, otherwise ``None``. :raises ExternalServiceError: If the HTTP request returns an error status and ``raise_for_status()`` raises :class:`requests.HTTPError`. @@ -241,9 +241,9 @@ def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: def _extract_single_search_result( self, body: ResultStructureDict - ) -> SearchResults | None: + ) -> PdsSearchResults | None: """ - Extract a single :class:`SearchResults` from a Patient response. + Extract a single :class:`PdsSearchResults` from a Patient response. This helper accepts either: * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or @@ -253,7 +253,7 @@ def _extract_single_search_result( single match; if multiple entries are present, the first entry is used. :param body: Parsed JSON body containing either a Patient resource or a Bundle whose first entry contains a Patient resource under ``resource``. - :return: A populated :class:`SearchResults` if extraction succeeds, otherwise + :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise ``None``. """ # Accept either: @@ -294,7 +294,7 @@ def _extract_single_search_result( gp_list = cast("ResultList", patient.get("generalPractitioner", [])) gp_ods_code = self._get_gp_ods_code(gp_list) - return SearchResults( + return PdsSearchResults( given_names=given_names_str, family_name=family_name, nhs_number=nhs_number, diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index e359ba97..7ecd0bd1 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,12 +1,23 @@ # tests/test_controller.py from types import SimpleNamespace +from typing import Any import pytest -from src.gateway_api.controller import controller +from requests import Response + +from gateway_api.common.common import json_str +from gateway_api.controller import ( + Controller, + SdsSearchResults, + _coerce_nhs_number_to_int, +) +from gateway_api.pds_search import PdsSearchResults class FakeResponse: - def __init__(self, status_code: int, text: str, headers=None): + def __init__( + self, status_code: int, text: str, headers: dict[str, Any] | None = None + ) -> None: self.status_code = status_code self.text = text self.headers = headers or {} @@ -16,225 +27,249 @@ class FakePdsClient: last_init = None _patient_details = None - def __init__(self, **kwargs): + def __init__(self, **kwargs: dict[str, Any]) -> None: # Controller constructs PdsClient with these kwargs FakePdsClient.last_init = kwargs self._result = kwargs.pop("_result", None) - def set_patient_details(self, value): + def set_patient_details(self, value: PdsSearchResults) -> None: self._patient_details = value - def search_patient_by_nhs_number(self, nhs_number_int: int): + def search_patient_by_nhs_number( + self, nhs_number_int: int + ) -> PdsSearchResults | None: # Patched per-test via class attribute return self._patient_details class FakeSdsClient: - _asid_details = None - - def __init__(self, auth_token=None, base_url=None, timeout=10): + _org_details = None + + def __init__( + self, + auth_token: str = "test_token", # noqa S107 (fake test credentials) + base_url: str = "test_url", + timeout: int = 10, + ) -> None: self.auth_token = auth_token self.base_url = base_url self.timeout = timeout - def set_asid_details(self, value): - self._asid_details = value + def set_org_details(self, org_details: SdsSearchResults) -> None: + self._org_details = org_details - def get_asid(self, ods_code: str): - return self._asid_details + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + return self._org_details -class FakeGpConnectClient: - _patient_records = None +class FakeGpProviderClient: + _status_code: int = 200 + _content: bytes = b"OK" - def __init__(self, base_url=None, timeout=10): - self.base_url = base_url - self.timeout = timeout - self.last_call = None + def __init__( + self, provider_endpoint: str, provider_asid: str, consumer_asid: str + ) -> None: + # Not actually using any of the constructor args for the stub + pass + + def set_response_details(self, status_code: int, body_content: bytes) -> None: + self._status_code = status_code + self._content = body_content - def set_patient_records(self, value): - self._patient_records = value + def access_structured_record(self, trace_id: str, body: json_str) -> Response: + resp = Response() + resp.status_code = self._status_code + resp._content = self._content # noqa SLF001 (Hacking internals for testing purposes) + resp.headers["Content-Type"] = "text/plain; charset=utf-8" + resp.url = "https://example.com/" + resp.encoding = "utf-8" - def get_patient_records(self, nhs_number: str, asid: str, auth_token: str): - self.last_call = { - "nhs_number": nhs_number, - "asid": asid, - "auth_token": auth_token, - } - return self._patient_records + return resp @pytest.fixture -def patched_deps(monkeypatch): +def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: # Patch dependency classes in the controller module namespace. - monkeypatch.setattr(controller, "PdsClient", FakePdsClient) - monkeypatch.setattr(controller, "SdsClient", FakeSdsClient) - monkeypatch.setattr(controller, "GpConnectClient", FakeGpConnectClient) + monkeypatch.setattr(Controller, "PdsClient", FakePdsClient) + monkeypatch.setattr(Controller, "SdsClient", FakeSdsClient) + monkeypatch.setattr(Controller, "GpConnectClient", FakeGpProviderClient) -def _make_controller(): - return controller.Controller( - pds_end_user_org_ods="ORG1", +def _make_controller() -> Controller: + return Controller( pds_base_url="https://pds.example", + sds_base_url="https://sds.example", nhsd_session_urid="session-123", timeout=3, - sds_base_url="https://sds.example", - gp_connect_base_url="https://gp.example", ) -def test__coerce_nhs_number_to_int_accepts_spaces_and_validates(monkeypatch): +def test__coerce_nhs_number_to_int_accepts_spaces_and_validates( + monkeypatch: pytest.MonkeyPatch, +) -> None: # Use real validator logic by default; 9434765919 is algorithmically valid. - assert controller._coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa SLF001 (testing) + assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa SLF001 (testing) @pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) -def test__coerce_nhs_number_to_int_rejects_bad_inputs(value): +def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: with pytest.raises(ValueError): # noqa PT011 (Raises several different ValueErrors) - controller._coerce_nhs_number_to_int(value) # noqa SLF001 (testing) + _coerce_nhs_number_to_int(value) # noqa SLF001 (testing) -def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false(monkeypatch): - monkeypatch.setattr(controller, "validate_nhs_number", lambda _: False) +def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(Controller, "validate_nhs_number", lambda _: False) with pytest.raises(ValueError, match="invalid"): - controller._coerce_nhs_number_to_int("9434765919") # noqa SLF001 (testing) + _coerce_nhs_number_to_int("9434765919") # noqa SLF001 (testing) def test_call_gp_connect_returns_404_when_pds_patient_not_found( - patched_deps, monkeypatch -): - monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + patched_deps: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) c = _make_controller() # Configure FakePdsClient instance return value to None. - def pds_init_side_effect(**kwargs): + def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: inst = FakePdsClient(**kwargs) return inst - monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + + r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers - r = c.call_gp_connect("9434765919", "token-abc") + # TODO: Avoid one-letter variable names assert r.status_code == 404 assert "No PDS patient found" in (r.data or "") def test_call_gp_connect_returns_404_when_gp_ods_code_missing( - patched_deps, monkeypatch -): - monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + patched_deps: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) c = _make_controller() - def pds_init_side_effect(**kwargs): + def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: inst = FakePdsClient(**kwargs) inst.set_patient_details(SimpleNamespace(gp_ods_code=" ")) return inst - monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) - r = c.call_gp_connect(9434765919, "token-abc") + r = c.call_gp_connect(9434765919, "token-abc") # TODO: Create body and headers assert r.status_code == 404 assert "did not contain a current GP ODS code" in (r.data or "") -def test_call_gp_connect_returns_404_when_sds_returns_none(patched_deps, monkeypatch): - monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) +def test_call_gp_connect_returns_404_when_sds_returns_none( + patched_deps: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) c = _make_controller() - def pds_init_side_effect(**kwargs): + def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: inst = FakePdsClient(**kwargs) inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) return inst - def sds_init_side_effect(**kwargs): + def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: inst = FakeSdsClient(**kwargs) return inst - monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) - r = c.call_gp_connect("9434765919", "token-abc") + r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers assert r.status_code == 404 assert r.data == "No ASID found for ODS code A12345" -def test_call_gp_connect_returns_404_when_sds_asid_blank(patched_deps, monkeypatch): - monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) +def test_call_gp_connect_returns_404_when_sds_asid_blank( + patched_deps: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) c = _make_controller() - def pds_init_side_effect(**kwargs): + def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: inst = FakePdsClient(**kwargs) - inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) + inst.set_patient_details( + SimpleNamespace(gp_ods_code="A12345") + ) # TODO: Fix this for updated set_patient_details return inst - def sds_init_side_effect(**kwargs): - inst = FakeSdsClient(**kwargs) - inst.set_asid_details(controller.SdsSearchResults(asid=" ")) + def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: + inst = FakeSdsClient( + **kwargs + ) # TODO: SDS args aren't this any more. Also check PDS. + inst.set_asid_details(Controller.SdsSearchResults(asid=" ")) return inst - monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) - r = c.call_gp_connect("9434765919", "token-abc") + r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers assert r.status_code == 404 assert "did not contain a current ASID" in (r.data or "") def test_call_gp_connect_returns_502_when_gp_connect_returns_none( - patched_deps, monkeypatch -): - monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + patched_deps: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) c = _make_controller() - def pds_init_side_effect(**kwargs): + def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: inst = FakePdsClient(**kwargs) inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) return inst - def sds_init_side_effect(**kwargs): + def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: inst = FakeSdsClient(**kwargs) - inst.set_asid_details(controller.SdsSearchResults(asid="asid_A12345")) + inst.set_asid_details(Controller.SdsSearchResults(asid="asid_A12345")) return inst - monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) - r = c.call_gp_connect("9434765919", "token-abc") + r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers assert r.status_code == 502 assert r.data == "GP Connect service error" assert r.headers is None def test_call_gp_connect_happy_path_maps_status_text_headers_and_strips_asid( - patched_deps, monkeypatch -): - monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + patched_deps: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) c = _make_controller() - def pds_init_side_effect(**kwargs): + def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: inst = FakePdsClient(**kwargs) inst.set_patient_details(SimpleNamespace(gp_ods_code=" A12345 ")) return inst - def sds_init_side_effect(**kwargs): + def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: inst = FakeSdsClient(**kwargs) - inst.set_asid_details(controller.SdsSearchResults(asid=" asid_A12345 ")) + inst.set_asid_details(Controller.SdsSearchResults(asid=" asid_A12345 ")) return inst - c.gp_connect_client.set_patient_records( + c.gp_connect_client.access_structured_record( FakeResponse( status_code=200, text="ok", headers={"Content-Type": "application/fhir+json"}, ) ) - monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) r = c.call_gp_connect("943 476 5919", "token-abc") assert r.status_code == 200 @@ -250,19 +285,19 @@ def sds_init_side_effect(**kwargs): def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( - patched_deps, monkeypatch -): - monkeypatch.setattr(controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) + patched_deps: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) c = _make_controller() - def pds_init_side_effect(**kwargs): + def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: inst = FakePdsClient( **kwargs ) # stop early (404) so we only assert constructor args return inst - monkeypatch.setattr(controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) _ = c.call_gp_connect("9434765919", "token-abc") diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py index 78ed9e73..a42b73c6 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/test_pds_search.py @@ -198,7 +198,7 @@ def test_search_patient_by_nhs_number_get_patient_success( Verify ``GET /Patient/{nhs_number}`` returns 200 and demographics are extracted. This test explicitly inserts the patient into the stub and asserts that the client - returns a populated :class:`gateway_api.pds_search.SearchResults`. + returns a populated :class:`gateway_api.pds_search.PdsSearchResults`. :param stub: Stub backend fixture. :param mock_requests_get: Patched ``requests.get`` fixture From 323d62ad95e87cda93998499864f480c025fc78d Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:51:05 +0000 Subject: [PATCH 39/54] Mypy happy, tests passing --- gateway-api/src/gateway_api/controller.py | 3 + .../src/gateway_api/test_controller.py | 394 +++++++++++------- 2 files changed, 251 insertions(+), 146 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 39ac3567..1636129a 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -1,6 +1,9 @@ from __future__ import annotations import json + +__all__ = ["json"] # Make mypy happy in tests + from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 7ecd0bd1..1da5b6e7 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,98 +1,164 @@ # tests/test_controller.py +from __future__ import annotations + +import json as std_json from types import SimpleNamespace -from typing import Any +from typing import TYPE_CHECKING, Any import pytest from requests import Response -from gateway_api.common.common import json_str +import gateway_api.controller as controller_module from gateway_api.controller import ( Controller, SdsSearchResults, _coerce_nhs_number_to_int, ) -from gateway_api.pds_search import PdsSearchResults +if TYPE_CHECKING: + from gateway_api.common.common import json_str -class FakeResponse: - def __init__( - self, status_code: int, text: str, headers: dict[str, Any] | None = None - ) -> None: - self.status_code = status_code - self.text = text - self.headers = headers or {} + +# ----------------------------- +# Helpers for request test data +# ----------------------------- +def make_request_body(nhs_number: str = "9434765919") -> json_str: + # Controller expects a JSON string containing an "nhs-number" field. + return std_json.dumps({"nhs-number": nhs_number}) + + +def make_headers( + ods_from: str = "ORG1", + trace_id: str = "trace-123", +) -> dict[str, str]: + # Controller expects these headers: + # - Ods-from (consumer ODS) + # - X-Request-ID (trace id) + return {"Ods-from": ods_from, "X-Request-ID": trace_id} + + +# ------------------------------------------------------------------- +# Shim for controller._get_details_from_body() "getitem" attribute check +# ------------------------------------------------------------------- +class _DictWithGetitem(dict[str, Any]): + # The controller currently checks hasattr(body, "getitem") + # so we provide a getitem attribute that behaves like __getitem__. + def getitem(self, key: str, default: Any = None) -> Any: # pragma: no cover + return self.get(key, default) + + +@pytest.fixture +def patched_json_loads(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Ensure controller_module.json.loads returns an object that passes: + hasattr(body, "getitem") + while still behaving like a normal dict for .get(). + """ + original_loads = controller_module.json.loads + + def loads_with_getitem(payload: str) -> Any: + parsed = original_loads(payload) + if isinstance(parsed, dict): + return _DictWithGetitem(parsed) + return parsed + + monkeypatch.setattr(controller_module.json, "loads", loads_with_getitem) + + +# ----------------------------- +# Fake downstream dependencies +# ----------------------------- +def _make_pds_result(gp_ods_code: str | None) -> Any: + # We only need .gp_ods_code for controller logic. + return SimpleNamespace(gp_ods_code=gp_ods_code) class FakePdsClient: - last_init = None - _patient_details = None + last_init: dict[str, Any] | None = None - def __init__(self, **kwargs: dict[str, Any]) -> None: - # Controller constructs PdsClient with these kwargs - FakePdsClient.last_init = kwargs - self._result = kwargs.pop("_result", None) + def __init__(self, **kwargs: Any) -> None: + # Controller constructs PdsClient with kwargs; capture for assertions. + FakePdsClient.last_init = dict(kwargs) + self._patient_details: Any | None = None - def set_patient_details(self, value: PdsSearchResults) -> None: + def set_patient_details(self, value: Any) -> None: + # Keep call sites explicit and "correct": pass a PDS-result-like object. self._patient_details = value - def search_patient_by_nhs_number( - self, nhs_number_int: int - ) -> PdsSearchResults | None: - # Patched per-test via class attribute + def search_patient_by_nhs_number(self, nhs_number: int) -> Any | None: return self._patient_details class FakeSdsClient: - _org_details = None - def __init__( self, - auth_token: str = "test_token", # noqa S107 (fake test credentials) + auth_token: str | None = None, base_url: str = "test_url", timeout: int = 10, ) -> None: 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, org_details: SdsSearchResults) -> None: - self._org_details = org_details + 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 + return self._org_details_by_ods.get(ods_code) + +class FakeGpConnectClient: + last_init: dict[str, str] | None = None + last_call: dict[str, str] | None = None -class FakeGpProviderClient: - _status_code: int = 200 - _content: bytes = b"OK" + # 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: - # Not actually using any of the constructor args for the stub - pass + FakeGpConnectClient.last_init = { + "provider_endpoint": provider_endpoint, + "provider_asid": provider_asid, + "consumer_asid": consumer_asid, + } - def set_response_details(self, status_code: int, body_content: bytes) -> None: - self._status_code = status_code - self._content = body_content + def access_structured_record( + self, + trace_id: str, + body: json_str, + nhsnumber: str, + ) -> Response | None: + FakeGpConnectClient.last_call = { + "trace_id": trace_id, + "body": body, + "nhsnumber": nhsnumber, + } + + if FakeGpConnectClient.return_none: + return None - def access_structured_record(self, trace_id: str, body: json_str) -> Response: resp = Response() - resp.status_code = self._status_code - resp._content = self._content # noqa SLF001 (Hacking internals for testing purposes) - resp.headers["Content-Type"] = "text/plain; charset=utf-8" - resp.url = "https://example.com/" + resp.status_code = FakeGpConnectClient.response_status_code + resp._content = FakeGpConnectClient.response_body # noqa: SLF001 resp.encoding = "utf-8" - + resp.headers.update(FakeGpConnectClient.response_headers) + resp.url = "https://example.invalid/fake" return resp @pytest.fixture def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: - # Patch dependency classes in the controller module namespace. - monkeypatch.setattr(Controller, "PdsClient", FakePdsClient) - monkeypatch.setattr(Controller, "SdsClient", FakeSdsClient) - monkeypatch.setattr(Controller, "GpConnectClient", FakeGpProviderClient) + # Patch dependency classes in the *module* namespace that Controller uses. + monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) + monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) + monkeypatch.setattr(controller_module, "GpConnectClient", FakeGpConnectClient) def _make_controller() -> Controller: @@ -104,205 +170,241 @@ def _make_controller() -> Controller: ) -def test__coerce_nhs_number_to_int_accepts_spaces_and_validates( - monkeypatch: pytest.MonkeyPatch, -) -> None: +# ----------------------------- +# Unit tests +# ----------------------------- +def test__coerce_nhs_number_to_int_accepts_spaces_and_validates() -> None: # Use real validator logic by default; 9434765919 is algorithmically valid. - assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa SLF001 (testing) + assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa: SLF001 @pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: - with pytest.raises(ValueError): # noqa PT011 (Raises several different ValueErrors) - _coerce_nhs_number_to_int(value) # noqa SLF001 (testing) + with pytest.raises(ValueError): # noqa: PT011 + _coerce_nhs_number_to_int(value) # noqa: SLF001 def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(Controller, "validate_nhs_number", lambda _: False) + # _coerce_nhs_number_to_int calls validate_nhs_number imported into + # gateway_api.controller + monkeypatch.setattr(controller_module, "validate_nhs_number", lambda _: False) with pytest.raises(ValueError, match="invalid"): - _coerce_nhs_number_to_int("9434765919") # noqa SLF001 (testing) + _coerce_nhs_number_to_int("9434765919") # noqa: SLF001 def test_call_gp_connect_returns_404_when_pds_patient_not_found( - patched_deps: Any, monkeypatch: pytest.MonkeyPatch + patched_deps: Any, + patched_json_loads: Any, ) -> None: - monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) - c = _make_controller() - # Configure FakePdsClient instance return value to None. - def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - return inst - - monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + # PDS returns None by default + body = make_request_body("9434765919") + headers = make_headers() - r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers + r = c.call_gp_connect(body, headers, "token-abc") - # TODO: Avoid one-letter variable names assert r.status_code == 404 - assert "No PDS patient found" in (r.data or "") + assert "No PDS patient found for NHS number" in (r.data or "") def test_call_gp_connect_returns_404_when_gp_ods_code_missing( - patched_deps: Any, monkeypatch: pytest.MonkeyPatch + patched_deps: Any, + patched_json_loads: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) - c = _make_controller() - def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: + def pds_factory(**kwargs: Any) -> FakePdsClient: inst = FakePdsClient(**kwargs) - inst.set_patient_details(SimpleNamespace(gp_ods_code=" ")) + inst.set_patient_details(_make_pds_result(" ")) # blank gp_ods_code return inst - monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_connect(body, headers, "token-abc") - r = c.call_gp_connect(9434765919, "token-abc") # TODO: Create body and headers assert r.status_code == 404 - assert "did not contain a current GP ODS code" in (r.data or "") + assert "No SDS org found for provider ODS code" in (r.data or "") -def test_call_gp_connect_returns_404_when_sds_returns_none( - patched_deps: Any, monkeypatch: pytest.MonkeyPatch +def test_call_gp_connect_returns_404_when_sds_returns_none_for_provider( + patched_deps: Any, + patched_json_loads: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) - c = _make_controller() - def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: + def pds_factory(**kwargs: Any) -> FakePdsClient: inst = FakePdsClient(**kwargs) - inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) + inst.set_patient_details(_make_pds_result("A12345")) return inst - def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: + def sds_factory(**kwargs: Any) -> FakeSdsClient: inst = FakeSdsClient(**kwargs) + # Do NOT set provider org details => None return inst - monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_connect(body, headers, "token-abc") - r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers assert r.status_code == 404 - assert r.data == "No ASID found for ODS code A12345" + assert r.data == "No SDS org found for provider ODS code A12345" -def test_call_gp_connect_returns_404_when_sds_asid_blank( - patched_deps: Any, monkeypatch: pytest.MonkeyPatch +def test_call_gp_connect_returns_404_when_sds_provider_asid_blank( + patched_deps: Any, + patched_json_loads: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) - c = _make_controller() - def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: + def pds_factory(**kwargs: Any) -> FakePdsClient: inst = FakePdsClient(**kwargs) - inst.set_patient_details( - SimpleNamespace(gp_ods_code="A12345") - ) # TODO: Fix this for updated set_patient_details + inst.set_patient_details(_make_pds_result("A12345")) return inst - def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: - inst = FakeSdsClient( - **kwargs - ) # TODO: SDS args aren't this any more. Also check PDS. - inst.set_asid_details(Controller.SdsSearchResults(asid=" ")) + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults(asid=" ", endpoint="https://provider.example/ep"), + ) return inst - monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_connect(body, headers, "token-abc") - r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers assert r.status_code == 404 assert "did not contain a current ASID" in (r.data or "") def test_call_gp_connect_returns_502_when_gp_connect_returns_none( - patched_deps: Any, monkeypatch: pytest.MonkeyPatch + patched_deps: Any, + patched_json_loads: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) - c = _make_controller() - def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: + def pds_factory(**kwargs: Any) -> FakePdsClient: inst = FakePdsClient(**kwargs) - inst.set_patient_details(SimpleNamespace(gp_ods_code="A12345")) + inst.set_patient_details(_make_pds_result("A12345")) return inst - def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: + def sds_factory(**kwargs: Any) -> FakeSdsClient: inst = FakeSdsClient(**kwargs) - inst.set_asid_details(Controller.SdsSearchResults(asid="asid_A12345")) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) return inst - monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + FakeGpConnectClient.return_none = True + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_connect(body, headers, "token-abc") - r = c.call_gp_connect("9434765919", "token-abc") # TODO: Create body and headers assert r.status_code == 502 assert r.data == "GP Connect service error" assert r.headers is None + # reset for other tests + FakeGpConnectClient.return_none = False -def test_call_gp_connect_happy_path_maps_status_text_headers_and_strips_asid( - patched_deps: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) +def test_call_gp_connect_happy_path_maps_status_text_headers_and_trims_sds_fields( + patched_deps: Any, + patched_json_loads: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: c = _make_controller() - def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: + def pds_factory(**kwargs: Any) -> FakePdsClient: inst = FakePdsClient(**kwargs) - inst.set_patient_details(SimpleNamespace(gp_ods_code=" A12345 ")) + inst.set_patient_details(_make_pds_result("A12345")) return inst - def sds_init_side_effect(**kwargs: dict[str, Any]) -> FakeSdsClient: + def sds_factory(**kwargs: Any) -> FakeSdsClient: inst = FakeSdsClient(**kwargs) - inst.set_asid_details(Controller.SdsSearchResults(asid=" asid_A12345 ")) + # include whitespace to assert trimming in controller._get_sds_details() + inst.set_org_details( + "A12345", + SdsSearchResults( + asid=" asid_A12345 ", endpoint=" https://provider.example/ep " + ), + ) + inst.set_org_details( + "ORG1", SdsSearchResults(asid=" asid_ORG1 ", endpoint=None) + ) return inst - c.gp_connect_client.access_structured_record( - FakeResponse( - status_code=200, - text="ok", - headers={"Content-Type": "application/fhir+json"}, - ) - ) - monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) - monkeypatch.setattr(Controller, "SdsClient", sds_init_side_effect) + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + FakeGpConnectClient.response_status_code = 200 + FakeGpConnectClient.response_body = b"ok" + FakeGpConnectClient.response_headers = {"Content-Type": "application/fhir+json"} + + body = make_request_body("943 476 5919") + headers = make_headers(ods_from="ORG1", trace_id="trace-123") + + r = c.call_gp_connect(body, headers, "token-abc") - r = c.call_gp_connect("943 476 5919", "token-abc") assert r.status_code == 200 assert r.data == "ok" assert r.headers == {"Content-Type": "application/fhir+json"} - # Verify GP Connect called with coerced NHS number string and stripped ASID - assert c.gp_connect_client.last_call == { - "nhs_number": "9434765919", - "asid": "asid_A12345", - "auth_token": "token-abc", + # GP Connect client constructed with trimmed SDS fields + assert FakeGpConnectClient.last_init == { + "provider_endpoint": "https://provider.example/ep", + "provider_asid": "asid_A12345", + "consumer_asid": "asid_ORG1", + } + + # GP Connect called with correct parameter names and values + assert FakeGpConnectClient.last_call == { + "trace_id": "trace-123", + "body": body, + "nhsnumber": "9434765919", } def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( - patched_deps: Any, monkeypatch: pytest.MonkeyPatch + patched_deps: Any, + patched_json_loads: Any, ) -> None: - monkeypatch.setattr(Controller, "_coerce_nhs_number_to_int", lambda _: 9434765919) - c = _make_controller() - def pds_init_side_effect(**kwargs: dict[str, Any]) -> FakePdsClient: - inst = FakePdsClient( - **kwargs - ) # stop early (404) so we only assert constructor args - return inst - - monkeypatch.setattr(Controller, "PdsClient", pds_init_side_effect) + body = make_request_body("9434765919") + headers = make_headers(ods_from="ORG1", trace_id="trace-123") - _ = c.call_gp_connect("9434765919", "token-abc") + _ = c.call_gp_connect(body, headers, "token-abc") # will stop at PDS None => 404 - # These are the kwargs Controller passes into PdsClient() - assert FakePdsClient.last_init["auth_token"] == "token-abc" # noqa S105 (fake test credentials) + assert FakePdsClient.last_init is not None + assert FakePdsClient.last_init["auth_token"] == "token-abc" # noqa: S105 assert FakePdsClient.last_init["end_user_org_ods"] == "ORG1" assert FakePdsClient.last_init["base_url"] == "https://pds.example" assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" From 8ad53a6b87fa0e78fcd5d927198ffbbae2c13c4c Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:35:48 +0000 Subject: [PATCH 40/54] Tests passing. Maybe got too many tests. --- gateway-api/src/gateway_api/controller.py | 10 +- .../src/gateway_api/test_controller.py | 371 ++++++++++++++++-- 2 files changed, 340 insertions(+), 41 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 1636129a..1ba7d80c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -130,7 +130,9 @@ def _get_details_from_body(self, request_body: json_str) -> int: message='Request body must be valid JSON with an "nhs-number" field', ) from None - if not hasattr(body, "getitem"): # Must be a dict-like object + if not ( + hasattr(body, "__getitem__") and hasattr(body, "get") + ): # Must be a dict-like object raise RequestError( status_code=400, message='Request body must be a JSON object with an "nhs-number" field', @@ -312,9 +314,9 @@ def call_gp_connect( ) return FlaskResponse( - status_code=response.status_code if response else 502, - data=response.text if response else "GP Connect service error", - headers=dict(response.headers) if response else None, + status_code=response.status_code if response is not None else 502, + data=response.text if response is not None else "GP Connect service error", + headers=dict(response.headers) if response is not None else None, ) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 1da5b6e7..d9999486 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -37,34 +37,6 @@ def make_headers( return {"Ods-from": ods_from, "X-Request-ID": trace_id} -# ------------------------------------------------------------------- -# Shim for controller._get_details_from_body() "getitem" attribute check -# ------------------------------------------------------------------- -class _DictWithGetitem(dict[str, Any]): - # The controller currently checks hasattr(body, "getitem") - # so we provide a getitem attribute that behaves like __getitem__. - def getitem(self, key: str, default: Any = None) -> Any: # pragma: no cover - return self.get(key, default) - - -@pytest.fixture -def patched_json_loads(monkeypatch: pytest.MonkeyPatch) -> None: - """ - Ensure controller_module.json.loads returns an object that passes: - hasattr(body, "getitem") - while still behaving like a normal dict for .get(). - """ - original_loads = controller_module.json.loads - - def loads_with_getitem(payload: str) -> Any: - parsed = original_loads(payload) - if isinstance(parsed, dict): - return _DictWithGetitem(parsed) - return parsed - - monkeypatch.setattr(controller_module.json, "loads", loads_with_getitem) - - # ----------------------------- # Fake downstream dependencies # ----------------------------- @@ -88,14 +60,25 @@ def set_patient_details(self, value: Any) -> None: def search_patient_by_nhs_number(self, nhs_number: int) -> Any | None: return self._patient_details + @classmethod + def reset(cls) -> None: + cls.last_init = None + class FakeSdsClient: + 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 @@ -109,6 +92,10 @@ def set_org_details( def get_org_details(self, ods_code: str) -> SdsSearchResults | None: return self._org_details_by_ods.get(ods_code) + @classmethod + def reset(cls) -> None: + cls.last_init = None + class FakeGpConnectClient: last_init: dict[str, str] | None = None @@ -152,6 +139,26 @@ def access_structured_record( resp.url = "https://example.invalid/fake" return resp + @classmethod + def reset(cls) -> None: + cls.last_init = None + cls.last_call = None + cls.return_none = False + cls.response_status_code = 200 + cls.response_body = b"ok" + cls.response_headers = {"Content-Type": "application/fhir+json"} + + +@pytest.fixture(autouse=True) +def _reset_test_fakes() -> None: + """ + Reset mutable class-level state on fakes before each test to prevent + cross-test contamination (e.g., return_none=True leaking into another test). + """ + FakePdsClient.reset() + FakeSdsClient.reset() + FakeGpConnectClient.reset() + @pytest.fixture def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: @@ -196,7 +203,6 @@ def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( def test_call_gp_connect_returns_404_when_pds_patient_not_found( patched_deps: Any, - patched_json_loads: Any, ) -> None: c = _make_controller() @@ -212,14 +218,14 @@ def test_call_gp_connect_returns_404_when_pds_patient_not_found( def test_call_gp_connect_returns_404_when_gp_ods_code_missing( patched_deps: Any, - patched_json_loads: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result(" ")) # blank gp_ods_code + # missing gp_ods_code should be a PDS error + inst.set_patient_details(_make_pds_result("")) return inst monkeypatch.setattr(controller_module, "PdsClient", pds_factory) @@ -230,12 +236,11 @@ def pds_factory(**kwargs: Any) -> FakePdsClient: r = c.call_gp_connect(body, headers, "token-abc") assert r.status_code == 404 - assert "No SDS org found for provider ODS code" in (r.data or "") + assert "did not contain a current provider ODS code" in (r.data or "") def test_call_gp_connect_returns_404_when_sds_returns_none_for_provider( patched_deps: Any, - patched_json_loads: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: c = _make_controller() @@ -264,7 +269,6 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_connect_returns_404_when_sds_provider_asid_blank( patched_deps: Any, - patched_json_loads: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: c = _make_controller() @@ -296,7 +300,6 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_connect_returns_502_when_gp_connect_returns_none( patched_deps: Any, - patched_json_loads: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: c = _make_controller() @@ -337,7 +340,6 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_connect_happy_path_maps_status_text_headers_and_trims_sds_fields( patched_deps: Any, - patched_json_loads: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: c = _make_controller() @@ -394,7 +396,6 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( patched_deps: Any, - patched_json_loads: Any, ) -> None: c = _make_controller() @@ -409,3 +410,299 @@ def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( assert FakePdsClient.last_init["base_url"] == "https://pds.example" assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" assert FakePdsClient.last_init["timeout"] == 3 + + +# ----------------------------- +# Additional unit tests +# ----------------------------- +def test_call_gp_connect_returns_400_when_request_body_not_valid_json( + patched_deps: Any, +) -> None: + c = _make_controller() + headers = make_headers() + + r = c.call_gp_connect("{", headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Request body must be valid JSON with an "nhs-number" field' + + +def test_call_gp_connect_returns_400_when_request_body_is_not_an_object( + patched_deps: Any, +) -> None: + c = _make_controller() + headers = make_headers() + + r = c.call_gp_connect('["9434765919"]', headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Request body must be a JSON object with an "nhs-number" field' + + +def test_call_gp_connect_returns_400_when_request_body_missing_nhs_number( + patched_deps: Any, +) -> None: + c = _make_controller() + headers = make_headers() + + r = c.call_gp_connect("{}", headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Missing required field "nhs-number" in JSON request body' + + +def test_call_gp_connect_returns_400_when_nhs_number_not_coercible( + patched_deps: Any, +) -> None: + c = _make_controller() + headers = make_headers() + + r = c.call_gp_connect(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Could not coerce NHS number "ABC" to an integer' + + +def test_call_gp_connect_returns_400_when_missing_ods_from_header( + patched_deps: Any, +) -> None: + c = _make_controller() + body = make_request_body("9434765919") + + r = c.call_gp_connect(body, {"X-Request-ID": "trace-123"}, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Missing required header "Ods-from"' + + +def test_call_gp_connect_returns_400_when_ods_from_is_whitespace( + patched_deps: Any, +) -> None: + c = _make_controller() + body = make_request_body("9434765919") + + r = c.call_gp_connect( + body, {"Ods-from": " ", "X-Request-ID": "trace-123"}, "token-abc" + ) + + assert r.status_code == 400 + assert r.data == 'Missing required header "Ods-from"' + + +def test_call_gp_connect_returns_400_when_missing_x_request_id( + patched_deps: Any, +) -> None: + c = _make_controller() + body = make_request_body("9434765919") + + r = c.call_gp_connect(body, {"Ods-from": "ORG1"}, "token-abc") + + assert r.status_code == 400 + assert r.data == "Missing required header: X-Request-ID" + + +def test_call_gp_connect_allows_empty_x_request_id_and_passes_through( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Documents current behaviour: controller checks for None, not empty string. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + body = make_request_body("9434765919") + headers = {"Ods-from": "ORG1", "X-Request-ID": ""} # empty but not None + + r = c.call_gp_connect(body, headers, "token-abc") + + assert r.status_code == 200 + assert FakeGpConnectClient.last_call is not None + assert FakeGpConnectClient.last_call["trace_id"] == "" + + +def test_call_gp_connect_returns_404_when_sds_provider_endpoint_blank( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", SdsSearchResults(asid="asid_A12345", endpoint=" ") + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + r = c.call_gp_connect(make_request_body("9434765919"), make_headers(), "token-abc") + + assert r.status_code == 404 + assert "did not contain a current endpoint" in (r.data or "") + + +def test_call_gp_connect_returns_404_when_sds_returns_none_for_consumer( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + # No consumer org details + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + r = c.call_gp_connect( + make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" + ) + + assert r.status_code == 404 + assert r.data == "No SDS org found for consumer ODS code ORG1" + + +def test_call_gp_connect_returns_404_when_sds_consumer_asid_blank( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid=" ", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + r = c.call_gp_connect( + make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" + ) + + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +def test_call_gp_connect_passthroughs_non_200_gp_connect_response( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + FakeGpConnectClient.response_status_code = 404 + FakeGpConnectClient.response_body = b"Not Found" + FakeGpConnectClient.response_headers = { + "Content-Type": "text/plain", + "X-Downstream": "gp-connect", + } + + r = c.call_gp_connect(make_request_body("9434765919"), make_headers(), "token-abc") + + 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-connect" + + +def test_call_gp_connect_constructs_sds_client_with_expected_kwargs( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + _ = c.call_gp_connect(make_request_body("9434765919"), make_headers(), "token-abc") + + assert FakeSdsClient.last_init == { + "auth_token": "token-abc", + "base_url": "https://sds.example", + "timeout": 3, + } From 700e4b105a75d0c23d0acfde44805a763b63fa7f Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:03:34 +0000 Subject: [PATCH 41/54] Trim some unnecessary unit tests --- .../src/gateway_api/test_controller.py | 171 +----------------- 1 file changed, 8 insertions(+), 163 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index d9999486..9d9ff290 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -60,10 +60,6 @@ def set_patient_details(self, value: Any) -> None: def search_patient_by_nhs_number(self, nhs_number: int) -> Any | None: return self._patient_details - @classmethod - def reset(cls) -> None: - cls.last_init = None - class FakeSdsClient: last_init: dict[str, Any] | None = None @@ -92,10 +88,6 @@ def set_org_details( def get_org_details(self, ods_code: str) -> SdsSearchResults | None: return self._org_details_by_ods.get(ods_code) - @classmethod - def reset(cls) -> None: - cls.last_init = None - class FakeGpConnectClient: last_init: dict[str, str] | None = None @@ -139,26 +131,6 @@ def access_structured_record( resp.url = "https://example.invalid/fake" return resp - @classmethod - def reset(cls) -> None: - cls.last_init = None - cls.last_call = None - cls.return_none = False - cls.response_status_code = 200 - cls.response_body = b"ok" - cls.response_headers = {"Content-Type": "application/fhir+json"} - - -@pytest.fixture(autouse=True) -def _reset_test_fakes() -> None: - """ - Reset mutable class-level state on fakes before each test to prevent - cross-test contamination (e.g., return_none=True leaking into another test). - """ - FakePdsClient.reset() - FakeSdsClient.reset() - FakeGpConnectClient.reset() - @pytest.fixture def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: @@ -182,13 +154,13 @@ def _make_controller() -> Controller: # ----------------------------- def test__coerce_nhs_number_to_int_accepts_spaces_and_validates() -> None: # Use real validator logic by default; 9434765919 is algorithmically valid. - assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa: SLF001 + assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa: SLF001 (testing private member) @pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: - with pytest.raises(ValueError): # noqa: PT011 - _coerce_nhs_number_to_int(value) # noqa: SLF001 + with pytest.raises(ValueError): # noqa: PT011 (ValueError is correct here) + _coerce_nhs_number_to_int(value) # noqa: SLF001 (testing private member) def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( @@ -198,7 +170,7 @@ def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( # gateway_api.controller monkeypatch.setattr(controller_module, "validate_nhs_number", lambda _: False) with pytest.raises(ValueError, match="invalid"): - _coerce_nhs_number_to_int("9434765919") # noqa: SLF001 + _coerce_nhs_number_to_int("9434765919") # noqa: SLF001 (testing private member) def test_call_gp_connect_returns_404_when_pds_patient_not_found( @@ -302,6 +274,10 @@ def test_call_gp_connect_returns_502_when_gp_connect_returns_none( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + GPConnectClient only returns None if we didn't call/get a response from + GP Connect, in which case 502 is correct + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: @@ -338,62 +314,6 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: FakeGpConnectClient.return_none = False -def test_call_gp_connect_happy_path_maps_status_text_headers_and_trims_sds_fields( - patched_deps: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - # include whitespace to assert trimming in controller._get_sds_details() - inst.set_org_details( - "A12345", - SdsSearchResults( - asid=" asid_A12345 ", endpoint=" https://provider.example/ep " - ), - ) - inst.set_org_details( - "ORG1", SdsSearchResults(asid=" asid_ORG1 ", endpoint=None) - ) - return inst - - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - - FakeGpConnectClient.response_status_code = 200 - FakeGpConnectClient.response_body = b"ok" - FakeGpConnectClient.response_headers = {"Content-Type": "application/fhir+json"} - - body = make_request_body("943 476 5919") - headers = make_headers(ods_from="ORG1", trace_id="trace-123") - - r = c.call_gp_connect(body, headers, "token-abc") - - assert r.status_code == 200 - assert r.data == "ok" - assert r.headers == {"Content-Type": "application/fhir+json"} - - # GP Connect client constructed with trimmed SDS fields - assert FakeGpConnectClient.last_init == { - "provider_endpoint": "https://provider.example/ep", - "provider_asid": "asid_A12345", - "consumer_asid": "asid_ORG1", - } - - # GP Connect called with correct parameter names and values - assert FakeGpConnectClient.last_call == { - "trace_id": "trace-123", - "body": body, - "nhsnumber": "9434765919", - } - - def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( patched_deps: Any, ) -> None: @@ -412,9 +332,6 @@ def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( assert FakePdsClient.last_init["timeout"] == 3 -# ----------------------------- -# Additional unit tests -# ----------------------------- def test_call_gp_connect_returns_400_when_request_body_not_valid_json( patched_deps: Any, ) -> None: @@ -501,44 +418,6 @@ def test_call_gp_connect_returns_400_when_missing_x_request_id( assert r.data == "Missing required header: X-Request-ID" -def test_call_gp_connect_allows_empty_x_request_id_and_passes_through( - patched_deps: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - Documents current behaviour: controller checks for None, not empty string. - """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", - SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" - ), - ) - inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) - return inst - - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - - body = make_request_body("9434765919") - headers = {"Ods-from": "ORG1", "X-Request-ID": ""} # empty but not None - - r = c.call_gp_connect(body, headers, "token-abc") - - assert r.status_code == 200 - assert FakeGpConnectClient.last_call is not None - assert FakeGpConnectClient.last_call["trace_id"] == "" - - def test_call_gp_connect_returns_404_when_sds_provider_endpoint_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, @@ -672,37 +551,3 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: assert r.headers is not None assert r.headers.get("Content-Type") == "text/plain" assert r.headers.get("X-Downstream") == "gp-connect" - - -def test_call_gp_connect_constructs_sds_client_with_expected_kwargs( - patched_deps: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", - SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" - ), - ) - inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) - return inst - - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - - _ = c.call_gp_connect(make_request_body("9434765919"), make_headers(), "token-abc") - - assert FakeSdsClient.last_init == { - "auth_token": "token-abc", - "base_url": "https://sds.example", - "timeout": 3, - } From 035faadf623d9b56f17de0090af1500b920477c6 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:57:08 +0000 Subject: [PATCH 42/54] Sort out docstrings --- gateway-api/src/gateway_api/common/common.py | 34 ++- .../src/gateway_api/common/test_common.py | 15 +- gateway-api/src/gateway_api/controller.py | 155 +++++++++++--- .../src/gateway_api/test_controller.py | 193 +++++++++++++++++- 4 files changed, 350 insertions(+), 47 deletions(-) diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py index ead64f21..5d647466 100644 --- a/gateway-api/src/gateway_api/common/common.py +++ b/gateway-api/src/gateway_api/common/common.py @@ -1,30 +1,44 @@ +""" +Shared lightweight types and helpers used across the gateway API. +""" + import re from dataclasses import dataclass +# This project uses JSON request/response bodies as strings in the controller layer. +# The alias is used to make intent clearer in function signatures. type json_str = str @dataclass class FlaskResponse: + """ + Lightweight response container returned by controller entry points. + + This mirrors the minimal set of fields used by the surrounding web framework. + + :param status_code: HTTP status code for the response (e.g., 200, 400, 404). + :param data: Response body as text, if any. + :param headers: Response headers, if any. + """ + + # TODO: Un-ai all these docstrings + status_code: int data: str | None = None headers: dict[str, str] | None = None def validate_nhs_number(value: str | int) -> bool: - # TODO: Un-AI all these docstrings """ Validate an NHS number using the NHS modulus-11 check digit algorithm. - Algorithm summary: - - NHS number is 10 digits: d1..d9 + check digit d10 - - Compute: total = d1*10 + d2*9 + ... + d9*2 - - remainder = total % 11 - - check = 11 - remainder - - If check == 11 => check digit must be 0 - - If check == 10 => check digit must be 10 (impossible as digit) => invalid - - If remainder == 1 => check would be 10 => invalid - - Else check digit must match d10 + The input may be a string or integer. Any non-digit separators in string + inputs (spaces, hyphens, etc.) are ignored. + + :param value: NHS number as a string or integer. Non-digit characters + are ignored when a string is provided. + :returns: ``True`` if the number is a valid NHS number, otherwise ``False``. """ str_value = str(value) # Just in case they passed an integer digits = re.sub(r"\D", "", str_value or "") diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py index b399f8dd..d87e909b 100644 --- a/gateway-api/src/gateway_api/common/test_common.py +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -1,22 +1,21 @@ -# tests/test_common.py +""" +Unit tests for :mod:`gateway_api.common.common`. +""" from gateway_api.common import common -def test_flask_response_defaults() -> None: - r = common.FlaskResponse(status_code=200) - assert r.status_code == 200 - assert r.data is None - assert r.headers is None - - def test_validate_nhs_number_accepts_valid_number_with_separators() -> None: + """ + Validate that separators (spaces, hyphens) are ignored and valid numbers pass. + """ assert common.validate_nhs_number("943 476 5919") is True assert common.validate_nhs_number("943-476-5919") is True assert common.validate_nhs_number(9434765919) is True def test_validate_nhs_number_rejects_wrong_length_and_bad_check_digit() -> None: + """Validate that incorrect lengths and invalid check digits are rejected.""" assert common.validate_nhs_number("") is False assert common.validate_nhs_number("943476591") is False # 9 digits assert common.validate_nhs_number("94347659190") is False # 11 digits diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 1ba7d80c..506492b0 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -1,3 +1,7 @@ +""" +Controller layer for orchestrating calls to external services +""" + from __future__ import annotations import json @@ -14,18 +18,27 @@ from gateway_api.pds_search import PdsClient, PdsSearchResults -class DownstreamServiceError(RuntimeError): - """Raised when a downstream dependency (PDS/SDS/GP Connect) fails.""" - - @dataclass class RequestError(Exception): - """Raised (and handled) when there is a problem with the incoming request.""" + """ + 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 @@ -33,7 +46,11 @@ def __str__(self) -> str: 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 @@ -43,6 +60,7 @@ class SdsSearchResults: class SdsClient: """ Stub SDS client for obtaining ASID from ODS code. + Replace this with the real one once it's implemented. """ @@ -50,15 +68,30 @@ class SdsClient: def __init__( self, - auth_token: str | None = None, + 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" @@ -68,6 +101,7 @@ def get_org_details(self, ods_code: str) -> SdsSearchResults | None: class GpConnectClient: """ Stub GP Connect client for obtaining patient records. + Replace this with the real one once it's implemented. """ @@ -79,6 +113,13 @@ def __init__( provider_asid: str, consumer_asid: str, ) -> None: + """ + Create a GP Connect client. + + :param provider_endpoint: Provider endpoint obtained from SDS. + :param provider_asid: Provider ASID obtained from SDS. + :param consumer_asid: Consumer ASID obtained from SDS. + """ self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid self.consumer_asid = consumer_asid @@ -89,6 +130,16 @@ def access_structured_record( body: json_str, # NOSONAR S1172 (ignore in stub) nhsnumber: str, # NOSONAR S1172 (ignore in stub) ) -> requests.Response | None: + """ + Retrieve a patient's structured record from GP Connect. + + This stub just returns None, the real thing will be more interesting! + + :param trace_id: Correlation/trace identifier for request tracking. + :param body: Original request body. + :param nhsnumber: NHS number as a string. + :returns: A ``requests.Response`` if the call was made, otherwise ``None``. + """ # Placeholder implementation return None @@ -98,11 +149,9 @@ class Controller: Orchestrates calls to PDS -> SDS -> GP Connect. Entry point: - - call_gp_connect(request_body_json, headers, auth_token) -> requests.Response + - ``call_gp_connect(request_body_json, headers, auth_token) -> FlaskResponse`` """ - # TODO: Un-AI the docstrings and comments - gp_connect_client: GpConnectClient | None def __init__( @@ -112,16 +161,30 @@ def __init__( nhsd_session_urid: str | None = None, timeout: int = 10, ) -> None: + """ + Create a controller instance. + + :param pds_base_url: Base URL for PDS client. + :param sds_base_url: Base URL for SDS client. + :param nhsd_session_urid: Session URID for NHS Digital session handling. + :param timeout: Timeout in seconds for downstream calls. + """ self.pds_base_url = pds_base_url self.sds_base_url = sds_base_url self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout - - self.sds_client = SdsClient(base_url=sds_base_url, timeout=timeout) self.gp_connect_client = None def _get_details_from_body(self, request_body: json_str) -> int: - # --- Extract NHS number from request body --- + """ + Parse request JSON and extract the NHS number as an integer. + + :param request_body: JSON request body containing an ``"nhs-number"`` field. + :returns: NHS number as an integer. + :raises RequestError: If the request body is invalid, missing fields, or + contains an invalid NHS number. + """ + # Extract NHS number from request body try: body: Any = json.loads(request_body) except (TypeError, json.JSONDecodeError): @@ -130,6 +193,7 @@ def _get_details_from_body(self, request_body: json_str) -> int: message='Request body must be valid JSON with an "nhs-number" field', ) from None + # Guard: require "dict-like" semantics without relying on isinstance checks. if not ( hasattr(body, "__getitem__") and hasattr(body, "get") ): # Must be a dict-like object @@ -160,7 +224,16 @@ def _get_details_from_body(self, request_body: json_str) -> int: def _get_pds_details( self, auth_token: str, consumer_ods: str, nhs_number: int ) -> str: - # --- PDS: find patient and extract GP ODS code (provider ODS) --- + """ + 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 (already coerced to an integer). + :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, @@ -195,7 +268,20 @@ def _get_pds_details( def _get_sds_details( self, auth_token: str, consumer_ods: str, provider_ods: str ) -> tuple[str, str, str]: - # --- SDS: Get provider details (ASID + endpoint) for provider ODS --- + """ + Call SDS to obtain consumer ASID, provider ASID, and provider endpoint. + + This method performs two SDS lookups: + - provider details (ASID + endpoint) + - consumer details (ASID) + + :param auth_token: Authorization token to use for SDS. + :param consumer_ods: Consumer organisation ODS code (from request headers). + :param provider_ods: Provider organisation ODS code (from PDS). + :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). + :raises RequestError: If SDS data is missing or incomplete for provider/consumer + """ + # SDS: Get provider details (ASID + endpoint) for provider ODS sds = SdsClient( auth_token=auth_token, base_url=self.sds_base_url, @@ -229,7 +315,7 @@ def _get_sds_details( ), ) - # --- SDS: Get consumer details (ASID) for consumer ODS --- + # SDS: Get consumer details (ASID) for consumer ODS consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) if consumer_details is None: raise RequestError( @@ -256,15 +342,25 @@ def call_gp_connect( auth_token: str, ) -> FlaskResponse: """ - Expects a JSON request body containing an "nhs-number" field. - Also expects HTTP headers (from Flask) and extracts "Ods-from" as consumer_ods. + Controller entry point + + Expects a JSON request body containing an ``"nhs-number"`` field. + Also expects HTTP headers (from Flask) and extracts: + - ``Ods-from`` as the consumer organisation ODS code + - ``X-Request-ID`` as the trace/correlation ID + Orchestration steps: 1) Call PDS to obtain the patient's GP (provider) ODS code. 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. 3) Call SDS using consumer ODS to obtain consumer ASID. - 4) Call GP Connect to obtain patient records - """ + 4) Call GP Connect to obtain patient records. + :param request_body: Raw JSON request body. + :param headers: HTTP headers from the request. + :param auth_token: Authorization token used for downstream services. + :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the + outcome. + """ try: nhs_number = self._get_details_from_body(request_body) except RequestError as err: @@ -273,7 +369,7 @@ def call_gp_connect( data=str(err), ) - # --- Extract consumer ODS from headers --- + # Extract consumer ODS from headers consumer_ods = headers.get("Ods-from", "").strip() if not consumer_ods: return FlaskResponse( @@ -299,8 +395,7 @@ def call_gp_connect( except RequestError as err: return FlaskResponse(status_code=err.status_code, data=str(err)) - # --- Call GP Connect with correct parameters --- - # (If these are dynamic per-request, reinitialise the client accordingly.) + # Call GP Connect with correct parameters self.gp_connect_client = GpConnectClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, @@ -313,6 +408,9 @@ def call_gp_connect( nhsnumber=str(nhs_number), ) + # If we get a None from GP Connect, 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 Connect service error", @@ -322,9 +420,16 @@ def call_gp_connect( def _coerce_nhs_number_to_int(value: str | int) -> int: """ - Coerce NHS number to int with basic validation. - NHS numbers are 10 digits, but leading zeros are not typically used. - Adjust validation as needed for your domain rules. + Coerce an NHS number to an integer with basic validation. + + Notes: + - NHS numbers are 10 digits. + - Input may include whitespace (e.g., ``"943 476 5919"``). + + :param value: NHS number value, as a string or integer. + :returns: The coerced NHS number as an integer. + :raises ValueError: If the NHS number is non-numeric, the wrong length, or fails + validation. """ try: stripped = cast("str", value).strip().replace(" ", "") diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 9d9ff290..d3d4a265 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,4 +1,7 @@ -# tests/test_controller.py +""" +Unit tests for :mod:`gateway_api.controller`. +""" + from __future__ import annotations import json as std_json @@ -23,6 +26,13 @@ # Helpers for request test data # ----------------------------- def make_request_body(nhs_number: str = "9434765919") -> json_str: + """ + Create a JSON request body string containing an ``"nhs-number"`` field. + + :param nhs_number: NHS number to embed in the request body. + :returns: JSON string payload suitable for + :meth:`gateway_api.controller.Controller.call_gp_connect`. + """ # Controller expects a JSON string containing an "nhs-number" field. return std_json.dumps({"nhs-number": nhs_number}) @@ -31,6 +41,14 @@ def make_headers( ods_from: str = "ORG1", trace_id: str = "trace-123", ) -> dict[str, str]: + """ + Create the minimum required headers for controller entry points. + + :param ods_from: Value for the ``Ods-from`` header (consumer ODS code). + :param trace_id: Value for the ``X-Request-ID`` header (trace/correlation ID). + :returns: Header dictionary suitable for + :meth:`gateway_api.controller.Controller.call_gp_connect`. + """ # Controller expects these headers: # - Ods-from (consumer ODS) # - X-Request-ID (trace id) @@ -41,27 +59,65 @@ def make_headers( # 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. + """ # We only need .gp_ods_code for controller logic. 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: + """ + Capture constructor kwargs for later assertions. + + :param kwargs: Arbitrary keyword arguments passed by the controller. + """ # Controller constructs PdsClient with kwargs; capture for assertions. FakePdsClient.last_init = dict(kwargs) self._patient_details: Any | None = None def set_patient_details(self, value: Any) -> None: + """ + Configure the value returned by ``search_patient_by_nhs_number``. + + :param value: Result-like object to return (or ``None`` to simulate not found). + """ # Keep call sites explicit and "correct": pass a PDS-result-like object. self._patient_details = value def search_patient_by_nhs_number(self, nhs_number: int) -> Any | None: + """ + Return the configured patient details. + + :param nhs_number: NHS number requested (not used by the fake). + :returns: Configured patient details or ``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__( @@ -70,6 +126,13 @@ def __init__( base_url: str = "test_url", timeout: int = 10, ) -> None: + """ + Capture constructor arguments and initialise storage for org details. + + :param auth_token: Auth token passed by the controller. + :param base_url: Base URL passed by the controller. + :param timeout: Timeout passed by the controller. + """ FakeSdsClient.last_init = { "auth_token": auth_token, "base_url": base_url, @@ -83,17 +146,36 @@ def __init__( def set_org_details( self, ods_code: str, org_details: SdsSearchResults | None ) -> None: + """ + Configure the SDS lookup result for a given ODS code. + + :param ods_code: ODS code key. + :param org_details: SDS details or ``None`` to simulate not found. + """ self._org_details_by_ods[ods_code] = org_details def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + """ + Retrieve configured org details for a given ODS code. + + :param ods_code: ODS code to look up. + :returns: Configured SDS details or ``None``. + """ return self._org_details_by_ods.get(ods_code) class FakeGpConnectClient: + """ + Test double for :class:`gateway_api.controller.GpConnectClient`. + + 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 + # Configure per-test. return_none: bool = False response_status_code: int = 200 response_body: bytes = b"ok" @@ -102,6 +184,13 @@ class FakeGpConnectClient: def __init__( self, provider_endpoint: str, provider_asid: str, consumer_asid: str ) -> None: + """ + Capture constructor arguments for later assertions. + + :param provider_endpoint: Provider endpoint passed by the controller. + :param provider_asid: Provider ASID passed by the controller. + :param consumer_asid: Consumer ASID passed by the controller. + """ FakeGpConnectClient.last_init = { "provider_endpoint": provider_endpoint, "provider_asid": provider_asid, @@ -114,6 +203,15 @@ def access_structured_record( body: json_str, nhsnumber: str, ) -> Response | None: + """ + Return either a configured :class:`requests.Response` or ``None``. + + :param trace_id: Trace identifier from request headers. + :param body: JSON request body. + :param nhsnumber: NHS number as a string. + :returns: A configured :class:`requests.Response`, or ``None`` if + ``return_none`` is set. + """ FakeGpConnectClient.last_call = { "trace_id": trace_id, "body": body, @@ -134,6 +232,12 @@ def access_structured_record( @pytest.fixture def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Patch controller dependencies to use test fakes. + Pass as a fixture to give any given test a clean set of patched dependencies. + + :param monkeypatch: pytest monkeypatch fixture. + """ # Patch dependency classes in the *module* namespace that Controller uses. monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) @@ -141,6 +245,11 @@ def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: def _make_controller() -> Controller: + """ + Construct a controller instance configured for unit tests. + + :returns: Controller instance. + """ return Controller( pds_base_url="https://pds.example", sds_base_url="https://sds.example", @@ -153,12 +262,20 @@ def _make_controller() -> Controller: # Unit tests # ----------------------------- def test__coerce_nhs_number_to_int_accepts_spaces_and_validates() -> None: + """ + Validate that whitespace separators are accepted and the number is validated. + """ # Use real validator logic by default; 9434765919 is algorithmically valid. assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa: SLF001 (testing private member) @pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: + """ + Validate that non-numeric and incorrect-length values are rejected. + + :param value: Parameterized input value. + """ with pytest.raises(ValueError): # noqa: PT011 (ValueError is correct here) _coerce_nhs_number_to_int(value) # noqa: SLF001 (testing private member) @@ -166,6 +283,11 @@ def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + Validate that a failing NHS number validator causes coercion to fail. + + :param monkeypatch: pytest monkeypatch fixture. + """ # _coerce_nhs_number_to_int calls validate_nhs_number imported into # gateway_api.controller monkeypatch.setattr(controller_module, "validate_nhs_number", lambda _: False) @@ -176,6 +298,9 @@ def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( def test_call_gp_connect_returns_404_when_pds_patient_not_found( patched_deps: Any, ) -> None: + """ + If PDS returns no patient record, the controller should return 404. + """ c = _make_controller() # PDS returns None by default @@ -192,6 +317,11 @@ def test_call_gp_connect_returns_404_when_gp_ods_code_missing( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + If PDS returns a patient without a provider (GP) ODS code, return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: @@ -215,6 +345,11 @@ def test_call_gp_connect_returns_404_when_sds_returns_none_for_provider( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + If SDS returns no provider org details, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: @@ -243,6 +378,11 @@ def test_call_gp_connect_returns_404_when_sds_provider_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + If provider ASID is blank/whitespace, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: @@ -275,8 +415,9 @@ def test_call_gp_connect_returns_502_when_gp_connect_returns_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """ - GPConnectClient only returns None if we didn't call/get a response from - GP Connect, in which case 502 is correct + If GP Connect returns no response object, the controller should return 502. + + :param monkeypatch: pytest monkeypatch fixture. """ c = _make_controller() @@ -317,6 +458,9 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( patched_deps: Any, ) -> None: + """ + Validate that the controller constructs the PDS client with expected kwargs. + """ c = _make_controller() body = make_request_body("9434765919") @@ -335,6 +479,9 @@ def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( def test_call_gp_connect_returns_400_when_request_body_not_valid_json( patched_deps: Any, ) -> None: + """ + If the request body is invalid JSON, the controller should return 400. + """ c = _make_controller() headers = make_headers() @@ -347,6 +494,9 @@ def test_call_gp_connect_returns_400_when_request_body_not_valid_json( def test_call_gp_connect_returns_400_when_request_body_is_not_an_object( patched_deps: Any, ) -> None: + """ + If the request body JSON is not an expected type of object (e.g., list), return 400. + """ c = _make_controller() headers = make_headers() @@ -359,6 +509,9 @@ def test_call_gp_connect_returns_400_when_request_body_is_not_an_object( def test_call_gp_connect_returns_400_when_request_body_missing_nhs_number( patched_deps: Any, ) -> None: + """ + If the request body omits ``"nhs-number"``, return 400. + """ c = _make_controller() headers = make_headers() @@ -371,6 +524,9 @@ def test_call_gp_connect_returns_400_when_request_body_missing_nhs_number( def test_call_gp_connect_returns_400_when_nhs_number_not_coercible( patched_deps: Any, ) -> None: + """ + If ``"nhs-number"`` cannot be coerced/validated, return 400. + """ c = _make_controller() headers = make_headers() @@ -383,6 +539,9 @@ def test_call_gp_connect_returns_400_when_nhs_number_not_coercible( def test_call_gp_connect_returns_400_when_missing_ods_from_header( patched_deps: Any, ) -> None: + """ + If the required ``Ods-from`` header is missing, return 400. + """ c = _make_controller() body = make_request_body("9434765919") @@ -395,6 +554,9 @@ def test_call_gp_connect_returns_400_when_missing_ods_from_header( def test_call_gp_connect_returns_400_when_ods_from_is_whitespace( patched_deps: Any, ) -> None: + """ + If the ``Ods-from`` header is whitespace-only, return 400. + """ c = _make_controller() body = make_request_body("9434765919") @@ -409,6 +571,9 @@ def test_call_gp_connect_returns_400_when_ods_from_is_whitespace( def test_call_gp_connect_returns_400_when_missing_x_request_id( patched_deps: Any, ) -> None: + """ + If the required ``X-Request-ID`` header is missing, return 400. + """ c = _make_controller() body = make_request_body("9434765919") @@ -422,6 +587,11 @@ def test_call_gp_connect_returns_404_when_sds_provider_endpoint_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + If provider endpoint is blank/whitespace, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: @@ -450,6 +620,11 @@ def test_call_gp_connect_returns_404_when_sds_returns_none_for_consumer( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + If SDS returns no consumer org details, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: @@ -483,6 +658,11 @@ def test_call_gp_connect_returns_404_when_sds_consumer_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + If consumer ASID is blank/whitespace, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: @@ -516,6 +696,11 @@ def test_call_gp_connect_passthroughs_non_200_gp_connect_response( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: + """ + Validate that non-200 responses from GP Connect are passed through. + + :param monkeypatch: pytest monkeypatch fixture. + """ c = _make_controller() def pds_factory(**kwargs: Any) -> FakePdsClient: From 69a8bc25cdb551a143ed0425140d29064a707c0c Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:29:25 +0000 Subject: [PATCH 43/54] Add tests for coverage --- gateway-api/src/gateway_api/common/common.py | 2 -- gateway-api/src/gateway_api/test_controller.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py index 5d647466..ab25528f 100644 --- a/gateway-api/src/gateway_api/common/common.py +++ b/gateway-api/src/gateway_api/common/common.py @@ -22,8 +22,6 @@ class FlaskResponse: :param headers: Response headers, if any. """ - # TODO: Un-ai all these docstrings - status_code: int data: str | None = None headers: dict[str, str] | None = None diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index d3d4a265..9bcd7c5b 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -295,6 +295,16 @@ def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( _coerce_nhs_number_to_int("9434765919") # noqa: SLF001 (testing private member) +def test__coerce_nhs_number_to_int_accepts_integer_value() -> None: + """ + Ensure ``_coerce_nhs_number_to_int`` accepts an integer input + and returns it unchanged. + + :returns: None + """ + assert _coerce_nhs_number_to_int(9434765919) == 9434765919 # noqa: SLF001 + + def test_call_gp_connect_returns_404_when_pds_patient_not_found( patched_deps: Any, ) -> None: From 2a0678e7292e9428abb98ced6e9cefb6ac609f67 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:31:08 +0000 Subject: [PATCH 44/54] Add tests for coverage --- .../src/gateway_api/common/test_common.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py index d87e909b..ee19aa8b 100644 --- a/gateway-api/src/gateway_api/common/test_common.py +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -20,3 +20,41 @@ def test_validate_nhs_number_rejects_wrong_length_and_bad_check_digit() -> None: assert common.validate_nhs_number("943476591") is False # 9 digits assert common.validate_nhs_number("94347659190") is False # 11 digits assert common.validate_nhs_number("9434765918") is False # wrong check digit + + +def test_validate_nhs_number_returns_false_for_non_ten_digits_and_non_numeric() -> None: + """ + validate_nhs_number should return False when: + - The number of digits is not exactly 10. + - The input is not numeric. + + Notes: + - The implementation strips non-digit characters before validation, so a fully + non-numeric input becomes an empty digit string and is rejected. + """ + # Not ten digits after stripping -> False + assert common.validate_nhs_number("123456789") is False + assert common.validate_nhs_number("12345678901") is False + + # Not numeric -> False (becomes 0 digits after stripping) + assert common.validate_nhs_number("NOT_A_NUMBER") is False + + +def test_validate_nhs_number_check_edge_cases_10_and_11() -> None: + """ + validate_nhs_number should behave correctly when the computed ``check`` value + is 10 or 11. + + - If ``check`` computes to 11, it should be treated as 0, so a number with check + digit 0 should validate successfully. + - If ``check`` computes to 10, the number is invalid and validation should return + False. + """ + # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid + # with check digit 0 + assert common.validate_nhs_number("0000000000") is True + + # First nine digits produce remainder 1 => check 10 => invalid regardless of + # final digit + # Choose d9=6 and others 0: total = 6*2 = 12 => 12 % 11 = 1 => check = 10 + assert common.validate_nhs_number("0000000060") is False From 57eccc3334787554a7a02a2e3107ebf3ad94ad9f Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:12:46 +0000 Subject: [PATCH 45/54] Change GP Connect to GP provider --- gateway-api/src/gateway_api/controller.py | 34 +++--- .../src/gateway_api/test_controller.py | 110 +++++++++--------- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 506492b0..0ed3ea0d 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -98,14 +98,14 @@ def get_org_details(self, ods_code: str) -> SdsSearchResults | None: ) -class GpConnectClient: +class GpProviderClient: """ - Stub GP Connect client for obtaining patient records. + Stub GP provider client for obtaining patient records. Replace this with the real one once it's implemented. """ - SANDBOX_URL = "https://example.invalid/gpconnect" + SANDBOX_URL = "https://example.invalid/gpprovider" def __init__( self, @@ -114,7 +114,7 @@ def __init__( consumer_asid: str, ) -> None: """ - Create a GP Connect client. + Create a GP provider client. :param provider_endpoint: Provider endpoint obtained from SDS. :param provider_asid: Provider ASID obtained from SDS. @@ -131,7 +131,7 @@ def access_structured_record( nhsnumber: str, # NOSONAR S1172 (ignore in stub) ) -> requests.Response | None: """ - Retrieve a patient's structured record from GP Connect. + Retrieve a patient's structured record from GP provider. This stub just returns None, the real thing will be more interesting! @@ -146,13 +146,13 @@ def access_structured_record( class Controller: """ - Orchestrates calls to PDS -> SDS -> GP Connect. + Orchestrates calls to PDS -> SDS -> GP provider. Entry point: - - ``call_gp_connect(request_body_json, headers, auth_token) -> FlaskResponse`` + - ``call_gp_provider(request_body_json, headers, auth_token) -> FlaskResponse`` """ - gp_connect_client: GpConnectClient | None + gp_provider_client: GpProviderClient | None def __init__( self, @@ -173,7 +173,7 @@ def __init__( self.sds_base_url = sds_base_url self.nhsd_session_urid = nhsd_session_urid self.timeout = timeout - self.gp_connect_client = None + self.gp_provider_client = None def _get_details_from_body(self, request_body: json_str) -> int: """ @@ -335,7 +335,7 @@ def _get_sds_details( return consumer_asid, provider_asid, provider_endpoint - def call_gp_connect( + def call_gp_provider( self, request_body: json_str, headers: dict[str, str], @@ -353,7 +353,7 @@ def call_gp_connect( 1) Call PDS to obtain the patient's GP (provider) ODS code. 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. 3) Call SDS using consumer ODS to obtain consumer ASID. - 4) Call GP Connect to obtain patient records. + 4) Call GP provider to obtain patient records. :param request_body: Raw JSON request body. :param headers: HTTP headers from the request. @@ -395,25 +395,25 @@ def call_gp_connect( except RequestError as err: return FlaskResponse(status_code=err.status_code, data=str(err)) - # Call GP Connect with correct parameters - self.gp_connect_client = GpConnectClient( + # Call GP provider with correct parameters + self.gp_provider_client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, ) - response = self.gp_connect_client.access_structured_record( + response = self.gp_provider_client.access_structured_record( trace_id=trace_id, body=request_body, nhsnumber=str(nhs_number), ) - # If we get a None from GP Connect, that means that either the service did not - # respond or we didn't make the request to the service in the first place. + # 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 Connect service error", + data=response.text if response is not None else "GP provider service error", headers=dict(response.headers) if response is not None else None, ) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 9bcd7c5b..2ee4ada7 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -31,7 +31,7 @@ def make_request_body(nhs_number: str = "9434765919") -> json_str: :param nhs_number: NHS number to embed in the request body. :returns: JSON string payload suitable for - :meth:`gateway_api.controller.Controller.call_gp_connect`. + :meth:`gateway_api.controller.Controller.call_gp_provider`. """ # Controller expects a JSON string containing an "nhs-number" field. return std_json.dumps({"nhs-number": nhs_number}) @@ -47,7 +47,7 @@ def make_headers( :param ods_from: Value for the ``Ods-from`` header (consumer ODS code). :param trace_id: Value for the ``X-Request-ID`` header (trace/correlation ID). :returns: Header dictionary suitable for - :meth:`gateway_api.controller.Controller.call_gp_connect`. + :meth:`gateway_api.controller.Controller.call_gp_provider`. """ # Controller expects these headers: # - Ods-from (consumer ODS) @@ -164,9 +164,9 @@ def get_org_details(self, ods_code: str) -> SdsSearchResults | None: return self._org_details_by_ods.get(ods_code) -class FakeGpConnectClient: +class FakeGpProviderClient: """ - Test double for :class:`gateway_api.controller.GpConnectClient`. + 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. @@ -191,7 +191,7 @@ def __init__( :param provider_asid: Provider ASID passed by the controller. :param consumer_asid: Consumer ASID passed by the controller. """ - FakeGpConnectClient.last_init = { + FakeGpProviderClient.last_init = { "provider_endpoint": provider_endpoint, "provider_asid": provider_asid, "consumer_asid": consumer_asid, @@ -212,20 +212,20 @@ def access_structured_record( :returns: A configured :class:`requests.Response`, or ``None`` if ``return_none`` is set. """ - FakeGpConnectClient.last_call = { + FakeGpProviderClient.last_call = { "trace_id": trace_id, "body": body, "nhsnumber": nhsnumber, } - if FakeGpConnectClient.return_none: + if FakeGpProviderClient.return_none: return None resp = Response() - resp.status_code = FakeGpConnectClient.response_status_code - resp._content = FakeGpConnectClient.response_body # noqa: SLF001 + resp.status_code = FakeGpProviderClient.response_status_code + resp._content = FakeGpProviderClient.response_body # noqa: SLF001 resp.encoding = "utf-8" - resp.headers.update(FakeGpConnectClient.response_headers) + resp.headers.update(FakeGpProviderClient.response_headers) resp.url = "https://example.invalid/fake" return resp @@ -241,7 +241,7 @@ def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: # Patch dependency classes in the *module* namespace that Controller uses. monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) - monkeypatch.setattr(controller_module, "GpConnectClient", FakeGpConnectClient) + monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) def _make_controller() -> Controller: @@ -305,7 +305,7 @@ def test__coerce_nhs_number_to_int_accepts_integer_value() -> None: assert _coerce_nhs_number_to_int(9434765919) == 9434765919 # noqa: SLF001 -def test_call_gp_connect_returns_404_when_pds_patient_not_found( +def test_call_gp_provider_returns_404_when_pds_patient_not_found( patched_deps: Any, ) -> None: """ @@ -317,13 +317,13 @@ def test_call_gp_connect_returns_404_when_pds_patient_not_found( body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_connect(body, headers, "token-abc") + r = c.call_gp_provider(body, headers, "token-abc") assert r.status_code == 404 assert "No PDS patient found for NHS number" in (r.data or "") -def test_call_gp_connect_returns_404_when_gp_ods_code_missing( +def test_call_gp_provider_returns_404_when_gp_ods_code_missing( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -345,13 +345,13 @@ def pds_factory(**kwargs: Any) -> FakePdsClient: body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_connect(body, headers, "token-abc") + r = c.call_gp_provider(body, headers, "token-abc") assert r.status_code == 404 assert "did not contain a current provider ODS code" in (r.data or "") -def test_call_gp_connect_returns_404_when_sds_returns_none_for_provider( +def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -378,13 +378,13 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_connect(body, headers, "token-abc") + r = c.call_gp_provider(body, headers, "token-abc") assert r.status_code == 404 assert r.data == "No SDS org found for provider ODS code A12345" -def test_call_gp_connect_returns_404_when_sds_provider_asid_blank( +def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -414,18 +414,18 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_connect(body, headers, "token-abc") + r = c.call_gp_provider(body, headers, "token-abc") assert r.status_code == 404 assert "did not contain a current ASID" in (r.data or "") -def test_call_gp_connect_returns_502_when_gp_connect_returns_none( +def test_call_gp_provider_returns_502_when_gp_provider_returns_none( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: """ - If GP Connect returns no response object, the controller should return 502. + If GP provider returns no response object, the controller should return 502. :param monkeypatch: pytest monkeypatch fixture. """ @@ -450,22 +450,22 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: monkeypatch.setattr(controller_module, "PdsClient", pds_factory) monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - FakeGpConnectClient.return_none = True + FakeGpProviderClient.return_none = True body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_connect(body, headers, "token-abc") + r = c.call_gp_provider(body, headers, "token-abc") assert r.status_code == 502 - assert r.data == "GP Connect service error" + assert r.data == "GP provider service error" assert r.headers is None # reset for other tests - FakeGpConnectClient.return_none = False + FakeGpProviderClient.return_none = False -def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( +def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( patched_deps: Any, ) -> None: """ @@ -476,7 +476,7 @@ def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( body = make_request_body("9434765919") headers = make_headers(ods_from="ORG1", trace_id="trace-123") - _ = c.call_gp_connect(body, headers, "token-abc") # will stop at PDS None => 404 + _ = c.call_gp_provider(body, headers, "token-abc") # will stop at PDS None => 404 assert FakePdsClient.last_init is not None assert FakePdsClient.last_init["auth_token"] == "token-abc" # noqa: S105 @@ -486,7 +486,7 @@ def test_call_gp_connect_constructs_pds_client_with_expected_kwargs( assert FakePdsClient.last_init["timeout"] == 3 -def test_call_gp_connect_returns_400_when_request_body_not_valid_json( +def test_call_gp_provider_returns_400_when_request_body_not_valid_json( patched_deps: Any, ) -> None: """ @@ -495,13 +495,13 @@ def test_call_gp_connect_returns_400_when_request_body_not_valid_json( c = _make_controller() headers = make_headers() - r = c.call_gp_connect("{", headers, "token-abc") + r = c.call_gp_provider("{", headers, "token-abc") assert r.status_code == 400 assert r.data == 'Request body must be valid JSON with an "nhs-number" field' -def test_call_gp_connect_returns_400_when_request_body_is_not_an_object( +def test_call_gp_provider_returns_400_when_request_body_is_not_an_object( patched_deps: Any, ) -> None: """ @@ -510,13 +510,13 @@ def test_call_gp_connect_returns_400_when_request_body_is_not_an_object( c = _make_controller() headers = make_headers() - r = c.call_gp_connect('["9434765919"]', headers, "token-abc") + r = c.call_gp_provider('["9434765919"]', headers, "token-abc") assert r.status_code == 400 assert r.data == 'Request body must be a JSON object with an "nhs-number" field' -def test_call_gp_connect_returns_400_when_request_body_missing_nhs_number( +def test_call_gp_provider_returns_400_when_request_body_missing_nhs_number( patched_deps: Any, ) -> None: """ @@ -525,13 +525,13 @@ def test_call_gp_connect_returns_400_when_request_body_missing_nhs_number( c = _make_controller() headers = make_headers() - r = c.call_gp_connect("{}", headers, "token-abc") + r = c.call_gp_provider("{}", headers, "token-abc") assert r.status_code == 400 assert r.data == 'Missing required field "nhs-number" in JSON request body' -def test_call_gp_connect_returns_400_when_nhs_number_not_coercible( +def test_call_gp_provider_returns_400_when_nhs_number_not_coercible( patched_deps: Any, ) -> None: """ @@ -540,13 +540,13 @@ def test_call_gp_connect_returns_400_when_nhs_number_not_coercible( c = _make_controller() headers = make_headers() - r = c.call_gp_connect(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") + r = c.call_gp_provider(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") assert r.status_code == 400 assert r.data == 'Could not coerce NHS number "ABC" to an integer' -def test_call_gp_connect_returns_400_when_missing_ods_from_header( +def test_call_gp_provider_returns_400_when_missing_ods_from_header( patched_deps: Any, ) -> None: """ @@ -555,13 +555,13 @@ def test_call_gp_connect_returns_400_when_missing_ods_from_header( c = _make_controller() body = make_request_body("9434765919") - r = c.call_gp_connect(body, {"X-Request-ID": "trace-123"}, "token-abc") + r = c.call_gp_provider(body, {"X-Request-ID": "trace-123"}, "token-abc") assert r.status_code == 400 assert r.data == 'Missing required header "Ods-from"' -def test_call_gp_connect_returns_400_when_ods_from_is_whitespace( +def test_call_gp_provider_returns_400_when_ods_from_is_whitespace( patched_deps: Any, ) -> None: """ @@ -570,7 +570,7 @@ def test_call_gp_connect_returns_400_when_ods_from_is_whitespace( c = _make_controller() body = make_request_body("9434765919") - r = c.call_gp_connect( + r = c.call_gp_provider( body, {"Ods-from": " ", "X-Request-ID": "trace-123"}, "token-abc" ) @@ -578,7 +578,7 @@ def test_call_gp_connect_returns_400_when_ods_from_is_whitespace( assert r.data == 'Missing required header "Ods-from"' -def test_call_gp_connect_returns_400_when_missing_x_request_id( +def test_call_gp_provider_returns_400_when_missing_x_request_id( patched_deps: Any, ) -> None: """ @@ -587,13 +587,13 @@ def test_call_gp_connect_returns_400_when_missing_x_request_id( c = _make_controller() body = make_request_body("9434765919") - r = c.call_gp_connect(body, {"Ods-from": "ORG1"}, "token-abc") + r = c.call_gp_provider(body, {"Ods-from": "ORG1"}, "token-abc") assert r.status_code == 400 assert r.data == "Missing required header: X-Request-ID" -def test_call_gp_connect_returns_404_when_sds_provider_endpoint_blank( +def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -620,13 +620,13 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: monkeypatch.setattr(controller_module, "PdsClient", pds_factory) monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - r = c.call_gp_connect(make_request_body("9434765919"), make_headers(), "token-abc") + r = c.call_gp_provider(make_request_body("9434765919"), make_headers(), "token-abc") assert r.status_code == 404 assert "did not contain a current endpoint" in (r.data or "") -def test_call_gp_connect_returns_404_when_sds_returns_none_for_consumer( +def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -656,7 +656,7 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: monkeypatch.setattr(controller_module, "PdsClient", pds_factory) monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - r = c.call_gp_connect( + r = c.call_gp_provider( make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" ) @@ -664,7 +664,7 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: assert r.data == "No SDS org found for consumer ODS code ORG1" -def test_call_gp_connect_returns_404_when_sds_consumer_asid_blank( +def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -694,7 +694,7 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: monkeypatch.setattr(controller_module, "PdsClient", pds_factory) monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - r = c.call_gp_connect( + r = c.call_gp_provider( make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" ) @@ -702,12 +702,12 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: assert "did not contain a current ASID" in (r.data or "") -def test_call_gp_connect_passthroughs_non_200_gp_connect_response( +def test_call_gp_provider_passthroughs_non_200_gp_provider_response( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, ) -> None: """ - Validate that non-200 responses from GP Connect are passed through. + Validate that non-200 responses from GP provider are passed through. :param monkeypatch: pytest monkeypatch fixture. """ @@ -732,17 +732,17 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: monkeypatch.setattr(controller_module, "PdsClient", pds_factory) monkeypatch.setattr(controller_module, "SdsClient", sds_factory) - FakeGpConnectClient.response_status_code = 404 - FakeGpConnectClient.response_body = b"Not Found" - FakeGpConnectClient.response_headers = { + FakeGpProviderClient.response_status_code = 404 + FakeGpProviderClient.response_body = b"Not Found" + FakeGpProviderClient.response_headers = { "Content-Type": "text/plain", - "X-Downstream": "gp-connect", + "X-Downstream": "gp-provider", } - r = c.call_gp_connect(make_request_body("9434765919"), make_headers(), "token-abc") + r = c.call_gp_provider(make_request_body("9434765919"), make_headers(), "token-abc") 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-connect" + assert r.headers.get("X-Downstream") == "gp-provider" From 3cd51113bf0a4425626f0259ca198d9f493127fd Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:24:36 +0000 Subject: [PATCH 46/54] Remove redundant parentheses --- gateway-api/src/gateway_api/controller.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 0ed3ea0d..09a6be4c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -214,9 +214,7 @@ def _get_details_from_body(self, request_body: json_str) -> int: except ValueError: raise RequestError( status_code=400, - message=( - f'Could not coerce NHS number "{nhs_number_value}" to an integer' - ), + message=f'Could not cast NHS number "{nhs_number_value}" to an integer', ) from None return nhs_number_int From ce8fe94817f5a4712c1907d93e6898e036e69544 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:10:40 +0000 Subject: [PATCH 47/54] Fix expected response --- gateway-api/src/gateway_api/test_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 2ee4ada7..9901e99e 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -543,7 +543,7 @@ def test_call_gp_provider_returns_400_when_nhs_number_not_coercible( r = c.call_gp_provider(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") assert r.status_code == 400 - assert r.data == 'Could not coerce NHS number "ABC" to an integer' + assert r.data == 'Could not cast NHS number "ABC" to an integer' def test_call_gp_provider_returns_400_when_missing_ods_from_header( From 2c4f962b3d0c6f2b16927e60f2622d0fc7b2c0ea Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:53:46 +0000 Subject: [PATCH 48/54] Address review comments --- gateway-api/src/gateway_api/common/common.py | 35 +- .../src/gateway_api/common/test_common.py | 98 ++-- gateway-api/src/gateway_api/controller.py | 207 ++++---- .../src/gateway_api/test_controller.py | 475 ++++++++++-------- 4 files changed, 456 insertions(+), 359 deletions(-) diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py index ab25528f..8382bc6f 100644 --- a/gateway-api/src/gateway_api/common/common.py +++ b/gateway-api/src/gateway_api/common/common.py @@ -4,6 +4,7 @@ import re from dataclasses import dataclass +from typing import cast # This project uses JSON request/response bodies as strings in the controller layer. # The alias is used to make intent clearer in function signatures. @@ -39,7 +40,7 @@ def validate_nhs_number(value: str | int) -> bool: :returns: ``True`` if the number is a valid NHS number, otherwise ``False``. """ str_value = str(value) # Just in case they passed an integer - digits = re.sub(r"\D", "", str_value or "") + digits = re.sub(r"[\s-]", "", str_value or "") if len(digits) != 10: return False @@ -61,3 +62,35 @@ def validate_nhs_number(value: str | int) -> bool: return False # invalid NHS number return check == provided_check_digit + + +def coerce_nhs_number_to_int(value: str | int) -> int: + """ + Coerce an NHS number to an integer with basic validation. + + Notes: + - NHS numbers are 10 digits. + - Input may include whitespace (e.g., ``"943 476 5919"``). + + :param value: NHS number value, as a string or integer. + :returns: The coerced NHS number as an integer. + :raises ValueError: If the NHS number is non-numeric, the wrong length, or fails + validation. + """ + try: + stripped = cast("str", value).strip().replace(" ", "") + except AttributeError: + nhs_number_int = cast("int", value) + else: + if not stripped.isdigit(): + raise ValueError("NHS number must be numeric") + nhs_number_int = int(stripped) + + if len(str(nhs_number_int)) != 10: + # If you need to accept test numbers of different length, relax this. + raise ValueError("NHS number must be 10 digits") + + if not validate_nhs_number(nhs_number_int): + raise ValueError("NHS number is invalid") + + return nhs_number_int diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py index ee19aa8b..733d4010 100644 --- a/gateway-api/src/gateway_api/common/test_common.py +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -2,45 +2,47 @@ Unit tests for :mod:`gateway_api.common.common`. """ -from gateway_api.common import common - - -def test_validate_nhs_number_accepts_valid_number_with_separators() -> None: - """ - Validate that separators (spaces, hyphens) are ignored and valid numbers pass. - """ - assert common.validate_nhs_number("943 476 5919") is True - assert common.validate_nhs_number("943-476-5919") is True - assert common.validate_nhs_number(9434765919) is True +from typing import Any +import pytest -def test_validate_nhs_number_rejects_wrong_length_and_bad_check_digit() -> None: - """Validate that incorrect lengths and invalid check digits are rejected.""" - assert common.validate_nhs_number("") is False - assert common.validate_nhs_number("943476591") is False # 9 digits - assert common.validate_nhs_number("94347659190") is False # 11 digits - assert common.validate_nhs_number("9434765918") is False # wrong check digit +from gateway_api.common import common -def test_validate_nhs_number_returns_false_for_non_ten_digits_and_non_numeric() -> None: +@pytest.mark.parametrize( + ("nhs_number", "expected"), + [ + ("9434765919", True), # Just a number + ("943 476 5919", True), # Spaces are permitted + ("987-654-3210", True), # Hyphens are permitted + (9434765919, True), # Integer input is permitted + ("", False), # Empty string is invalid + ("943476591", False), # 9 digits + ("94347659190", False), # 11 digits + ("9434765918", False), # wrong check digit + ("NOT_A_NUMBER", False), # non-numeric + ("943SOME_LETTERS4765919", False), # non-numeric in a valid NHS number + ], +) +def test_validate_nhs_number(nhs_number: str | int, expected: bool) -> None: """ - validate_nhs_number should return False when: - - The number of digits is not exactly 10. - - The input is not numeric. - - Notes: - - The implementation strips non-digit characters before validation, so a fully - non-numeric input becomes an empty digit string and is rejected. + Validate that separators (spaces, hyphens) are ignored and valid numbers pass. """ - # Not ten digits after stripping -> False - assert common.validate_nhs_number("123456789") is False - assert common.validate_nhs_number("12345678901") is False + assert common.validate_nhs_number(nhs_number) is expected - # Not numeric -> False (becomes 0 digits after stripping) - assert common.validate_nhs_number("NOT_A_NUMBER") is False - -def test_validate_nhs_number_check_edge_cases_10_and_11() -> None: +@pytest.mark.parametrize( + ("nhs_number", "expected"), + [ + # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid + ("0000000000", True), + # First 9 digits produce remainder 1 => check 10 => invalid + ("0000000060", False), + ], +) +def test_validate_nhs_number_check_edge_cases_10_and_11( + nhs_number: str | int, expected: bool +) -> None: """ validate_nhs_number should behave correctly when the computed ``check`` value is 10 or 11. @@ -52,9 +54,33 @@ def test_validate_nhs_number_check_edge_cases_10_and_11() -> None: """ # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid # with check digit 0 - assert common.validate_nhs_number("0000000000") is True + assert common.validate_nhs_number(nhs_number) is expected + - # First nine digits produce remainder 1 => check 10 => invalid regardless of - # final digit - # Choose d9=6 and others 0: total = 6*2 = 12 => 12 % 11 = 1 => check = 10 - assert common.validate_nhs_number("0000000060") is False +def test__coerce_nhs_number_to_int_accepts_spaces_and_validates() -> None: + """ + Validate that whitespace separators are accepted and the number is validated. + """ + # Use real validator logic by default; 9434765919 is algorithmically valid. + assert common.coerce_nhs_number_to_int("943 476 5919") == 9434765919 + + +@pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) +def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: + """ + Validate that non-numeric and incorrect-length values are rejected. + + :param value: Parameterized input value. + """ + with pytest.raises(ValueError): # noqa: PT011 (ValueError is correct here) + common.coerce_nhs_number_to_int(value) + + +def test__coerce_nhs_number_to_int_accepts_integer_value() -> None: + """ + Ensure ``_coerce_nhs_number_to_int`` accepts an integer input + and returns it unchanged. + + :returns: None + """ + assert common.coerce_nhs_number_to_int(9434765919) == 9434765919 diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 09a6be4c..43ad309e 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -9,12 +9,12 @@ __all__ = ["json"] # Make mypy happy in tests from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import requests -from gateway_api.common.common import FlaskResponse, json_str, validate_nhs_number +from gateway_api.common.common import FlaskResponse, coerce_nhs_number_to_int, json_str from gateway_api.pds_search import PdsClient, PdsSearchResults @@ -128,7 +128,6 @@ def access_structured_record( self, trace_id: str, # NOSONAR S1172 (ignore in stub) body: json_str, # NOSONAR S1172 (ignore in stub) - nhsnumber: str, # NOSONAR S1172 (ignore in stub) ) -> requests.Response | None: """ Retrieve a patient's structured record from GP provider. @@ -175,6 +174,87 @@ def __init__( self.timeout = timeout self.gp_provider_client = None + def run( + self, + request_body: json_str, + headers: dict[str, str], + auth_token: str, + ) -> FlaskResponse: + """ + Controller entry point + + Expects a JSON request body containing an ``"nhs-number"`` field. + Also expects HTTP headers (from Flask) and extracts: + - ``Ods-from`` as the consumer organisation ODS code + - ``X-Request-ID`` as the trace/correlation ID + + Orchestration steps: + 1) Call PDS to obtain the patient's GP (provider) ODS code. + 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. + 3) Call SDS using consumer ODS to obtain consumer ASID. + 4) Call GP provider to obtain patient records. + + :param request_body: Raw JSON request body. + :param headers: HTTP headers from the request. + :param auth_token: Authorization token used for downstream services. + :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the + outcome. + """ + try: + nhs_number = self._get_details_from_body(request_body) + except RequestError as err: + return FlaskResponse( + status_code=err.status_code, + data=str(err), + ) + + # Extract consumer ODS from headers + consumer_ods = headers.get("Ods-from", "").strip() + if not consumer_ods: + return FlaskResponse( + status_code=400, + data='Missing required header "Ods-from"', + ) + + trace_id = headers.get("X-Request-ID") + if trace_id is None: + return FlaskResponse( + status_code=400, data="Missing required header: X-Request-ID" + ) + + try: + provider_ods = self._get_pds_details(auth_token, consumer_ods, nhs_number) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + try: + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + auth_token, consumer_ods, provider_ods + ) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + # Call GP provider with correct parameters + self.gp_provider_client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + response = self.gp_provider_client.access_structured_record( + trace_id=trace_id, + body=request_body, + ) + + # If we get a None from the GP provider, that means that either the service did + # not respond or we didn't make the request to the service in the first place. + # Therefore a None is a 502, any real response just pass straight back. + return FlaskResponse( + status_code=response.status_code if response is not None else 502, + data=response.text if response is not None else "GP provider service error", + headers=dict(response.headers) if response is not None else None, + ) + def _get_details_from_body(self, request_body: json_str) -> int: """ Parse request JSON and extract the NHS number as an integer. @@ -190,16 +270,15 @@ def _get_details_from_body(self, request_body: json_str) -> int: except (TypeError, json.JSONDecodeError): raise RequestError( status_code=400, - message='Request body must be valid JSON with an "nhs-number" field', + message="Request body must be valid JSON", ) from None - # Guard: require "dict-like" semantics without relying on isinstance checks. if not ( hasattr(body, "__getitem__") and hasattr(body, "get") ): # Must be a dict-like object raise RequestError( status_code=400, - message='Request body must be a JSON object with an "nhs-number" field', + message="JSON structure must be an object/dictionary", ) from None nhs_number_value = body.get("nhs-number") @@ -210,7 +289,7 @@ def _get_details_from_body(self, request_body: json_str) -> int: ) from None try: - nhs_number_int = _coerce_nhs_number_to_int(nhs_number_value) + nhs_number_int = coerce_nhs_number_to_int(nhs_number_value) except ValueError: raise RequestError( status_code=400, @@ -332,117 +411,3 @@ def _get_sds_details( ) return consumer_asid, provider_asid, provider_endpoint - - def call_gp_provider( - self, - request_body: json_str, - headers: dict[str, str], - auth_token: str, - ) -> FlaskResponse: - """ - Controller entry point - - Expects a JSON request body containing an ``"nhs-number"`` field. - Also expects HTTP headers (from Flask) and extracts: - - ``Ods-from`` as the consumer organisation ODS code - - ``X-Request-ID`` as the trace/correlation ID - - Orchestration steps: - 1) Call PDS to obtain the patient's GP (provider) ODS code. - 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. - 3) Call SDS using consumer ODS to obtain consumer ASID. - 4) Call GP provider to obtain patient records. - - :param request_body: Raw JSON request body. - :param headers: HTTP headers from the request. - :param auth_token: Authorization token used for downstream services. - :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the - outcome. - """ - try: - nhs_number = self._get_details_from_body(request_body) - except RequestError as err: - return FlaskResponse( - status_code=err.status_code, - data=str(err), - ) - - # Extract consumer ODS from headers - consumer_ods = headers.get("Ods-from", "").strip() - if not consumer_ods: - return FlaskResponse( - status_code=400, - data='Missing required header "Ods-from"', - ) - - trace_id = headers.get("X-Request-ID") - if trace_id is None: - return FlaskResponse( - status_code=400, data="Missing required header: X-Request-ID" - ) - - try: - provider_ods = self._get_pds_details(auth_token, consumer_ods, nhs_number) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) - - try: - consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( - auth_token, consumer_ods, provider_ods - ) - except RequestError as err: - return FlaskResponse(status_code=err.status_code, data=str(err)) - - # Call GP provider with correct parameters - self.gp_provider_client = GpProviderClient( - provider_endpoint=provider_endpoint, - provider_asid=provider_asid, - consumer_asid=consumer_asid, - ) - - response = self.gp_provider_client.access_structured_record( - trace_id=trace_id, - body=request_body, - nhsnumber=str(nhs_number), - ) - - # If we get a None from the GP provider, that means that either the service did - # not respond or we didn't make the request to the service in the first place. - # Therefore a None is a 502, any real response just pass straight back. - return FlaskResponse( - status_code=response.status_code if response is not None else 502, - data=response.text if response is not None else "GP provider service error", - headers=dict(response.headers) if response is not None else None, - ) - - -def _coerce_nhs_number_to_int(value: str | int) -> int: - """ - Coerce an NHS number to an integer with basic validation. - - Notes: - - NHS numbers are 10 digits. - - Input may include whitespace (e.g., ``"943 476 5919"``). - - :param value: NHS number value, as a string or integer. - :returns: The coerced NHS number as an integer. - :raises ValueError: If the NHS number is non-numeric, the wrong length, or fails - validation. - """ - try: - stripped = cast("str", value).strip().replace(" ", "") - except AttributeError: - nhs_number_int = cast("int", value) - else: - if not stripped.isdigit(): - raise ValueError("NHS number must be numeric") - nhs_number_int = int(stripped) - - if len(str(nhs_number_int)) != 10: - # If you need to accept test numbers of different length, relax this. - raise ValueError("NHS number must be 10 digits") - - if not validate_nhs_number(nhs_number_int): - raise ValueError("NHS number is invalid") - - return nhs_number_int diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 9901e99e..8cd4237a 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -5,6 +5,7 @@ from __future__ import annotations import json as std_json +from dataclasses import dataclass from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -15,7 +16,6 @@ from gateway_api.controller import ( Controller, SdsSearchResults, - _coerce_nhs_number_to_int, ) if TYPE_CHECKING: @@ -201,22 +201,16 @@ def access_structured_record( self, trace_id: str, body: json_str, - nhsnumber: str, ) -> Response | None: """ Return either a configured :class:`requests.Response` or ``None``. :param trace_id: Trace identifier from request headers. :param body: JSON request body. - :param nhsnumber: NHS number as a string. :returns: A configured :class:`requests.Response`, or ``None`` if ``return_none`` is set. """ - FakeGpProviderClient.last_call = { - "trace_id": trace_id, - "body": body, - "nhsnumber": nhsnumber, - } + FakeGpProviderClient.last_call = {"trace_id": trace_id, "body": body} if FakeGpProviderClient.return_none: return None @@ -230,6 +224,122 @@ def access_structured_record( 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. + + Used in tests to set up SDS responses for provider and consumer orgs. + """ + + def __init__( + self, + org1: SdsSetup | None = None, + org2: SdsSetup | None = None, + ) -> None: + """ + Construct the fake SDS client and configure org details. + + :param org1: First organisation to configure, or ``None``. + :param org2: Second organisation to configure, or ``None``. + :param kwargs: Additional keyword arguments passed to + :class:`FakeSdsClient`. + """ + self.org1 = org1 + self.org2 = org2 + # TODO: Fix factory class docstrings + + def __call__(self, **kwargs: Any) -> FakeSdsClient: + """ + Return the configured fake SDS client. + + :returns: Configured :class:`FakeSdsClient` instance. + """ + 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 + + +# def sds_factory( +# org1: SdsSetup | None, org2: SdsSetup | None, **kwargs: Any +# ) -> FakeSdsClient: +# inst = FakeSdsClient(**kwargs) +# if org1 is not None: +# inst.set_org_details( +# org1.ods_code, +# SdsSearchResults( +# asid=org1.search_results.asid, endpoint=org1.search_results.endpoint +# ), +# ) +# +# if org2 is not None: +# inst.set_org_details( +# org2.ods_code, +# SdsSearchResults( +# asid=org2.search_results.asid, endpoint=org2.search_results.endpoint +# ), +# ) +# return inst + + +class pds_factory: + """ + Factory to create a :class:`FakePdsClient` pre-configured with patient details. + """ + + def __init__(self, ods_code: str | None) -> None: + """ + Construct the fake PDS client and configure patient details. + + :param ods_code: Provider ODS code to set on the patient details. + :param kwargs: Additional keyword arguments passed to + :class:`FakePdsClient`. + """ + self.ods_code = ods_code + + def __call__(self, **kwargs: Any) -> FakePdsClient: + """ + Return the configured fake PDS client. + + :returns: Configured :class:`FakePdsClient` instance. + """ + self.inst = FakePdsClient(**kwargs) + self.inst.set_patient_details(_make_pds_result(self.ods_code)) + return self.inst + + +# def pds_factory(ods_code: str, **kwargs: Any) -> FakePdsClient: +# inst = FakePdsClient(**kwargs) +# inst.set_patient_details(_make_pds_result(ods_code)) +# return inst + + @pytest.fixture def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: """ @@ -244,7 +354,8 @@ def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) -def _make_controller() -> Controller: +@pytest.fixture +def controller() -> Controller: """ Construct a controller instance configured for unit tests. @@ -261,63 +372,67 @@ def _make_controller() -> Controller: # ----------------------------- # Unit tests # ----------------------------- -def test__coerce_nhs_number_to_int_accepts_spaces_and_validates() -> None: - """ - Validate that whitespace separators are accepted and the number is validated. - """ - # Use real validator logic by default; 9434765919 is algorithmically valid. - assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa: SLF001 (testing private member) - - -@pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) -def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: - """ - Validate that non-numeric and incorrect-length values are rejected. - - :param value: Parameterized input value. - """ - with pytest.raises(ValueError): # noqa: PT011 (ValueError is correct here) - _coerce_nhs_number_to_int(value) # noqa: SLF001 (testing private member) -def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( +def test_call_gp_provider_returns_200_on_success( + patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ - Validate that a failing NHS number validator causes coercion to fail. - - :param monkeypatch: pytest monkeypatch fixture. + On successful end-to-end call, the controller should return 200 with + expected body/headers. """ - # _coerce_nhs_number_to_int calls validate_nhs_number imported into - # gateway_api.controller - monkeypatch.setattr(controller_module, "validate_nhs_number", lambda _: False) - with pytest.raises(ValueError, match="invalid"): - _coerce_nhs_number_to_int("9434765919") # noqa: SLF001 (testing private member) + # TODO: OK, this works. Repeat it sixteen more times (or get the AI to do it) + pds = pds_factory(ods_code="A12345") + sds_org1 = SdsSetup( + ods_code="A12345", + search_results=SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="ORG1", + search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) -def test__coerce_nhs_number_to_int_accepts_integer_value() -> None: - """ - Ensure ``_coerce_nhs_number_to_int`` accepts an integer input - and returns it unchanged. + FakeGpProviderClient.response_status_code = 200 + FakeGpProviderClient.response_body = b'{"resourceType":"Bundle"}' + FakeGpProviderClient.response_headers = { + "Content-Type": "application/fhir+json", + "X-Downstream": "gp-provider", + } - :returns: None - """ - assert _coerce_nhs_number_to_int(9434765919) == 9434765919 # noqa: SLF001 + body = make_request_body("9434765919") + headers = make_headers() + + r = controller.run(body, headers, "token-abc") + + assert r.status_code == 200 + assert r.data == '{"resourceType":"Bundle"}' + assert r.headers is not None + assert r.headers.get("Content-Type") == "application/fhir+json" + assert r.headers.get("X-Downstream") == "gp-provider" def test_call_gp_provider_returns_404_when_pds_patient_not_found( patched_deps: Any, + controller: Controller, ) -> None: """ If PDS returns no patient record, the controller should return 404. """ - c = _make_controller() - # PDS returns None by default + # No users added to the PDS stub, so a request for this user will get nothing + # back from "PDS". The controller should return 404 with the given error. body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_provider(body, headers, "token-abc") + r = controller.run(body, headers, "token-abc") assert r.status_code == 404 assert "No PDS patient found for NHS number" in (r.data or "") @@ -326,26 +441,20 @@ def test_call_gp_provider_returns_404_when_pds_patient_not_found( def test_call_gp_provider_returns_404_when_gp_ods_code_missing( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ If PDS returns a patient without a provider (GP) ODS code, return 404. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - # missing gp_ods_code should be a PDS error - inst.set_patient_details(_make_pds_result("")) - return inst - - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + pds = pds_factory(ods_code="") + monkeypatch.setattr(controller_module, "PdsClient", pds) body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_provider(body, headers, "token-abc") + r = controller.run(body, headers, "token-abc") assert r.status_code == 404 assert "did not contain a current provider ODS code" in (r.data or "") @@ -354,31 +463,23 @@ def pds_factory(**kwargs: Any) -> FakePdsClient: def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ If SDS returns no provider org details, the controller should return 404. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - # Do NOT set provider org details => None - return inst + pds = pds_factory(ods_code="A12345") + sds = sds_factory() - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_provider(body, headers, "token-abc") + r = controller.run(body, headers, "token-abc") assert r.status_code == 404 assert r.data == "No SDS org found for provider ODS code A12345" @@ -387,34 +488,29 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ If provider ASID is blank/whitespace, the controller should return 404. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", - SdsSearchResults(asid=" ", endpoint="https://provider.example/ep"), - ) - return inst + pds = pds_factory(ods_code="A12345") + sds_org1 = SdsSetup( + ods_code="A12345", + search_results=SdsSearchResults( + asid=" ", endpoint="https://provider.example/ep" + ), + ) + sds = sds_factory(org1=sds_org1) - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_provider(body, headers, "token-abc") + r = controller.run(body, headers, "token-abc") assert r.status_code == 404 assert "did not contain a current ASID" in (r.data or "") @@ -423,39 +519,35 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_provider_returns_502_when_gp_provider_returns_none( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ If GP provider returns no response object, the controller should return 502. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", - SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" - ), - ) - inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) - return inst + pds = pds_factory(ods_code="A12345") + sds_org1 = SdsSetup( + ods_code="A12345", + search_results=SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="ORG1", + search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) FakeGpProviderClient.return_none = True body = make_request_body("9434765919") headers = make_headers() - r = c.call_gp_provider(body, headers, "token-abc") + r = controller.run(body, headers, "token-abc") assert r.status_code == 502 assert r.data == "GP provider service error" @@ -467,16 +559,16 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( patched_deps: Any, + controller: Controller, ) -> None: """ Validate that the controller constructs the PDS client with expected kwargs. """ - c = _make_controller() body = make_request_body("9434765919") headers = make_headers(ods_from="ORG1", trace_id="trace-123") - _ = c.call_gp_provider(body, headers, "token-abc") # will stop at PDS None => 404 + _ = controller.run(body, headers, "token-abc") # will stop at PDS None => 404 assert FakePdsClient.last_init is not None assert FakePdsClient.last_init["auth_token"] == "token-abc" # noqa: S105 @@ -488,44 +580,44 @@ def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( def test_call_gp_provider_returns_400_when_request_body_not_valid_json( patched_deps: Any, + controller: Controller, ) -> None: """ If the request body is invalid JSON, the controller should return 400. """ - c = _make_controller() headers = make_headers() - r = c.call_gp_provider("{", headers, "token-abc") + r = controller.run("{", headers, "token-abc") assert r.status_code == 400 - assert r.data == 'Request body must be valid JSON with an "nhs-number" field' + assert r.data == "Request body must be valid JSON" def test_call_gp_provider_returns_400_when_request_body_is_not_an_object( patched_deps: Any, + controller: Controller, ) -> None: """ If the request body JSON is not an expected type of object (e.g., list), return 400. """ - c = _make_controller() headers = make_headers() - r = c.call_gp_provider('["9434765919"]', headers, "token-abc") + r = controller.run('["9434765919"]', headers, "token-abc") assert r.status_code == 400 - assert r.data == 'Request body must be a JSON object with an "nhs-number" field' + assert r.data == "JSON structure must be an object/dictionary" def test_call_gp_provider_returns_400_when_request_body_missing_nhs_number( patched_deps: Any, + controller: Controller, ) -> None: """ If the request body omits ``"nhs-number"``, return 400. """ - c = _make_controller() headers = make_headers() - r = c.call_gp_provider("{}", headers, "token-abc") + r = controller.run("{}", headers, "token-abc") assert r.status_code == 400 assert r.data == 'Missing required field "nhs-number" in JSON request body' @@ -533,14 +625,14 @@ def test_call_gp_provider_returns_400_when_request_body_missing_nhs_number( def test_call_gp_provider_returns_400_when_nhs_number_not_coercible( patched_deps: Any, + controller: Controller, ) -> None: """ If ``"nhs-number"`` cannot be coerced/validated, return 400. """ - c = _make_controller() headers = make_headers() - r = c.call_gp_provider(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") + r = controller.run(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") assert r.status_code == 400 assert r.data == 'Could not cast NHS number "ABC" to an integer' @@ -548,14 +640,14 @@ def test_call_gp_provider_returns_400_when_nhs_number_not_coercible( def test_call_gp_provider_returns_400_when_missing_ods_from_header( patched_deps: Any, + controller: Controller, ) -> None: """ If the required ``Ods-from`` header is missing, return 400. """ - c = _make_controller() body = make_request_body("9434765919") - r = c.call_gp_provider(body, {"X-Request-ID": "trace-123"}, "token-abc") + r = controller.run(body, {"X-Request-ID": "trace-123"}, "token-abc") assert r.status_code == 400 assert r.data == 'Missing required header "Ods-from"' @@ -563,14 +655,14 @@ def test_call_gp_provider_returns_400_when_missing_ods_from_header( def test_call_gp_provider_returns_400_when_ods_from_is_whitespace( patched_deps: Any, + controller: Controller, ) -> None: """ If the ``Ods-from`` header is whitespace-only, return 400. """ - c = _make_controller() body = make_request_body("9434765919") - r = c.call_gp_provider( + r = controller.run( body, {"Ods-from": " ", "X-Request-ID": "trace-123"}, "token-abc" ) @@ -580,14 +672,14 @@ def test_call_gp_provider_returns_400_when_ods_from_is_whitespace( def test_call_gp_provider_returns_400_when_missing_x_request_id( patched_deps: Any, + controller: Controller, ) -> None: """ If the required ``X-Request-ID`` header is missing, return 400. """ - c = _make_controller() body = make_request_body("9434765919") - r = c.call_gp_provider(body, {"Ods-from": "ORG1"}, "token-abc") + r = controller.run(body, {"Ods-from": "ORG1"}, "token-abc") assert r.status_code == 400 assert r.data == "Missing required header: X-Request-ID" @@ -596,31 +688,28 @@ def test_call_gp_provider_returns_400_when_missing_x_request_id( def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ If provider endpoint is blank/whitespace, the controller should return 404. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", SdsSearchResults(asid="asid_A12345", endpoint=" ") - ) - inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) - return inst + pds = pds_factory(ods_code="A12345") + sds_org1 = SdsSetup( + ods_code="A12345", + search_results=SdsSearchResults(asid="asid_A12345", endpoint=" "), + ) + sds_org2 = SdsSetup( + ods_code="ORG1", + search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) - r = c.call_gp_provider(make_request_body("9434765919"), make_headers(), "token-abc") + r = controller.run(make_request_body("9434765919"), make_headers(), "token-abc") assert r.status_code == 404 assert "did not contain a current endpoint" in (r.data or "") @@ -629,34 +718,26 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ If SDS returns no consumer org details, the controller should return 404. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", - SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" - ), - ) - # No consumer org details - return inst + pds = pds_factory(ods_code="A12345") + sds_org1 = SdsSetup( + ods_code="A12345", + search_results=SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + sds = sds_factory(org1=sds_org1) - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) - r = c.call_gp_provider( + r = controller.run( make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" ) @@ -667,34 +748,30 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ If consumer ASID is blank/whitespace, the controller should return 404. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", - SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" - ), - ) - inst.set_org_details("ORG1", SdsSearchResults(asid=" ", endpoint=None)) - return inst + pds = pds_factory(ods_code="A12345") + sds_org1 = SdsSetup( + ods_code="A12345", + search_results=SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="ORG1", + search_results=SdsSearchResults(asid=" ", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) - r = c.call_gp_provider( + r = controller.run( make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" ) @@ -705,32 +782,28 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: def test_call_gp_provider_passthroughs_non_200_gp_provider_response( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, + controller: Controller, ) -> None: """ Validate that non-200 responses from GP provider are passed through. :param monkeypatch: pytest monkeypatch fixture. """ - c = _make_controller() - - def pds_factory(**kwargs: Any) -> FakePdsClient: - inst = FakePdsClient(**kwargs) - inst.set_patient_details(_make_pds_result("A12345")) - return inst - - def sds_factory(**kwargs: Any) -> FakeSdsClient: - inst = FakeSdsClient(**kwargs) - inst.set_org_details( - "A12345", - SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" - ), - ) - inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) - return inst + pds = pds_factory(ods_code="A12345") + sds_org1 = SdsSetup( + ods_code="A12345", + search_results=SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="ORG1", + search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) - monkeypatch.setattr(controller_module, "PdsClient", pds_factory) - monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) FakeGpProviderClient.response_status_code = 404 FakeGpProviderClient.response_body = b"Not Found" @@ -739,7 +812,7 @@ def sds_factory(**kwargs: Any) -> FakeSdsClient: "X-Downstream": "gp-provider", } - r = c.call_gp_provider(make_request_body("9434765919"), make_headers(), "token-abc") + r = controller.run(make_request_body("9434765919"), make_headers(), "token-abc") assert r.status_code == 404 assert r.data == "Not Found" From a4d95ab9e0792a69f3eea7e25de63a7b9c421e76 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:03:56 +0000 Subject: [PATCH 49/54] Integrate with real GpProviderClient --- gateway-api/src/gateway_api/controller.py | 52 ++--------------------- 1 file changed, 3 insertions(+), 49 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 43ad309e..6a3fbf06 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -6,13 +6,12 @@ import json +from gateway_api.provider_request import GpProviderClient + __all__ = ["json"] # Make mypy happy in tests from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - import requests +from typing import Any from gateway_api.common.common import FlaskResponse, coerce_nhs_number_to_int, json_str from gateway_api.pds_search import PdsClient, PdsSearchResults @@ -98,51 +97,6 @@ def get_org_details(self, ods_code: str) -> SdsSearchResults | None: ) -class GpProviderClient: - """ - Stub GP provider client for obtaining patient records. - - Replace this with the real one once it's implemented. - """ - - SANDBOX_URL = "https://example.invalid/gpprovider" - - def __init__( - self, - provider_endpoint: str, # Obtain from ODS - provider_asid: str, - consumer_asid: str, - ) -> None: - """ - Create a GP provider client. - - :param provider_endpoint: Provider endpoint obtained from SDS. - :param provider_asid: Provider ASID obtained from SDS. - :param consumer_asid: Consumer ASID obtained from SDS. - """ - self.provider_endpoint = provider_endpoint - self.provider_asid = provider_asid - self.consumer_asid = consumer_asid - - def access_structured_record( - self, - trace_id: str, # NOSONAR S1172 (ignore in stub) - body: json_str, # NOSONAR S1172 (ignore in stub) - ) -> requests.Response | None: - """ - Retrieve a patient's structured record from GP provider. - - This stub just returns None, the real thing will be more interesting! - - :param trace_id: Correlation/trace identifier for request tracking. - :param body: Original request body. - :param nhsnumber: NHS number as a string. - :returns: A ``requests.Response`` if the call was made, otherwise ``None``. - """ - # Placeholder implementation - return None - - class Controller: """ Orchestrates calls to PDS -> SDS -> GP provider. From 89ebb4d860799bc6ef25a1ed4e2b80d557ac7a28 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:13:51 +0000 Subject: [PATCH 50/54] Integrate API handler with controller --- gateway-api/src/gateway_api/controller.py | 99 ++++++------------- .../get_structured_record/handler.py | 42 ++------ .../get_structured_record/request.py | 27 ++++- gateway-api/src/gateway_api/pds_search.py | 2 +- .../src/gateway_api/test_controller.py | 28 ------ 5 files changed, 64 insertions(+), 134 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 6a3fbf06..e25a14f3 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -5,15 +5,18 @@ 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 typing import Any -from gateway_api.common.common import FlaskResponse, coerce_nhs_number_to_int, json_str +from gateway_api.common.common import FlaskResponse from gateway_api.pds_search import PdsClient, PdsSearchResults @@ -128,19 +131,12 @@ def __init__( self.timeout = timeout self.gp_provider_client = None - def run( - self, - request_body: json_str, - headers: dict[str, str], - auth_token: str, - ) -> FlaskResponse: + def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: """ Controller entry point - Expects a JSON request body containing an ``"nhs-number"`` field. - Also expects HTTP headers (from Flask) and extracts: - - ``Ods-from`` as the consumer organisation ODS code - - ``X-Request-ID`` as the trace/correlation ID + Expects a GetStructuredRecordRequest instance that contains the header and body + details of the HTTP request received Orchestration steps: 1) Call PDS to obtain the patient's GP (provider) ODS code. @@ -148,42 +144,34 @@ def run( 3) Call SDS using consumer ODS to obtain consumer ASID. 4) Call GP provider to obtain patient records. - :param request_body: Raw JSON request body. - :param headers: HTTP headers from the request. - :param auth_token: Authorization token used for downstream services. + :param request: A GetStructuredRecordRequest instance. :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the outcome. """ - try: - nhs_number = self._get_details_from_body(request_body) - except RequestError as err: - return FlaskResponse( - status_code=err.status_code, - data=str(err), - ) + auth_token = self.get_auth_token() - # Extract consumer ODS from headers - consumer_ods = headers.get("Ods-from", "").strip() - if not consumer_ods: + if not request.ods_from: return FlaskResponse( status_code=400, data='Missing required header "Ods-from"', ) - trace_id = headers.get("X-Request-ID") + trace_id = request.trace_id if trace_id is None: return FlaskResponse( - status_code=400, data="Missing required header: X-Request-ID" + status_code=400, data="Missing required header: Ssp-TraceID" ) try: - provider_ods = self._get_pds_details(auth_token, consumer_ods, nhs_number) + provider_ods = self._get_pds_details( + auth_token, request.ods_from, request.nhs_number + ) except RequestError as err: return FlaskResponse(status_code=err.status_code, data=str(err)) try: consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( - auth_token, consumer_ods, provider_ods + auth_token, request.ods_from, provider_ods ) except RequestError as err: return FlaskResponse(status_code=err.status_code, data=str(err)) @@ -197,7 +185,7 @@ def run( response = self.gp_provider_client.access_structured_record( trace_id=trace_id, - body=request_body, + body=request.request_body, ) # If we get a None from the GP provider, that means that either the service did @@ -209,58 +197,27 @@ def run( headers=dict(response.headers) if response is not None else None, ) - def _get_details_from_body(self, request_body: json_str) -> int: - """ - Parse request JSON and extract the NHS number as an integer. - - :param request_body: JSON request body containing an ``"nhs-number"`` field. - :returns: NHS number as an integer. - :raises RequestError: If the request body is invalid, missing fields, or - contains an invalid NHS number. + def get_auth_token(self) -> str: """ - # Extract NHS number from request body - try: - body: Any = json.loads(request_body) - except (TypeError, json.JSONDecodeError): - raise RequestError( - status_code=400, - message="Request body must be valid JSON", - ) from None - - if not ( - hasattr(body, "__getitem__") and hasattr(body, "get") - ): # Must be a dict-like object - raise RequestError( - status_code=400, - message="JSON structure must be an object/dictionary", - ) from None - - nhs_number_value = body.get("nhs-number") - if nhs_number_value is None: - raise RequestError( - status_code=400, - message='Missing required field "nhs-number" in JSON request body', - ) from None + Retrieve the authorization token. - try: - nhs_number_int = coerce_nhs_number_to_int(nhs_number_value) - except ValueError: - raise RequestError( - status_code=400, - message=f'Could not cast NHS number "{nhs_number_value}" to an integer', - ) from None + This is a placeholder implementation. Replace with actual logic to obtain + the auth token as needed. - return nhs_number_int + :returns: Authorization token as a string. + """ + # Placeholder implementation + return "PLACEHOLDER_AUTH_TOKEN" def _get_pds_details( - self, auth_token: str, consumer_ods: str, nhs_number: int + self, auth_token: str, consumer_ods: str, nhs_number: str ) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. :param auth_token: Authorization token to use for PDS. :param consumer_ods: Consumer organisation ODS code (from request headers). - :param nhs_number: NHS number (already coerced to an integer). + :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 """ diff --git a/gateway-api/src/gateway_api/get_structured_record/handler.py b/gateway-api/src/gateway_api/get_structured_record/handler.py index 15479f28..e938f5e7 100644 --- a/gateway-api/src/gateway_api/get_structured_record/handler.py +++ b/gateway-api/src/gateway_api/get_structured_record/handler.py @@ -1,38 +1,16 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from fhir import Bundle - +from gateway_api.controller import Controller from gateway_api.get_structured_record.request import GetStructuredRecordRequest class GetStructuredRecordHandler: @classmethod def handle(cls, request: GetStructuredRecordRequest) -> None: - bundle: Bundle = { - "resourceType": "Bundle", - "id": "example-patient-bundle", - "type": "collection", - "timestamp": "2026-01-12T10:00:00Z", - "entry": [ - { - "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", - "resource": { - "resourceType": "Patient", - "id": "9999999999", - "identifier": [ - { - "system": "https://fhir.nhs.uk/Id/nhs-number", - "value": "9999999999", - } - ], - "name": [ - {"use": "official", "family": "Doe", "given": ["John"]} - ], - "gender": "male", - "birthDate": "1985-04-12", - }, - } - ], - } - request.set_positive_response(bundle) + try: + controller = Controller() + except Exception as e: + request.set_negative_response(f"Failed to initialize controller: {e}") + return + + flask_response = controller.run(request=request) + + request.set_response_from_flaskresponse(flask_response) diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 141c3cda..8c466671 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -5,6 +5,8 @@ from fhir.operation_outcome import OperationOutcomeIssue from flask.wrappers import Request, Response +from gateway_api.common.common import FlaskResponse + class GetStructuredRecordRequest: INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" @@ -33,6 +35,10 @@ def ods_from(self) -> str: ods_from: str = self._headers["ODS-from"] return ods_from + @property + def request_body(self) -> str: + return json.dumps(self._request_body) + def build_response(self) -> Response: return Response( response=json.dumps(self._response_body), @@ -44,8 +50,8 @@ def set_positive_response(self, bundle: Bundle) -> None: self._status_code = 200 self._response_body = bundle - def set_negative_response(self, error: str) -> None: - self._status_code = 500 + def set_negative_response(self, error: str, status_code: int = 500) -> None: + self._status_code = status_code self._response_body = OperationOutcome( resourceType="OperationOutcome", issue=[ @@ -56,3 +62,20 @@ def set_negative_response(self, error: str) -> None: ) ], ) + + def set_response_from_flaskresponse(self, flask_response: FlaskResponse) -> None: + if flask_response.data: + self._status_code = flask_response.status_code + try: + self._response_body = json.loads(flask_response.data) + except json.JSONDecodeError as err: + self.set_negative_response(f"Failed to decode response body: {err}") + except Exception as err: + self.set_negative_response( + f"Unexpected error decoding response body: {err}" + ) + else: + self.set_negative_response( + error="No response body received", + status_code=flask_response.status_code, + ) diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index 4a17cc62..68bf91e3 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -160,7 +160,7 @@ def _build_headers( def search_patient_by_nhs_number( self, - nhs_number: int, + nhs_number: str, request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 8cd4237a..224ea7d2 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -286,28 +286,6 @@ def __call__(self, **kwargs: Any) -> FakeSdsClient: return self.inst -# def sds_factory( -# org1: SdsSetup | None, org2: SdsSetup | None, **kwargs: Any -# ) -> FakeSdsClient: -# inst = FakeSdsClient(**kwargs) -# if org1 is not None: -# inst.set_org_details( -# org1.ods_code, -# SdsSearchResults( -# asid=org1.search_results.asid, endpoint=org1.search_results.endpoint -# ), -# ) -# -# if org2 is not None: -# inst.set_org_details( -# org2.ods_code, -# SdsSearchResults( -# asid=org2.search_results.asid, endpoint=org2.search_results.endpoint -# ), -# ) -# return inst - - class pds_factory: """ Factory to create a :class:`FakePdsClient` pre-configured with patient details. @@ -334,12 +312,6 @@ def __call__(self, **kwargs: Any) -> FakePdsClient: return self.inst -# def pds_factory(ods_code: str, **kwargs: Any) -> FakePdsClient: -# inst = FakePdsClient(**kwargs) -# inst.set_patient_details(_make_pds_result(ods_code)) -# return inst - - @pytest.fixture def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: """ From 95adf649efafa7edf8ba7491377265484fba07a5 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:07:31 +0000 Subject: [PATCH 51/54] One test passing with updated run signature --- .../src/gateway_api/test_controller.py | 85 ++++++++++++++++--- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 224ea7d2..af9d3c53 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any import pytest +from flask import Flask +from flask import request as flask_request from requests import Response import gateway_api.controller as controller_module @@ -17,6 +19,7 @@ Controller, SdsSearchResults, ) +from gateway_api.get_structured_record.request import GetStructuredRecordRequest if TYPE_CHECKING: from gateway_api.common.common import json_str @@ -341,31 +344,76 @@ def controller() -> Controller: ) +@pytest.fixture +def get_structured_record_request( + request: pytest.FixtureRequest, +) -> GetStructuredRecordRequest: + app = Flask(__name__) + + # Pass two dicts to this fixture that give dicts to add to + # header and body respectively. + header_update, body_update = request.param + + headers = { + "Ssp-TraceID": "3d7f2a6e-0f4e-4af3-9b7b-2a3d5f6a7b8c", + "ODS-from": "CONSUMER", + } + + headers.update(header_update) + + body = { + "resourceType": "Parameters", + "parameter": [ + { + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + } + ], + } + + body.update(body_update) + + with app.test_request_context( + path="/patient/$gpc.getstructuredrecord", + method="POST", + headers=headers, + json=body, + ): + return GetStructuredRecordRequest(flask_request) + + # ----------------------------- # Unit tests # ----------------------------- +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_200_on_success( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ On successful end-to-end call, the controller should return 200 with expected body/headers. """ - # TODO: OK, this works. Repeat it sixteen more times (or get the AI to do it) - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( - ods_code="A12345", + ods_code="PROVIDER", search_results=SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" + asid="asid_PROV", endpoint="https://provider.example/ep" ), ) sds_org2 = SdsSetup( - ods_code="ORG1", - search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), ) sds = sds_factory(org1=sds_org1, org2=sds_org2) @@ -379,16 +427,25 @@ def test_call_gp_provider_returns_200_on_success( "X-Downstream": "gp-provider", } - body = make_request_body("9434765919") - headers = make_headers() - - r = controller.run(body, headers, "token-abc") + r = controller.run(get_structured_record_request) + # Check that response from GP provider was passed through. assert r.status_code == 200 - assert r.data == '{"resourceType":"Bundle"}' - assert r.headers is not None - assert r.headers.get("Content-Type") == "application/fhir+json" - assert r.headers.get("X-Downstream") == "gp-provider" + 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, + } def test_call_gp_provider_returns_404_when_pds_patient_not_found( From 857ae0eedc920dc875aed8a419a985bc4fdbe729 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:16:46 +0000 Subject: [PATCH 52/54] Tests passing --- gateway-api/src/gateway_api/controller.py | 10 +- .../src/gateway_api/test_controller.py | 441 +++++++----------- 2 files changed, 169 insertions(+), 282 deletions(-) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index e25a14f3..358f9d1d 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -150,28 +150,28 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: """ auth_token = self.get_auth_token() - if not request.ods_from: + if not request.ods_from.strip(): return FlaskResponse( status_code=400, data='Missing required header "Ods-from"', ) - trace_id = request.trace_id - if trace_id is None: + trace_id = request.trace_id.strip() + if not trace_id: return FlaskResponse( status_code=400, data="Missing required header: Ssp-TraceID" ) try: provider_ods = self._get_pds_details( - auth_token, request.ods_from, request.nhs_number + auth_token, request.ods_from.strip(), request.nhs_number ) except RequestError as err: return FlaskResponse(status_code=err.status_code, data=str(err)) try: consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( - auth_token, request.ods_from, provider_ods + auth_token, request.ods_from.strip(), provider_ods ) except RequestError as err: return FlaskResponse(status_code=err.status_code, data=str(err)) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index af9d3c53..1e320509 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -4,7 +4,6 @@ from __future__ import annotations -import json as std_json from dataclasses import dataclass from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -28,34 +27,24 @@ # ----------------------------- # Helpers for request test data # ----------------------------- -def make_request_body(nhs_number: str = "9434765919") -> json_str: - """ - Create a JSON request body string containing an ``"nhs-number"`` field. - - :param nhs_number: NHS number to embed in the request body. - :returns: JSON string payload suitable for - :meth:`gateway_api.controller.Controller.call_gp_provider`. - """ - # Controller expects a JSON string containing an "nhs-number" field. - return std_json.dumps({"nhs-number": nhs_number}) - - -def make_headers( - ods_from: str = "ORG1", - trace_id: str = "trace-123", -) -> dict[str, str]: - """ - Create the minimum required headers for controller entry points. - - :param ods_from: Value for the ``Ods-from`` header (consumer ODS code). - :param trace_id: Value for the ``X-Request-ID`` header (trace/correlation ID). - :returns: Header dictionary suitable for - :meth:`gateway_api.controller.Controller.call_gp_provider`. - """ - # Controller expects these headers: - # - Ods-from (consumer ODS) - # - X-Request-ID (trace id) - return {"Ods-from": ods_from, "X-Request-ID": trace_id} +# def make_request_body(nhs_number: str = "9434765919") -> json_str: +# """ +# Legacy helper (previous controller signature) retained for backwards compatibility +# with older tests. New tests use GetStructuredRecordRequest fixture. +# """ +# return std_json.dumps({"nhs-number": nhs_number}) + + +# TODO: Remove this and the one above +# def make_headers( +# ods_from: str = "ORG1", +# trace_id: str = "trace-123", +# ) -> dict[str, str]: +# """ +# Legacy helper (previous controller signature) retained for backwards compatibility +# with older tests. New tests use GetStructuredRecordRequest fixture. +# """ +# return {"Ods-from": ods_from, "X-Request-ID": trace_id} # ----------------------------- @@ -70,7 +59,6 @@ def _make_pds_result(gp_ods_code: str | None) -> Any: :param gp_ods_code: Provider ODS code to expose on the result. :returns: An object with a ``gp_ods_code`` attribute. """ - # We only need .gp_ods_code for controller logic. return SimpleNamespace(gp_ods_code=gp_ods_code) @@ -85,31 +73,13 @@ class FakePdsClient: last_init: dict[str, Any] | None = None def __init__(self, **kwargs: Any) -> None: - """ - Capture constructor kwargs for later assertions. - - :param kwargs: Arbitrary keyword arguments passed by the controller. - """ - # Controller constructs PdsClient with kwargs; capture for assertions. FakePdsClient.last_init = dict(kwargs) self._patient_details: Any | None = None def set_patient_details(self, value: Any) -> None: - """ - Configure the value returned by ``search_patient_by_nhs_number``. - - :param value: Result-like object to return (or ``None`` to simulate not found). - """ - # Keep call sites explicit and "correct": pass a PDS-result-like object. self._patient_details = value def search_patient_by_nhs_number(self, nhs_number: int) -> Any | None: - """ - Return the configured patient details. - - :param nhs_number: NHS number requested (not used by the fake). - :returns: Configured patient details or ``None``. - """ return self._patient_details @@ -129,13 +99,6 @@ def __init__( base_url: str = "test_url", timeout: int = 10, ) -> None: - """ - Capture constructor arguments and initialise storage for org details. - - :param auth_token: Auth token passed by the controller. - :param base_url: Base URL passed by the controller. - :param timeout: Timeout passed by the controller. - """ FakeSdsClient.last_init = { "auth_token": auth_token, "base_url": base_url, @@ -149,21 +112,9 @@ def __init__( def set_org_details( self, ods_code: str, org_details: SdsSearchResults | None ) -> None: - """ - Configure the SDS lookup result for a given ODS code. - - :param ods_code: ODS code key. - :param org_details: SDS details or ``None`` to simulate not found. - """ self._org_details_by_ods[ods_code] = org_details def get_org_details(self, ods_code: str) -> SdsSearchResults | None: - """ - Retrieve configured org details for a given ODS code. - - :param ods_code: ODS code to look up. - :returns: Configured SDS details or ``None``. - """ return self._org_details_by_ods.get(ods_code) @@ -187,13 +138,6 @@ class FakeGpProviderClient: def __init__( self, provider_endpoint: str, provider_asid: str, consumer_asid: str ) -> None: - """ - Capture constructor arguments for later assertions. - - :param provider_endpoint: Provider endpoint passed by the controller. - :param provider_asid: Provider ASID passed by the controller. - :param consumer_asid: Consumer ASID passed by the controller. - """ FakeGpProviderClient.last_init = { "provider_endpoint": provider_endpoint, "provider_asid": provider_asid, @@ -205,14 +149,6 @@ def access_structured_record( trace_id: str, body: json_str, ) -> Response | None: - """ - Return either a configured :class:`requests.Response` or ``None``. - - :param trace_id: Trace identifier from request headers. - :param body: JSON request body. - :returns: A configured :class:`requests.Response`, or ``None`` if - ``return_none`` is set. - """ FakeGpProviderClient.last_call = {"trace_id": trace_id, "body": body} if FakeGpProviderClient.return_none: @@ -241,8 +177,6 @@ class sds_factory: """ Factory to create a :class:`FakeSdsClient` pre-configured with up to two organisations. - - Used in tests to set up SDS responses for provider and consumer orgs. """ def __init__( @@ -250,24 +184,10 @@ def __init__( org1: SdsSetup | None = None, org2: SdsSetup | None = None, ) -> None: - """ - Construct the fake SDS client and configure org details. - - :param org1: First organisation to configure, or ``None``. - :param org2: Second organisation to configure, or ``None``. - :param kwargs: Additional keyword arguments passed to - :class:`FakeSdsClient`. - """ self.org1 = org1 self.org2 = org2 - # TODO: Fix factory class docstrings def __call__(self, **kwargs: Any) -> FakeSdsClient: - """ - Return the configured fake SDS client. - - :returns: Configured :class:`FakeSdsClient` instance. - """ self.inst = FakeSdsClient(**kwargs) if self.org1 is not None: self.inst.set_org_details( @@ -295,21 +215,9 @@ class pds_factory: """ def __init__(self, ods_code: str | None) -> None: - """ - Construct the fake PDS client and configure patient details. - - :param ods_code: Provider ODS code to set on the patient details. - :param kwargs: Additional keyword arguments passed to - :class:`FakePdsClient`. - """ self.ods_code = ods_code def __call__(self, **kwargs: Any) -> FakePdsClient: - """ - Return the configured fake PDS client. - - :returns: Configured :class:`FakePdsClient` instance. - """ self.inst = FakePdsClient(**kwargs) self.inst.set_patient_details(_make_pds_result(self.ods_code)) return self.inst @@ -319,11 +227,7 @@ def __call__(self, **kwargs: Any) -> FakePdsClient: def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: """ Patch controller dependencies to use test fakes. - Pass as a fixture to give any given test a clean set of patched dependencies. - - :param monkeypatch: pytest monkeypatch fixture. """ - # Patch dependency classes in the *module* namespace that Controller uses. monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) @@ -333,8 +237,6 @@ def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: def controller() -> Controller: """ Construct a controller instance configured for unit tests. - - :returns: Controller instance. """ return Controller( pds_base_url="https://pds.example", @@ -448,85 +350,92 @@ def test_call_gp_provider_returns_200_on_success( } +@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, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If PDS returns no patient record, the controller should return 404. """ - - # No users added to the PDS stub, so a request for this user will get nothing - # back from "PDS". The controller should return 404 with the given error. - body = make_request_body("9434765919") - headers = make_headers() - - r = controller.run(body, headers, "token-abc") + # FakePdsClient defaults to returning None => RequestError => 404 + r = controller.run(get_structured_record_request) assert r.status_code == 404 assert "No PDS patient found for NHS number" in (r.data or "") +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_404_when_gp_ods_code_missing( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If PDS returns a patient without a provider (GP) ODS code, return 404. - - :param monkeypatch: pytest monkeypatch fixture. """ pds = pds_factory(ods_code="") monkeypatch.setattr(controller_module, "PdsClient", pds) - body = make_request_body("9434765919") - headers = make_headers() - - r = controller.run(body, headers, "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 404 assert "did not contain a current provider ODS code" in (r.data or "") +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If SDS returns no provider org details, the controller should return 404. - - :param monkeypatch: pytest monkeypatch fixture. """ - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds = sds_factory() monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - body = make_request_body("9434765919") - headers = make_headers() - - r = controller.run(body, headers, "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 404 - assert r.data == "No SDS org found for provider ODS code A12345" + assert r.data == "No SDS org found for provider ODS code PROVIDER" +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If provider ASID is blank/whitespace, the controller should return 404. - - :param monkeypatch: pytest monkeypatch fixture. """ - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( - ods_code="A12345", + ods_code="PROVIDER", search_results=SdsSearchResults( asid=" ", endpoint="https://provider.example/ep" ), @@ -536,35 +445,36 @@ def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - body = make_request_body("9434765919") - headers = make_headers() - - r = controller.run(body, headers, "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 404 assert "did not contain a current ASID" in (r.data or "") +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_502_when_gp_provider_returns_none( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If GP provider returns no response object, the controller should return 502. - - :param monkeypatch: pytest monkeypatch fixture. """ - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( - ods_code="A12345", + ods_code="PROVIDER", search_results=SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" + asid="asid_PROV", endpoint="https://provider.example/ep" ), ) sds_org2 = SdsSetup( - ods_code="ORG1", - search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), ) sds = sds_factory(org1=sds_org1, org2=sds_org2) @@ -573,192 +483,165 @@ def test_call_gp_provider_returns_502_when_gp_provider_returns_none( FakeGpProviderClient.return_none = True - body = make_request_body("9434765919") - headers = make_headers() - - r = controller.run(body, headers, "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 502 assert r.data == "GP provider service error" assert r.headers is None # reset for other tests + # TODO: Do we need this? Really? FakeGpProviderClient.return_none = False +@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, + monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ Validate that the controller constructs the PDS client with expected kwargs. """ - - body = make_request_body("9434765919") - headers = make_headers(ods_from="ORG1", trace_id="trace-123") - - _ = controller.run(body, headers, "token-abc") # will stop at PDS None => 404 + _ = 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"] == "token-abc" # noqa: S105 - assert FakePdsClient.last_init["end_user_org_ods"] == "ORG1" + 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 -def test_call_gp_provider_returns_400_when_request_body_not_valid_json( - patched_deps: Any, - controller: Controller, -) -> None: - """ - If the request body is invalid JSON, the controller should return 400. - """ - headers = make_headers() - - r = controller.run("{", headers, "token-abc") - - assert r.status_code == 400 - assert r.data == "Request body must be valid JSON" - - -def test_call_gp_provider_returns_400_when_request_body_is_not_an_object( - patched_deps: Any, - controller: Controller, -) -> None: - """ - If the request body JSON is not an expected type of object (e.g., list), return 400. - """ - headers = make_headers() - - r = controller.run('["9434765919"]', headers, "token-abc") - - assert r.status_code == 400 - assert r.data == "JSON structure must be an object/dictionary" - - -def test_call_gp_provider_returns_400_when_request_body_missing_nhs_number( - patched_deps: Any, - controller: Controller, -) -> None: - """ - If the request body omits ``"nhs-number"``, return 400. - """ - headers = make_headers() - - r = controller.run("{}", headers, "token-abc") - - assert r.status_code == 400 - assert r.data == 'Missing required field "nhs-number" in JSON request body' - - -def test_call_gp_provider_returns_400_when_nhs_number_not_coercible( +@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, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ - If ``"nhs-number"`` cannot be coerced/validated, return 400. + If PDS returns no patient record, error message should include NHS number parsed + from the FHIR Parameters request body. """ - headers = make_headers() - - r = controller.run(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") + r = controller.run(get_structured_record_request) - assert r.status_code == 400 - assert r.data == 'Could not cast NHS number "ABC" to an integer' + assert r.status_code == 404 + assert r.data == "No PDS patient found for NHS number 1234567890" -def test_call_gp_provider_returns_400_when_missing_ods_from_header( +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": ""}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_400_when_ods_from_is_empty( patched_deps: Any, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ - If the required ``Ods-from`` header is missing, return 400. + If the required ``ODS-from`` header is empty/falsy, return 400. """ - body = make_request_body("9434765919") - - r = controller.run(body, {"X-Request-ID": "trace-123"}, "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 400 assert r.data == 'Missing required header "Ods-from"' -def test_call_gp_provider_returns_400_when_ods_from_is_whitespace( +@pytest.mark.parametrize( + "get_structured_record_request", + [({"Ssp-TraceID": ""}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_passes_empty_trace_id_through_to_gp_provider( patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ - If the ``Ods-from`` header is whitespace-only, return 400. + If Ssp-TraceID is present but empty, we get a 400 """ - body = make_request_body("9434765919") - - r = controller.run( - body, {"Ods-from": " ", "X-Request-ID": "trace-123"}, "token-abc" + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) - assert r.status_code == 400 - assert r.data == 'Missing required header "Ods-from"' - - -def test_call_gp_provider_returns_400_when_missing_x_request_id( - patched_deps: Any, - controller: Controller, -) -> None: - """ - If the required ``X-Request-ID`` header is missing, return 400. - """ - body = make_request_body("9434765919") + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(body, {"Ods-from": "ORG1"}, "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 400 - assert r.data == "Missing required header: X-Request-ID" + assert "Missing required header: Ssp-TraceID" in (r.data or "") +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If provider endpoint is blank/whitespace, the controller should return 404. - - :param monkeypatch: pytest monkeypatch fixture. """ - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( - ods_code="A12345", - search_results=SdsSearchResults(asid="asid_A12345", endpoint=" "), - ) - sds_org2 = SdsSetup( - ods_code="ORG1", - search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ods_code="PROVIDER", + search_results=SdsSearchResults(asid="asid_PROV", endpoint=" "), ) - sds = sds_factory(org1=sds_org1, org2=sds_org2) + sds = sds_factory(org1=sds_org1) monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run(make_request_body("9434765919"), make_headers(), "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 404 assert "did not contain a current endpoint" in (r.data or "") +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If SDS returns no consumer org details, the controller should return 404. - - :param monkeypatch: pytest monkeypatch fixture. """ - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( - ods_code="A12345", + ods_code="PROVIDER", search_results=SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" + asid="asid_PROV", endpoint="https://provider.example/ep" ), ) sds = sds_factory(org1=sds_org1) @@ -766,33 +649,35 @@ def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run( - make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" - ) + r = controller.run(get_structured_record_request) assert r.status_code == 404 - assert r.data == "No SDS org found for consumer ODS code ORG1" + assert r.data == "No SDS org found for consumer ODS code CONSUMER" +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ If consumer ASID is blank/whitespace, the controller should return 404. - - :param monkeypatch: pytest monkeypatch fixture. """ - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( - ods_code="A12345", + ods_code="PROVIDER", search_results=SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" + asid="asid_PROV", endpoint="https://provider.example/ep" ), ) sds_org2 = SdsSetup( - ods_code="ORG1", + ods_code="CONSUMER", search_results=SdsSearchResults(asid=" ", endpoint=None), ) sds = sds_factory(org1=sds_org1, org2=sds_org2) @@ -800,34 +685,36 @@ def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - r = controller.run( - make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" - ) + r = controller.run(get_structured_record_request) assert r.status_code == 404 assert "did not contain a current ASID" in (r.data or "") +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) def test_call_gp_provider_passthroughs_non_200_gp_provider_response( patched_deps: Any, monkeypatch: pytest.MonkeyPatch, controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, ) -> None: """ Validate that non-200 responses from GP provider are passed through. - - :param monkeypatch: pytest monkeypatch fixture. """ - pds = pds_factory(ods_code="A12345") + pds = pds_factory(ods_code="PROVIDER") sds_org1 = SdsSetup( - ods_code="A12345", + ods_code="PROVIDER", search_results=SdsSearchResults( - asid="asid_A12345", endpoint="https://provider.example/ep" + asid="asid_PROV", endpoint="https://provider.example/ep" ), ) sds_org2 = SdsSetup( - ods_code="ORG1", - search_results=SdsSearchResults(asid="asid_ORG1", endpoint=None), + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), ) sds = sds_factory(org1=sds_org1, org2=sds_org2) @@ -841,7 +728,7 @@ def test_call_gp_provider_passthroughs_non_200_gp_provider_response( "X-Downstream": "gp-provider", } - r = controller.run(make_request_body("9434765919"), make_headers(), "token-abc") + r = controller.run(get_structured_record_request) assert r.status_code == 404 assert r.data == "Not Found" From 32bb69ca00fe88a5ec3812da6407f85c03da7f21 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:18:50 +0000 Subject: [PATCH 53/54] Tidy up todos --- .../src/gateway_api/test_controller.py | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 1e320509..ad5f8010 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -24,29 +24,6 @@ from gateway_api.common.common import json_str -# ----------------------------- -# Helpers for request test data -# ----------------------------- -# def make_request_body(nhs_number: str = "9434765919") -> json_str: -# """ -# Legacy helper (previous controller signature) retained for backwards compatibility -# with older tests. New tests use GetStructuredRecordRequest fixture. -# """ -# return std_json.dumps({"nhs-number": nhs_number}) - - -# TODO: Remove this and the one above -# def make_headers( -# ods_from: str = "ORG1", -# trace_id: str = "trace-123", -# ) -> dict[str, str]: -# """ -# Legacy helper (previous controller signature) retained for backwards compatibility -# with older tests. New tests use GetStructuredRecordRequest fixture. -# """ -# return {"Ods-from": ods_from, "X-Request-ID": trace_id} - - # ----------------------------- # Fake downstream dependencies # ----------------------------- @@ -490,7 +467,6 @@ def test_call_gp_provider_returns_502_when_gp_provider_returns_none( assert r.headers is None # reset for other tests - # TODO: Do we need this? Really? FakeGpProviderClient.return_none = False From 5e5ec9777f7abe392a112a333b8a6d0e48d1685a Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:30:33 +0000 Subject: [PATCH 54/54] Make cleanup more robust --- gateway-api/src/gateway_api/test_controller.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index ad5f8010..bb9efb0b 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -21,6 +21,8 @@ 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 @@ -223,6 +225,16 @@ def controller() -> Controller: ) +@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, @@ -438,6 +450,7 @@ def test_call_gp_provider_returns_502_when_gp_provider_returns_none( monkeypatch: pytest.MonkeyPatch, controller: Controller, get_structured_record_request: GetStructuredRecordRequest, + gp_provider_returns_none: None, ) -> None: """ If GP provider returns no response object, the controller should return 502. @@ -458,17 +471,12 @@ def test_call_gp_provider_returns_502_when_gp_provider_returns_none( monkeypatch.setattr(controller_module, "PdsClient", pds) monkeypatch.setattr(controller_module, "SdsClient", sds) - FakeGpProviderClient.return_none = True - r = controller.run(get_structured_record_request) assert r.status_code == 502 assert r.data == "GP provider service error" assert r.headers is None - # reset for other tests - FakeGpProviderClient.return_none = False - @pytest.mark.parametrize( "get_structured_record_request",