diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml new file mode 100644 index 00000000..0f6d6d20 --- /dev/null +++ b/.github/actions/start-app/action.yaml @@ -0,0 +1,50 @@ +name: "Start local app" +description: "Start Flask app that will handle requests" +inputs: + deploy-command: + description: "Command to start app" + required: false + default: "make deploy" + health-path: + description: "Health check path" + required: false + default: "/health" + max-seconds: + description: "Maximum seconds to wait for readiness" + required: false + default: "60" + python-version: + description: "Python version to install" + required: true +runs: + using: "composite" + steps: + - name: "Start app" + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python-version }} + run: | + set -euo pipefail + echo "Starting app: '${{ inputs.deploy-command }}'" + nohup ${{ inputs.deploy-command }} > /tmp/app.log 2>&1 & + echo $! > /tmp/app.pid + echo "PID: $(cat /tmp/app.pid)" + - name: "Wait for app to be ready" + shell: bash + run: | + set -euo pipefail + BASE_URL="${BASE_URL:-http://localhost:5000}" + HEALTH_URL="${BASE_URL}${{ inputs.health-path }}" + MAX="${{ inputs.max-seconds }}" + echo "Waiting for app at ${HEALTH_URL} (max ${MAX}s)..." + for i in $(seq 1 "${MAX}"); do + if curl -sSf -X GET "${HEALTH_URL}" >/dev/null; then + echo "App is ready" + exit 0 + fi + sleep 1 + done + echo "App did not become ready in time" + echo "---- recent app log ----" + tail -n 200 /tmp/app.log || true + exit 1 diff --git a/.github/actions/start-local-lambda/action.yaml b/.github/actions/start-local-lambda/action.yaml deleted file mode 100644 index 49d77405..00000000 --- a/.github/actions/start-local-lambda/action.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: "Start local Lambda environment" -description: "Start a local AWS Lambda environment for testing" -inputs: - deploy-command: - description: "Command to start local Lambda" - required: false - default: "make deploy" - health-path: - description: "Health probe path to POST" - required: false - default: "/2015-03-31/functions/function/invocations" - max-seconds: - description: "Maximum seconds to wait for readiness" - required: false - default: "60" -python-version: - description: "Python version to install" - required: true -runs: - using: "composite" - steps: - - name: "Start local Lambda environment" - shell: bash - env: - PYTHON_VERSION: ${{ inputs.python-version }} - run: | - set -euo pipefail - echo "Starting local Lambda: '${{ inputs.deploy-command }}'" - nohup ${{ inputs.deploy-command }} >/tmp/lambda.log 2>&1 & - echo $! > /tmp/lambda.pid - echo "PID: $(cat /tmp/lambda.pid)" - - name: "Wait for Lambda to be ready" - shell: bash - run: | - set -euo pipefail - BASE_URL="${BASE_URL:-http://localhost:5000}" - HEALTH_URL="${BASE_URL}${{ inputs.health-path }}" - MAX="${{ inputs.max-seconds }}" - echo "Waiting for Lambda at ${HEALTH_URL} (max ${MAX}s)..." - for i in $(seq 1 "${MAX}"); do - if curl -sSf -X POST "${HEALTH_URL}" -d '{}' >/dev/null; then - echo "Lambda is ready" - exit 0 - fi - sleep 1 - done - echo "Lambda did not become ready in time" - echo "---- recent lambda log ----" - tail -n 200 /tmp/lambda.log || true - exit 1 diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 66e4a240..32a5fd2b 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -68,8 +68,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run contract tests" @@ -98,8 +98,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run schema validation tests" @@ -128,8 +128,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run integration test" @@ -158,8 +158,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} max-seconds: 90 diff --git a/Makefile b/Makefile index 3e634518..40b5f7fe 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ IMAGE_REPOSITORY := ${ECR_URL} endif IMAGE_NAME := ${IMAGE_REPOSITORY}:${IMAGE_TAG} +COMMIT_VERSION := $(shell git rev-parse --short HEAD) +BUILD_DATE := $(shell date -u +"%Y%m%d") # ============================================================================== # Example CI/CD targets are: dependencies, build, publish, deploy, clean, etc. @@ -34,9 +36,8 @@ build-gateway-api: dependencies @poetry run mypy --no-namespace-packages . @echo "Packaging dependencies..." @poetry build --format=wheel - @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_1_x86_64 --only-binary=:all: + @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all: # Copy main file separately as it is not included within the package. - @cp lambda_handler.py ./target/gateway-api/ @rm -rf ../infrastructure/images/gateway-api/resources/build/ @mkdir ../infrastructure/images/gateway-api/resources/build/ @cp -r ./target/gateway-api ../infrastructure/images/gateway-api/resources/build/ @@ -46,7 +47,7 @@ build-gateway-api: dependencies .PHONY: build build: build-gateway-api # Build the project artefact @Pipeline @echo "Building Docker x86 image using Docker. Utilising python version: ${PYTHON_VERSION} ..." - @$(docker) buildx build --platform linux/amd64 --load --provenance=false --build-arg PYTHON_VERSION=${PYTHON_VERSION} -t ${IMAGE_NAME} infrastructure/images/gateway-api + @$(docker) buildx build --platform linux/amd64 --load --provenance=false --build-arg PYTHON_VERSION=${PYTHON_VERSION} --build-arg COMMIT_VERSION=${COMMIT_VERSION} --build-arg BUILD_DATE=${BUILD_DATE} -t ${IMAGE_NAME} infrastructure/images/gateway-api @echo "Docker image '${IMAGE_NAME}' built successfully!" publish: # Publish the project artefact @Pipeline diff --git a/gateway-api/lambda_handler.py b/gateway-api/lambda_handler.py deleted file mode 100644 index 554f6e28..00000000 --- a/gateway-api/lambda_handler.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import TypedDict - -from gateway_api.handler import User, greet - - -class LambdaResponse[T](TypedDict): - """A lambda response including a body with a generic type.""" - - statusCode: int - headers: dict[str, str] - body: T - - -def _with_default_headers[T](status_code: int, body: T) -> LambdaResponse[T]: - return { - "statusCode": status_code, - "headers": {"Content-Type": "application/json"}, - "body": body, - } - - -def handler(event: dict[str, str], context: dict[str, str]) -> LambdaResponse[str]: - print(f"Received event: {event}") - - if "payload" not in event: - return _with_default_headers(status_code=400, body="Name is required") - - name = event["payload"] - if not name: - return _with_default_headers(status_code=400, body="Name cannot be empty") - user = User(name=name) - - try: - return _with_default_headers(status_code=200, body=f"{greet(user)}") - except ValueError: - return _with_default_headers( - status_code=404, body=f"Provided name cannot be found. name={name}" - ) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index b6799f7c..96b3f30e 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -9,283 +9,154 @@ servers: - url: http://localhost:5000 description: Local development server paths: - /2015-03-31/functions/function/invocations: + /patient/$gpc.getstructuredrecord: post: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld + summary: Get structured record + description: Returns a FHIR Bundle containing patient structured record + operationId: getStructuredRecord + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: [application/fhir+json] + required: true requestBody: - required: false + required: true content: - application/json: + application/fhir+json: schema: type: object properties: - payload: + resourceType: type: string - description: The payload to be processed + example: "Parameters" + parameter: + type: array + items: + type: object + properties: + name: + type: string + example: "patientNHSNumber" + valueIdentifier: + type: object + properties: + system: + type: string + example: "https://fhir.nhs.uk/Id/nhs-number" + value: + type: string + example: "9999999999" responses: '200': description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - get: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: + parameters: + - in: header + name: Content-Type schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response + type: string + enum: [application/fhir+json] + required: true content: - text/plain: + application/fhir+json: schema: type: object properties: - status_code: + statusCode: type: integer description: Status code of the interaction + example: 200 + headers: + type: object + properties: + Content-Type: + type: string + example: "application/fhir+json" body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - put: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - patch: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed + type: object + description: FHIR Bundle containing patient data + properties: + resourceType: + type: string + example: "Bundle" + id: + type: string + example: "example-patient-bundle" + type: + type: string + example: "collection" + timestamp: + type: string + format: date-time + example: "2026-01-12T10:00:00Z" + entry: + type: array + items: + type: object + properties: + fullUrl: + type: string + example: "urn:uuid:123e4567-e89b-12d3-a456-426614174000" + resource: + type: object + properties: + resourceType: + type: string + example: "Patient" + id: + type: string + example: "9999999999" + identifier: + type: array + items: + type: object + properties: + system: + type: string + example: "https://fhir.nhs.uk/Id/nhs-number" + value: + type: string + example: "9999999999" + name: + type: array + items: + type: object + properties: + use: + type: string + example: "official" + family: + type: string + example: "Doe" + given: + type: array + items: + type: string + example: ["John"] + gender: + type: string + example: "male" + birthDate: + type: string + format: date + example: "1985-04-12" + /health: + get: + summary: Health check + description: Returns the health status of the API + operationId: healthCheck responses: '200': - description: Successful response + description: Service is healthy content: - text/plain: + application/json: schema: type: object properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: + status: type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred + example: "healthy" + required: + - status - '404': - description: Route not found - content: - text/html: - schema: - type: string - delete: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - trace: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 338577d4..8ec2ddde 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -63,6 +63,18 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -301,7 +313,7 @@ version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -332,11 +344,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\""} [[package]] name = "coverage" @@ -443,6 +456,30 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "flask" +version = "3.1.2" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "fqdn" version = "1.5.1" @@ -668,13 +705,25 @@ files = [ [package.dependencies] arrow = ">=0.15.0" +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -808,7 +857,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -1981,6 +2030,62 @@ files = [ requests = "*" starlette = ">=0.20.1" +[[package]] +name = "types-click" +version = "7.1.8" +description = "Typing stubs for click" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-click-7.1.8.tar.gz", hash = "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"}, + {file = "types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81"}, +] + +[[package]] +name = "types-flask" +version = "1.1.6" +description = "Typing stubs for Flask" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Flask-1.1.6.tar.gz", hash = "sha256:aac777b3abfff9436e6b01f6d08171cf23ea6e5be71cbf773aaabb1c5763e9cf"}, + {file = "types_Flask-1.1.6-py3-none-any.whl", hash = "sha256:6ab8a9a5e258b76539d652f6341408867298550b19b81f0e41e916825fc39087"}, +] + +[package.dependencies] +types-click = "*" +types-Jinja2 = "*" +types-Werkzeug = "*" + +[[package]] +name = "types-jinja2" +version = "2.11.9" +description = "Typing stubs for Jinja2" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Jinja2-2.11.9.tar.gz", hash = "sha256:dbdc74a40aba7aed520b7e4d89e8f0fe4286518494208b35123bcf084d4b8c81"}, + {file = "types_Jinja2-2.11.9-py3-none-any.whl", hash = "sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2"}, +] + +[package.dependencies] +types-MarkupSafe = "*" + +[[package]] +name = "types-markupsafe" +version = "1.1.10" +description = "Typing stubs for MarkupSafe" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-MarkupSafe-1.1.10.tar.gz", hash = "sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1"}, + {file = "types_MarkupSafe-1.1.10-py3-none-any.whl", hash = "sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5"}, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -2008,6 +2113,18 @@ files = [ [package.dependencies] urllib3 = ">=2" +[[package]] +name = "types-werkzeug" +version = "1.0.9" +description = "Typing stubs for Werkzeug" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Werkzeug-1.0.9.tar.gz", hash = "sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c"}, + {file = "types_Werkzeug-1.0.9-py3-none-any.whl", hash = "sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -2083,7 +2200,7 @@ version = "3.1.5" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, @@ -2243,4 +2360,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "67e8839de72625c8f7c4d42aea6ea55afaf9f738aef2267bb4dac2f83a389f8e" +content-hash = "30cdb09db37902c7051aa190c1e4c374dbfa6a14ca0c69131c0295ee33e7338f" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 2242551f..fa79be03 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -10,10 +10,13 @@ requires-python = ">3.13,<4.0.0" [tool.poetry.dependencies] clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } +flask = "^3.1.2" +types-flask = "^1.1.6" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, - {include = "stubs", from = "stubs"}] + {include = "stubs", from = "stubs"}, + {include = "fhir", from = "src"}] [tool.coverage.run] relative_files = true diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py new file mode 100644 index 00000000..4ad915ee --- /dev/null +++ b/gateway-api/src/fhir/__init__.py @@ -0,0 +1,20 @@ +"""FHIR data types and resources.""" + +from fhir.bundle import Bundle, BundleEntry +from fhir.human_name import HumanName +from fhir.identifier import Identifier +from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue +from fhir.parameters import Parameter, Parameters +from fhir.patient import Patient + +__all__ = [ + "Bundle", + "BundleEntry", + "HumanName", + "Identifier", + "OperationOutcome", + "OperationOutcomeIssue", + "Parameter", + "Parameters", + "Patient", +] diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py new file mode 100644 index 00000000..5fbc9a3b --- /dev/null +++ b/gateway-api/src/fhir/bundle.py @@ -0,0 +1,18 @@ +"""FHIR Bundle resource.""" + +from typing import TypedDict + +from fhir.patient import Patient + + +class BundleEntry(TypedDict): + fullUrl: str + resource: Patient + + +class Bundle(TypedDict): + resourceType: str + id: str + type: str + timestamp: str + entry: list[BundleEntry] diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py new file mode 100644 index 00000000..2a73deb0 --- /dev/null +++ b/gateway-api/src/fhir/human_name.py @@ -0,0 +1,9 @@ +"""FHIR HumanName type.""" + +from typing import TypedDict + + +class HumanName(TypedDict): + use: str + family: str + given: list[str] diff --git a/gateway-api/src/fhir/identifier.py b/gateway-api/src/fhir/identifier.py new file mode 100644 index 00000000..4e59908d --- /dev/null +++ b/gateway-api/src/fhir/identifier.py @@ -0,0 +1,8 @@ +"""FHIR Identifier type.""" + +from typing import TypedDict + + +class Identifier(TypedDict): + system: str + value: str diff --git a/gateway-api/src/fhir/operation_outcome.py b/gateway-api/src/fhir/operation_outcome.py new file mode 100644 index 00000000..d25765f5 --- /dev/null +++ b/gateway-api/src/fhir/operation_outcome.py @@ -0,0 +1,14 @@ +"""FHIR OperationOutcome resource.""" + +from typing import TypedDict + + +class OperationOutcomeIssue(TypedDict): + severity: str + code: str + diagnostics: str + + +class OperationOutcome(TypedDict): + resourceType: str + issue: list[OperationOutcomeIssue] diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py new file mode 100644 index 00000000..30b7cce8 --- /dev/null +++ b/gateway-api/src/fhir/parameters.py @@ -0,0 +1,15 @@ +"""FHIR Parameters resource.""" + +from typing import TypedDict + +from fhir.identifier import Identifier + + +class Parameter(TypedDict): + name: str + valueIdentifier: Identifier + + +class Parameters(TypedDict): + resourceType: str + parameter: list[Parameter] diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py new file mode 100644 index 00000000..33d0ce41 --- /dev/null +++ b/gateway-api/src/fhir/patient.py @@ -0,0 +1,15 @@ +"""FHIR Patient resource.""" + +from typing import TypedDict + +from fhir.human_name import HumanName +from fhir.identifier import Identifier + + +class Patient(TypedDict): + resourceType: str + id: str + identifier: list[Identifier] + name: list[HumanName] + gender: str + birthDate: str diff --git a/gateway-api/src/fhir/py.typed b/gateway-api/src/fhir/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py new file mode 100644 index 00000000..8174fe17 --- /dev/null +++ b/gateway-api/src/gateway_api/app.py @@ -0,0 +1,62 @@ +import os +from typing import TypedDict + +from flask import Flask, request +from flask.wrappers import Response + +from gateway_api.get_structured_record import ( + GetStructuredRecordHandler, + GetStructuredRecordRequest, +) + +app = Flask(__name__) + + +class HealthCheckResponse(TypedDict): + status: str + version: str + + +def get_app_host() -> str: + host = os.getenv("FLASK_HOST") + if host is None: + raise RuntimeError("FLASK_HOST environment variable is not set.") + print(f"Starting Flask app on host: {host}") + return host + + +def get_app_port() -> int: + port = os.getenv("FLASK_PORT") + if port is None: + raise RuntimeError("FLASK_PORT environment variable is not set.") + print(f"Starting Flask app on port: {port}") + return int(port) + + +@app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) +def get_structured_record() -> Response: + try: + get_structured_record_request = GetStructuredRecordRequest(request) + GetStructuredRecordHandler.handle(get_structured_record_request) + except Exception as e: + get_structured_record_request.set_negative_response(str(e)) + return get_structured_record_request.build_response() + + +@app.route("/health", methods=["GET"]) +def health_check() -> HealthCheckResponse: + """Health check endpoint.""" + version: str = "unkonwn" + + commit_version: str | None = os.getenv("COMMIT_VERSION") + build_date: str | None = os.getenv("BUILD_DATE") + if commit_version and build_date: + version = f"{build_date}.{commit_version}" + + return {"status": "healthy", "version": version} + + +if __name__ == "__main__": + host, port = get_app_host(), get_app_port() + print(f"Version: {os.getenv('COMMIT_VERSION')}") + app.run(host=host, port=port) diff --git a/gateway-api/src/gateway_api/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..8382bc6f --- /dev/null +++ b/gateway-api/src/gateway_api/common/common.py @@ -0,0 +1,96 @@ +""" +Shared lightweight types and helpers used across the gateway API. +""" + +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. +type json_str = str + + +@dataclass +class FlaskResponse: + """ + Lightweight response container returned by controller entry points. + + This mirrors the minimal set of fields used by the surrounding web framework. + + :param status_code: HTTP status code for the response (e.g., 200, 400, 404). + :param data: Response body as text, if any. + :param headers: Response headers, if any. + """ + + status_code: int + data: str | None = None + headers: dict[str, str] | None = None + + +def validate_nhs_number(value: str | int) -> bool: + """ + Validate an NHS number using the NHS modulus-11 check digit algorithm. + + The input may be a string or integer. Any non-digit separators in string + inputs (spaces, hyphens, etc.) are ignored. + + :param value: NHS number as a string or integer. Non-digit characters + are ignored when a string is provided. + :returns: ``True`` if the number is a valid NHS number, otherwise ``False``. + """ + str_value = str(value) # Just in case they passed an integer + digits = re.sub(r"[\s-]", "", str_value or "") + + if len(digits) != 10: + return False + if not digits.isdigit(): + return False + + first_nine = [int(ch) for ch in digits[:9]] + provided_check_digit = int(digits[9]) + + weights = list(range(10, 1, -1)) + total = sum(d * w for d, w in zip(first_nine, weights, strict=True)) + + remainder = total % 11 + check = 11 - remainder + + if check == 11: + check = 0 + if check == 10: + return False # invalid NHS number + + return check == provided_check_digit + + +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/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..733d4010 --- /dev/null +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -0,0 +1,86 @@ +""" +Unit tests for :mod:`gateway_api.common.common`. +""" + +from typing import Any + +import pytest + +from gateway_api.common import common + + +@pytest.mark.parametrize( + ("nhs_number", "expected"), + [ + ("9434765919", True), # Just a number + ("943 476 5919", True), # Spaces are permitted + ("987-654-3210", True), # Hyphens are permitted + (9434765919, True), # Integer input is permitted + ("", False), # Empty string is invalid + ("943476591", False), # 9 digits + ("94347659190", False), # 11 digits + ("9434765918", False), # wrong check digit + ("NOT_A_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 that separators (spaces, hyphens) are ignored and valid numbers pass. + """ + assert common.validate_nhs_number(nhs_number) is expected + + +@pytest.mark.parametrize( + ("nhs_number", "expected"), + [ + # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid + ("0000000000", True), + # First 9 digits produce remainder 1 => check 10 => invalid + ("0000000060", False), + ], +) +def test_validate_nhs_number_check_edge_cases_10_and_11( + nhs_number: str | int, expected: bool +) -> None: + """ + validate_nhs_number should behave correctly when the computed ``check`` value + is 10 or 11. + + - If ``check`` computes to 11, it should be treated as 0, so a number with check + digit 0 should validate successfully. + - If ``check`` computes to 10, the number is invalid and validation should return + False. + """ + # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid + # with check digit 0 + assert common.validate_nhs_number(nhs_number) is expected + + +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/conftest.py b/gateway-api/src/gateway_api/conftest.py new file mode 100644 index 00000000..05307c86 --- /dev/null +++ b/gateway-api/src/gateway_api/conftest.py @@ -0,0 +1,20 @@ +"""Pytest configuration and shared fixtures for gateway API tests.""" + +import pytest +from fhir.parameters import Parameters + + +@pytest.fixture +def valid_simple_request_payload() -> Parameters: + return { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py new file mode 100644 index 00000000..358f9d1d --- /dev/null +++ b/gateway-api/src/gateway_api/controller.py @@ -0,0 +1,324 @@ +""" +Controller layer for orchestrating calls to external services +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from gateway_api.provider_request import GpProviderClient + +if TYPE_CHECKING: + from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +__all__ = ["json"] # Make mypy happy in tests + +from dataclasses import dataclass + +from gateway_api.common.common import FlaskResponse +from gateway_api.pds_search import PdsClient, PdsSearchResults + + +@dataclass +class RequestError(Exception): + """ + Raised (and handled) when there is a problem with the incoming request. + + Instances of this exception are caught by controller entry points and converted + into an appropriate :class:`FlaskResponse`. + + :param status_code: HTTP status code that should be returned. + :param message: Human-readable error message. + """ + + status_code: int + message: str + + def __str__(self) -> str: + """ + Coercing this exception to a string returns the error message. + + :returns: The error message. + """ + return self.message + + +@dataclass +class SdsSearchResults: + """ + Stub SDS search results dataclass. + + Replace this with the real one once it's implemented. + + :param asid: Accredited System ID. + :param endpoint: Endpoint URL associated with the organisation, if applicable. + """ + + asid: str + endpoint: str | None + + +class SdsClient: + """ + Stub SDS client for obtaining ASID from ODS code. + + Replace this with the real one once it's implemented. + """ + + SANDBOX_URL = "https://example.invalid/sds" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + """ + Create an SDS client. + + :param auth_token: Authentication token to present to SDS. + :param base_url: Base URL for SDS. + :param timeout: Timeout in seconds for SDS calls. + """ + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + """ + Retrieve SDS org details for a given ODS code. + + This is a placeholder implementation that always returns an ASID and endpoint. + + :param ods_code: ODS code to look up. + :returns: SDS search results or ``None`` if not found. + """ + # Placeholder implementation + return SdsSearchResults( + asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" + ) + + +class Controller: + """ + Orchestrates calls to PDS -> SDS -> GP provider. + + Entry point: + - ``call_gp_provider(request_body_json, headers, auth_token) -> FlaskResponse`` + """ + + gp_provider_client: GpProviderClient | None + + def __init__( + self, + pds_base_url: str = PdsClient.SANDBOX_URL, + sds_base_url: str = "https://example.invalid/sds", + nhsd_session_urid: str | None = None, + timeout: int = 10, + ) -> None: + """ + Create a controller instance. + + :param pds_base_url: Base URL for PDS client. + :param sds_base_url: Base URL for SDS client. + :param nhsd_session_urid: Session URID for NHS Digital session handling. + :param timeout: Timeout in seconds for downstream calls. + """ + self.pds_base_url = pds_base_url + self.sds_base_url = sds_base_url + self.nhsd_session_urid = nhsd_session_urid + self.timeout = timeout + self.gp_provider_client = None + + def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: + """ + Controller entry point + + Expects a GetStructuredRecordRequest instance that contains the header and body + details of the HTTP request received + + Orchestration steps: + 1) Call PDS to obtain the patient's GP (provider) ODS code. + 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. + 3) Call SDS using consumer ODS to obtain consumer ASID. + 4) Call GP provider to obtain patient records. + + :param request: A GetStructuredRecordRequest instance. + :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the + outcome. + """ + auth_token = self.get_auth_token() + + if not request.ods_from.strip(): + return FlaskResponse( + status_code=400, + data='Missing required header "Ods-from"', + ) + + 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.strip(), request.nhs_number + ) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + try: + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + auth_token, request.ods_from.strip(), provider_ods + ) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + # Call GP provider with correct parameters + self.gp_provider_client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + response = self.gp_provider_client.access_structured_record( + trace_id=trace_id, + body=request.request_body, + ) + + # If we get a None from the GP provider, that means that either the service did + # not respond or we didn't make the request to the service in the first place. + # Therefore a None is a 502, any real response just pass straight back. + return FlaskResponse( + status_code=response.status_code if response is not None else 502, + data=response.text if response is not None else "GP provider service error", + headers=dict(response.headers) if response is not None else None, + ) + + def get_auth_token(self) -> str: + """ + Retrieve the authorization token. + + This is a placeholder implementation. Replace with actual logic to obtain + the auth token as needed. + + :returns: Authorization token as a string. + """ + # Placeholder implementation + return "PLACEHOLDER_AUTH_TOKEN" + + def _get_pds_details( + self, auth_token: str, consumer_ods: str, nhs_number: str + ) -> str: + """ + Call PDS to find the provider ODS code (GP ODS code) for a patient. + + :param auth_token: Authorization token to use for PDS. + :param consumer_ods: Consumer organisation ODS code (from request headers). + :param nhs_number: NHS number + :returns: Provider ODS code (GP ODS code). + :raises RequestError: If the patient cannot be found or has no provider ODS code + """ + # PDS: find patient and extract GP ODS code (provider ODS) + pds = PdsClient( + auth_token=auth_token, + end_user_org_ods=consumer_ods, + base_url=self.pds_base_url, + nhsd_session_urid=self.nhsd_session_urid, + timeout=self.timeout, + ) + + pds_result: PdsSearchResults | None = pds.search_patient_by_nhs_number( + nhs_number + ) + + if pds_result is None: + raise RequestError( + status_code=404, + message=f"No PDS patient found for NHS number {nhs_number}", + ) + + if pds_result.gp_ods_code: + provider_ods_code = pds_result.gp_ods_code + else: + raise RequestError( + status_code=404, + message=( + f"PDS patient {nhs_number} did not contain a current " + "provider ODS code" + ), + ) + + return provider_ods_code + + def _get_sds_details( + self, auth_token: str, consumer_ods: str, provider_ods: str + ) -> tuple[str, str, str]: + """ + Call SDS to obtain consumer ASID, provider ASID, and provider endpoint. + + This method performs two SDS lookups: + - provider details (ASID + endpoint) + - consumer details (ASID) + + :param auth_token: Authorization token to use for SDS. + :param consumer_ods: Consumer organisation ODS code (from request headers). + :param provider_ods: Provider organisation ODS code (from PDS). + :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). + :raises RequestError: If SDS data is missing or incomplete for provider/consumer + """ + # SDS: Get provider details (ASID + endpoint) for provider ODS + sds = SdsClient( + auth_token=auth_token, + base_url=self.sds_base_url, + timeout=self.timeout, + ) + + provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) + if provider_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for provider ODS code {provider_ods}", + ) + + provider_asid = (provider_details.asid or "").strip() + if not provider_asid: + raise RequestError( + status_code=404, + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current ASID" + ), + ) + + provider_endpoint = (provider_details.endpoint or "").strip() + if not provider_endpoint: + raise RequestError( + status_code=404, + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current endpoint" + ), + ) + + # SDS: Get consumer details (ASID) for consumer ODS + consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) + if consumer_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for consumer ODS code {consumer_ods}", + ) + + consumer_asid = (consumer_details.asid or "").strip() + if not consumer_asid: + raise RequestError( + status_code=404, + message=( + f"SDS result for consumer ODS code {consumer_ods} did not contain " + "a current ASID" + ), + ) + + return consumer_asid, provider_asid, provider_endpoint diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py new file mode 100644 index 00000000..c279cb73 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -0,0 +1,6 @@ +"""Get Structured Record module.""" + +from gateway_api.get_structured_record.handler import GetStructuredRecordHandler +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +__all__ = ["GetStructuredRecordHandler", "GetStructuredRecordRequest"] diff --git a/gateway-api/src/gateway_api/get_structured_record/handler.py b/gateway-api/src/gateway_api/get_structured_record/handler.py new file mode 100644 index 00000000..e938f5e7 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/handler.py @@ -0,0 +1,16 @@ +from gateway_api.controller import Controller +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + + +class GetStructuredRecordHandler: + @classmethod + def handle(cls, request: GetStructuredRecordRequest) -> None: + 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 new file mode 100644 index 00000000..8c466671 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -0,0 +1,81 @@ +import json + +from fhir import OperationOutcome, Parameters +from fhir.bundle import Bundle +from fhir.operation_outcome import OperationOutcomeIssue +from flask.wrappers import Request, Response + +from gateway_api.common.common import FlaskResponse + + +class GetStructuredRecordRequest: + INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1" + RESOURCE: str = "patient" + FHIR_OPERATION: str = "$gpc.getstructuredrecord" + + def __init__(self, request: Request) -> None: + self._http_request = request + self._headers = request.headers + self._request_body: Parameters = request.get_json() + self._response_body: Bundle | OperationOutcome | None = None + self._status_code: int | None = None + + @property + def trace_id(self) -> str: + trace_id: str = self._headers["Ssp-TraceID"] + return trace_id + + @property + def nhs_number(self) -> str: + nhs_number: str = self._request_body["parameter"][0]["valueIdentifier"]["value"] + return nhs_number + + @property + def ods_from(self) -> str: + ods_from: str = self._headers["ODS-from"] + return ods_from + + @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), + 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, status_code: int = 500) -> None: + self._status_code = status_code + self._response_body = OperationOutcome( + resourceType="OperationOutcome", + issue=[ + OperationOutcomeIssue( + severity="error", + code="exception", + diagnostics=error, + ) + ], + ) + + def set_response_from_flaskresponse(self, flask_response: FlaskResponse) -> None: + if flask_response.data: + self._status_code = flask_response.status_code + try: + self._response_body = json.loads(flask_response.data) + except json.JSONDecodeError as err: + self.set_negative_response(f"Failed to decode response body: {err}") + except Exception as err: + self.set_negative_response( + f"Unexpected error decoding response body: {err}" + ) + else: + self.set_negative_response( + error="No response body received", + status_code=flask_response.status_code, + ) diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py new file mode 100644 index 00000000..7ff082c5 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -0,0 +1,58 @@ +import pytest +from fhir.parameters import Parameters +from flask import Request + +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + + +class MockRequest: + def __init__(self, headers: dict[str, str], body: Parameters) -> None: + self.headers = headers + self.body = body + + def get_json(self) -> Parameters: + return self.body + + +@pytest.fixture +def mock_request_with_headers(valid_simple_request_payload: Parameters) -> MockRequest: + headers = { + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + } + return MockRequest(headers, valid_simple_request_payload) + + +class TestGetStructuredRecordRequest: + def test_trace_id_is_pulled_from_ssp_traceid_header( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.trace_id + expected = "test-trace-id" + assert actual == expected + + def test_ods_is_pulled_from_ssp_from_header( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.ods_from + expected = "test-ods" + assert actual == expected + + def test_nhs_number_is_pulled_from_request_body( + self, mock_request_with_headers: Request + ) -> None: + get_structured_record_request = GetStructuredRecordRequest( + request=mock_request_with_headers + ) + + actual = get_structured_record_request.nhs_number + expected = "9999999999" + assert actual == expected diff --git a/gateway-api/src/gateway_api/handler.py b/gateway-api/src/gateway_api/handler.py deleted file mode 100644 index a3f66b94..00000000 --- a/gateway-api/src/gateway_api/handler.py +++ /dev/null @@ -1,17 +0,0 @@ -from clinical_data_common import get_hello - - -class User: - def __init__(self, name: str): - self._name = name - - @property - def name(self) -> str: - return self._name - - -def greet(user: User) -> str: - if user.name == "nonexistent": - raise ValueError("nonexistent user provided.") - hello = get_hello() - return f"{hello}{user.name}!" diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index cddcc056..68bf91e3 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**:: @@ -160,16 +160,16 @@ def _build_headers( def search_patient_by_nhs_number( self, - nhs_number: int, + nhs_number: str, request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, - ) -> SearchResults | None: + ) -> PdsSearchResults | None: """ Retrieve a patient by NHS number. Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`SearchResults`. + resource on success, then extracts a single :class:`PdsSearchResults`. :param nhs_number: NHS number to search for. :param request_id: Optional request ID to reuse for retries; if not supplied a @@ -177,7 +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_app.py b/gateway-api/src/gateway_api/test_app.py new file mode 100644 index 00000000..18a4b0f2 --- /dev/null +++ b/gateway-api/src/gateway_api/test_app.py @@ -0,0 +1,104 @@ +"""Unit tests for the Flask app endpoints.""" + +import os +from collections.abc import Generator +from typing import TYPE_CHECKING + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from gateway_api.app import app, get_app_host, get_app_port + +if TYPE_CHECKING: + from fhir.parameters import Parameters + + +@pytest.fixture +def client() -> Generator[FlaskClient[Flask], None, None]: + app.config["TESTING"] = True + with app.test_client() as client: + yield client + + +class TestAppInitialization: + def test_get_app_host_returns_set_host_name(self) -> None: + os.environ["FLASK_HOST"] = "host_is_set" + + actual = get_app_host() + assert actual == "host_is_set" + + def test_get_app_host_raises_runtime_error_if_host_name_not_set(self) -> None: + del os.environ["FLASK_HOST"] + + with pytest.raises(RuntimeError): + _ = get_app_host() + + def test_get_app_port_returns_set_port_number(self) -> None: + os.environ["FLASK_PORT"] = "8080" + + actual = get_app_port() + assert actual == 8080 + + def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: + del os.environ["FLASK_PORT"] + + with pytest.raises(RuntimeError): + _ = get_app_port() + + +class TestGetStructuredRecord: + def test_get_structured_record_returns_200_with_bundle( + self, client: FlaskClient[Flask], valid_simple_request_payload: "Parameters" + ) -> None: + response = client.post( + "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload + ) + + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, dict) + assert data.get("resourceType") == "Bundle" + assert data.get("id") == "example-patient-bundle" + assert data.get("type") == "collection" + assert "entry" in data + assert isinstance(data["entry"], list) + assert len(data["entry"]) > 0 + assert data["entry"][0]["resource"]["resourceType"] == "Patient" + assert data["entry"][0]["resource"]["id"] == "9999999999" + assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" + + def test_get_structured_record_handles_exception( + self, + client: FlaskClient[Flask], + monkeypatch: pytest.MonkeyPatch, + valid_simple_request_payload: "Parameters", + ) -> None: + monkeypatch.setattr( + "gateway_api.get_structured_record.GetStructuredRecordHandler.handle", + Exception(), + ) + + response = client.post( + "/patient/$gpc.getstructuredrecord", json=valid_simple_request_payload + ) + assert response.status_code == 500 + + +class TestHealthCheck: + def test_health_check_returns_200_and_healthy_status( + self, client: FlaskClient[Flask] + ) -> None: + response = client.get("/health") + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "healthy" + + @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "PATCH"]) + def test_health_check_only_accepts_get_method( + self, client: FlaskClient[Flask], method: str + ) -> None: + """Test that health_check only accepts GET method.""" + response = client.open("/health", method=method) + assert response.status_code == 405 diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py new file mode 100644 index 00000000..bb9efb0b --- /dev/null +++ b/gateway-api/src/gateway_api/test_controller.py @@ -0,0 +1,721 @@ +""" +Unit tests for :mod:`gateway_api.controller`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest +from flask import Flask +from flask import request as flask_request +from requests import Response + +import gateway_api.controller as controller_module +from gateway_api.controller import ( + Controller, + SdsSearchResults, +) +from gateway_api.get_structured_record.request import GetStructuredRecordRequest + +if TYPE_CHECKING: + from collections.abc import Generator + + from gateway_api.common.common import json_str + + +# ----------------------------- +# Fake downstream dependencies +# ----------------------------- +def _make_pds_result(gp_ods_code: str | None) -> Any: + """ + Construct a minimal PDS-result-like object for tests. + + The controller only relies on the ``gp_ods_code`` attribute. + + :param gp_ods_code: Provider ODS code to expose on the result. + :returns: An object with a ``gp_ods_code`` attribute. + """ + return SimpleNamespace(gp_ods_code=gp_ods_code) + + +class FakePdsClient: + """ + Test double for :class:`gateway_api.pds_search.PdsClient`. + + The controller instantiates this class and calls ``search_patient_by_nhs_number``. + Tests configure the returned patient details using ``set_patient_details``. + """ + + last_init: dict[str, Any] | None = None + + def __init__(self, **kwargs: Any) -> None: + FakePdsClient.last_init = dict(kwargs) + self._patient_details: Any | None = None + + def set_patient_details(self, value: Any) -> None: + self._patient_details = value + + def search_patient_by_nhs_number(self, nhs_number: int) -> Any | None: + return self._patient_details + + +class FakeSdsClient: + """ + Test double for :class:`gateway_api.controller.SdsClient`. + + Tests configure per-ODS results using ``set_org_details`` and the controller + retrieves them via ``get_org_details``. + """ + + last_init: dict[str, Any] | None = None + + def __init__( + self, + auth_token: str | None = None, + base_url: str = "test_url", + timeout: int = 10, + ) -> None: + FakeSdsClient.last_init = { + "auth_token": auth_token, + "base_url": base_url, + "timeout": timeout, + } + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + self._org_details_by_ods: dict[str, SdsSearchResults | None] = {} + + def set_org_details( + self, ods_code: str, org_details: SdsSearchResults | None + ) -> None: + self._org_details_by_ods[ods_code] = org_details + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + return self._org_details_by_ods.get(ods_code) + + +class FakeGpProviderClient: + """ + Test double for :class:`gateway_api.controller.GpProviderClient`. + + The controller instantiates this class and calls ``access_structured_record``. + Tests configure the returned HTTP response using class-level attributes. + """ + + last_init: dict[str, str] | None = None + last_call: dict[str, str] | None = None + + # Configure per-test. + return_none: bool = False + response_status_code: int = 200 + response_body: bytes = b"ok" + response_headers: dict[str, str] = {"Content-Type": "application/fhir+json"} + + def __init__( + self, provider_endpoint: str, provider_asid: str, consumer_asid: str + ) -> None: + FakeGpProviderClient.last_init = { + "provider_endpoint": provider_endpoint, + "provider_asid": provider_asid, + "consumer_asid": consumer_asid, + } + + def access_structured_record( + self, + trace_id: str, + body: json_str, + ) -> Response | None: + FakeGpProviderClient.last_call = {"trace_id": trace_id, "body": body} + + if FakeGpProviderClient.return_none: + return None + + resp = Response() + resp.status_code = FakeGpProviderClient.response_status_code + resp._content = FakeGpProviderClient.response_body # noqa: SLF001 + resp.encoding = "utf-8" + resp.headers.update(FakeGpProviderClient.response_headers) + resp.url = "https://example.invalid/fake" + return resp + + +@dataclass +class SdsSetup: + """ + Helper dataclass to hold SDS setup data for tests. + """ + + ods_code: str + search_results: SdsSearchResults + + +class sds_factory: + """ + Factory to create a :class:`FakeSdsClient` pre-configured with up to two + organisations. + """ + + def __init__( + self, + org1: SdsSetup | None = None, + org2: SdsSetup | None = None, + ) -> None: + self.org1 = org1 + self.org2 = org2 + + def __call__(self, **kwargs: Any) -> FakeSdsClient: + self.inst = FakeSdsClient(**kwargs) + if self.org1 is not None: + self.inst.set_org_details( + self.org1.ods_code, + SdsSearchResults( + asid=self.org1.search_results.asid, + endpoint=self.org1.search_results.endpoint, + ), + ) + + if self.org2 is not None: + self.inst.set_org_details( + self.org2.ods_code, + SdsSearchResults( + asid=self.org2.search_results.asid, + endpoint=self.org2.search_results.endpoint, + ), + ) + return self.inst + + +class pds_factory: + """ + Factory to create a :class:`FakePdsClient` pre-configured with patient details. + """ + + def __init__(self, ods_code: str | None) -> None: + self.ods_code = ods_code + + def __call__(self, **kwargs: Any) -> FakePdsClient: + self.inst = FakePdsClient(**kwargs) + self.inst.set_patient_details(_make_pds_result(self.ods_code)) + return self.inst + + +@pytest.fixture +def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Patch controller dependencies to use test fakes. + """ + monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) + monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) + monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) + + +@pytest.fixture +def controller() -> Controller: + """ + Construct a controller instance configured for unit tests. + """ + return Controller( + pds_base_url="https://pds.example", + sds_base_url="https://sds.example", + nhsd_session_urid="session-123", + timeout=3, + ) + + +@pytest.fixture +def gp_provider_returns_none() -> Generator[None, None, None]: + """ + Configure FakeGpProviderClient to return None and reset after the test. + """ + FakeGpProviderClient.return_none = True + yield + FakeGpProviderClient.return_none = False + + +@pytest.fixture +def get_structured_record_request( + request: pytest.FixtureRequest, +) -> GetStructuredRecordRequest: + 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. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + FakeGpProviderClient.response_status_code = 200 + FakeGpProviderClient.response_body = b'{"resourceType":"Bundle"}' + FakeGpProviderClient.response_headers = { + "Content-Type": "application/fhir+json", + "X-Downstream": "gp-provider", + } + + r = controller.run(get_structured_record_request) + + # Check that response from GP provider was passed through. + assert r.status_code == 200 + assert r.data == FakeGpProviderClient.response_body.decode("utf-8") + assert r.headers == FakeGpProviderClient.response_headers + + # Check that GP provider was initialised correctly + assert FakeGpProviderClient.last_init == { + "provider_endpoint": "https://provider.example/ep", + "provider_asid": "asid_PROV", + "consumer_asid": "asid_CONS", + } + + # Check that we passed the trace ID and body to the provider + assert FakeGpProviderClient.last_call == { + "trace_id": get_structured_record_request.trace_id, + "body": get_structured_record_request.request_body, + } + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_pds_patient_not_found( + patched_deps: Any, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If PDS returns no patient record, the controller should return 404. + """ + # FakePdsClient defaults to returning None => RequestError => 404 + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "No PDS patient found for NHS number" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_gp_ods_code_missing( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If PDS returns a patient without a provider (GP) ODS code, return 404. + """ + pds = pds_factory(ods_code="") + monkeypatch.setattr(controller_module, "PdsClient", pds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current provider ODS code" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If SDS returns no provider org details, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds = sds_factory() + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "No SDS org found for provider ODS code PROVIDER" + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If provider ASID is blank/whitespace, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid=" ", endpoint="https://provider.example/ep" + ), + ) + sds = sds_factory(org1=sds_org1) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_502_when_gp_provider_returns_none( + patched_deps: Any, + 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. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 502 + assert r.data == "GP provider service error" + assert r.headers is None + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + Validate that the controller constructs the PDS client with expected kwargs. + """ + _ = controller.run(get_structured_record_request) # will stop at PDS None => 404 + + assert FakePdsClient.last_init is not None + assert FakePdsClient.last_init["auth_token"] == "PLACEHOLDER_AUTH_TOKEN" # noqa: S105 + assert FakePdsClient.last_init["end_user_org_ods"] == "CONSUMER" + assert FakePdsClient.last_init["base_url"] == "https://pds.example" + assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" + assert FakePdsClient.last_init["timeout"] == 3 + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({}, {"parameter": [{"valueIdentifier": {"value": "1234567890"}}]})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_404_message_includes_nhs_number_from_request_body( + patched_deps: Any, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If PDS returns no patient record, error message should include NHS number parsed + from the FHIR Parameters request body. + """ + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "No PDS patient found for NHS number 1234567890" + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": ""}, {})], + 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 empty/falsy, return 400. + """ + r = controller.run(get_structured_record_request) + + assert r.status_code == 400 + assert r.data == 'Missing required header "Ods-from"' + + +@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 Ssp-TraceID is present but empty, we get a 400 + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 400 + 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. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults(asid="asid_PROV", endpoint=" "), + ) + sds = sds_factory(org1=sds_org1) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current endpoint" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If SDS returns no consumer org details, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds = sds_factory(org1=sds_org1) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "No SDS org found for consumer ODS code CONSUMER" + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + If consumer ASID is blank/whitespace, the controller should return 404. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid=" ", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +@pytest.mark.parametrize( + "get_structured_record_request", + [({"ODS-from": "CONSUMER"}, {})], + indirect=["get_structured_record_request"], +) +def test_call_gp_provider_passthroughs_non_200_gp_provider_response( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, + controller: Controller, + get_structured_record_request: GetStructuredRecordRequest, +) -> None: + """ + Validate that non-200 responses from GP provider are passed through. + """ + pds = pds_factory(ods_code="PROVIDER") + sds_org1 = SdsSetup( + ods_code="PROVIDER", + search_results=SdsSearchResults( + asid="asid_PROV", endpoint="https://provider.example/ep" + ), + ) + sds_org2 = SdsSetup( + ods_code="CONSUMER", + search_results=SdsSearchResults(asid="asid_CONS", endpoint=None), + ) + sds = sds_factory(org1=sds_org1, org2=sds_org2) + + monkeypatch.setattr(controller_module, "PdsClient", pds) + monkeypatch.setattr(controller_module, "SdsClient", sds) + + FakeGpProviderClient.response_status_code = 404 + FakeGpProviderClient.response_body = b"Not Found" + FakeGpProviderClient.response_headers = { + "Content-Type": "text/plain", + "X-Downstream": "gp-provider", + } + + r = controller.run(get_structured_record_request) + + assert r.status_code == 404 + assert r.data == "Not Found" + assert r.headers is not None + assert r.headers.get("Content-Type") == "text/plain" + assert r.headers.get("X-Downstream") == "gp-provider" diff --git a/gateway-api/src/gateway_api/test_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/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 diff --git a/gateway-api/test_lambda_handler.py b/gateway-api/test_lambda_handler.py deleted file mode 100644 index df38367d..00000000 --- a/gateway-api/test_lambda_handler.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from lambda_handler import handler - - -class TestHandler: - """Unit tests for the Lambda handler function.""" - - @pytest.mark.parametrize( - ("name", "expected_greeting"), - [ - ("Alice", "Hello, Alice!"), - ("Bob", "Hello, Bob!"), - ("John Doe", "Hello, John Doe!"), - ("user123", "Hello, user123!"), - ], - ids=["simple_name_alice", "simple_name_bob", "name_with_space", "alphanumeric"], - ) - def test_handler_success(self, name: str, expected_greeting: str) -> None: - """Test handler returns 200 with greeting for valid names.""" - # Arrange - event = {"payload": name} - context: dict[str, str] = {} - - # Act - response = handler(event, context) - - # Assert - assert response["statusCode"] == 200 - assert response["body"] == expected_greeting - assert response["headers"] == {"Content-Type": "application/json"} - - @pytest.mark.parametrize( - ("event", "expected_status", "expected_body"), - [ - ({"other_key": "value"}, 400, "Name is required"), - ({"payload": ""}, 400, "Name cannot be empty"), - ({"payload": None}, 400, "Name cannot be empty"), - ( - {"payload": "nonexistent"}, - 404, - "Provided name cannot be found. name=nonexistent", - ), - ], - ids=[ - "missing_payload_key", - "empty_payload", - "none_payload", - "nonexistent_user", - ], - ) - def test_handler_error_cases( - self, event: dict[str, str], expected_status: int, expected_body: str - ) -> None: - """Test handler returns appropriate error responses for invalid or - nonexistent input. - """ - # Act - response = handler(event, {}) - - # Assert - assert response["statusCode"] == expected_status - assert response["body"] == expected_body - assert response["headers"] == {"Content-Type": "application/json"} diff --git a/gateway-api/tests/acceptance/features/happy_path.feature b/gateway-api/tests/acceptance/features/happy_path.feature new file mode 100644 index 00000000..a2afa5b5 --- /dev/null +++ b/gateway-api/tests/acceptance/features/happy_path.feature @@ -0,0 +1,16 @@ +Feature: Gateway API Hello World + As an API consumer + I want to interact with the Gateway API + So that I can verify it responds correctly to valid and invalid requests + + Background: The API is running + Given the API is running + + Scenario: Get structured record request + When I send a valid Parameters resource to the endpoint + Then the response status code should be 200 + And the response should contain a valid Bundle resource + + Scenario: Accessing a non-existent endpoint returns a 404 + When I send a valid Parameters resource to a nonexistent endpoint + Then the response status code should be 404 diff --git a/gateway-api/tests/acceptance/scenarios/test_hello_world.py b/gateway-api/tests/acceptance/scenarios/test_happy_path.py similarity index 52% rename from gateway-api/tests/acceptance/scenarios/test_hello_world.py rename to gateway-api/tests/acceptance/scenarios/test_happy_path.py index 93ed4a1a..e175a034 100644 --- a/gateway-api/tests/acceptance/scenarios/test_hello_world.py +++ b/gateway-api/tests/acceptance/scenarios/test_happy_path.py @@ -4,16 +4,16 @@ from pytest_bdd import scenario -from tests.acceptance.steps.hello_world_steps import * # noqa: F403,S2208 - Required to import all hello world steps. +from tests.acceptance.steps.happy_path import * # noqa: F403,S2208 - Required to import all happy path steps. -@scenario("hello_world.feature", "Get hello world message") -def test_hello_world() -> None: +@scenario("happy_path.feature", "Get structured record request") +def test_structured_record_request() -> None: # No body required here as this method simply provides a binding to the BDD step pass -@scenario("hello_world.feature", "Accessing a non-existent endpoint returns a 404") +@scenario("happy_path.feature", "Accessing a non-existent endpoint returns a 404") def test_nonexistent_route() -> None: # No body required here as this method simply provides a binding to the BDD step pass diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py new file mode 100644 index 00000000..e9c813c8 --- /dev/null +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -0,0 +1,67 @@ +"""Step definitions for Gateway API happy path feature.""" + +import json +from datetime import timedelta + +import requests +from fhir.bundle import Bundle +from fhir.parameters import Parameters +from pytest_bdd import given, parsers, then, when + +from tests.acceptance.conftest import ResponseContext +from tests.conftest import Client + + +@given("the API is running") +def check_api_is_running(client: Client) -> None: + response = client.send_health_check() + assert response.status_code == 200 + + +@when("I send a valid Parameters resource to the endpoint") +def send_get_request( + client: Client, + response_context: ResponseContext, + simple_request_payload: Parameters, +) -> None: + response_context.response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + + +@when("I send a valid Parameters resource to a nonexistent endpoint") +def send_to_nonexistent_endpoint( + client: Client, + response_context: ResponseContext, + simple_request_payload: Parameters, +) -> None: + nonexistent_endpoint = f"{client.base_url}/nonexistent" + response_context.response = requests.post( + url=nonexistent_endpoint, + data=json.dumps(simple_request_payload), + timeout=timedelta(seconds=1).total_seconds(), + ) + + +@then( + parsers.cfparse( + "the response status code should be {expected_status:d}", + extra_types={"expected_status": int}, + ) +) +def check_status_code(response_context: ResponseContext, expected_status: int) -> None: + assert response_context.response is not None, "Response has not been set." + assert response_context.response.status_code == expected_status, ( + f"Expected status {expected_status}, " + f"got {response_context.response.status_code}" + ) + + +@then("the response should contain a valid Bundle resource") +def check_response_contains( + response_context: ResponseContext, expected_response_payload: Bundle +) -> None: + assert response_context.response, "Response has not been set." + assert response_context.response.json() == expected_response_payload, ( + "Expected response payload does not match actual response payload." + ) diff --git a/gateway-api/tests/acceptance/steps/hello_world_steps.py b/gateway-api/tests/acceptance/steps/hello_world_steps.py deleted file mode 100644 index a7439725..00000000 --- a/gateway-api/tests/acceptance/steps/hello_world_steps.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Step definitions for Gateway API hello world feature.""" - -from pytest_bdd import given, parsers, then, when - -from tests.acceptance.conftest import ResponseContext -from tests.conftest import Client - - -@given("the API is running") -def step_api_is_running(client: Client) -> None: - """Verify the API test client is available. - - Args: - client: Test client from conftest.py - """ - response = client.send("test") - assert response.text is not None - assert response.status_code == 200 - - -@when(parsers.cfparse('I send "{message}" to the endpoint')) -def step_send_get_request( - client: Client, message: str, response_context: ResponseContext -) -> None: - """Send a GET request to the specified endpoint. - - Args: - client: Test client - endpoint: The API endpoint path to request - """ - response_context.response = client.send(message) - - -# fmt: off -@then(parsers.cfparse("the response status code should be {expected_status:d}",extra_types={"expected_status": int})) # noqa: E501 - BDD steps must be declared on a singular line. -# fmt: on -def step_check_status_code( - response_context: ResponseContext, expected_status: int -) -> None: - """Verify the response status code matches expected value. - - Args: - context: Behave context containing the response - expected_status: Expected HTTP status code - """ - assert response_context.response, "Response has not been set." - - data = response_context.response.json() - - assert data["statusCode"] == expected_status, ( - f"Expected status {expected_status}, " - f"got {response_context.response.status_code}" - ) - - -@then(parsers.cfparse('the response should contain "{expected_text}"')) -def step_check_response_contains( - response_context: ResponseContext, expected_text: str -) -> None: - """Verify the response contains the expected text. - - Args: - context: Behave context containing the response - expected_text: Text that should be in the response - """ - assert response_context.response, "Response has not been set." - - assert expected_text in response_context.response.text, ( - f"Expected '{expected_text}' in response, got: {response_context.response.text}" - ) diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index d5fba218..5facb089 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -1,6 +1,5 @@ """Pytest configuration and shared fixtures for gateway API tests.""" -import json import os from datetime import timedelta from typing import cast @@ -8,6 +7,8 @@ import pytest import requests from dotenv import find_dotenv, load_dotenv +from fhir.bundle import Bundle +from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root # find_dotenv searches upward from current directory for .env file @@ -17,42 +18,81 @@ class Client: """A simple HTTP client for testing purposes.""" - def __init__(self, lambda_url: str, timeout: timedelta = timedelta(seconds=1)): - self._lambda_url = lambda_url + def __init__(self, base_url: str, timeout: timedelta = timedelta(seconds=1)): + self.base_url = base_url self._timeout = timeout.total_seconds() - def send(self, data: str) -> requests.Response: + def send_to_get_structured_record_endpoint(self, payload: str) -> requests.Response: """ - Send a request to the APIs with some given parameters. - Args: - data: The data to send in the request payload - Returns: - Response object from the request + Send a request to the get_structured_record endpoint with the given NHS number. """ - return self._send(data=data, include_payload=True) + url = f"{self.base_url}/patient/$gpc.getstructuredrecord" + headers = {"Content-Type": "application/fhir+json"} + return requests.post( + url=url, + data=payload, + headers=headers, + timeout=self._timeout, + ) - def send_without_payload(self) -> requests.Response: + def send_health_check(self) -> requests.Response: """ - Send a request to the APIs without a payload. + Send a health check request to the API. Returns: Response object from the request """ - return self._send(data=None, include_payload=False) - - def _send(self, data: str | None, include_payload: bool) -> requests.Response: - json_data = {"payload": data} if include_payload else {} - - return requests.post( - f"{self._lambda_url}/2015-03-31/functions/function/invocations", - data=json.dumps(json_data), - timeout=self._timeout, - ) + url = f"{self.base_url}/health" + return requests.get(url=url, timeout=self._timeout) + + +@pytest.fixture +def simple_request_payload() -> Parameters: + return { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + + +@pytest.fixture +def expected_response_payload() -> Bundle: + return { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [{"use": "official", "family": "Doe", "given": ["John"]}], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } @pytest.fixture(scope="module") def client(base_url: str) -> Client: """Create a test client for the application.""" - return Client(lambda_url=base_url) + return Client(base_url=base_url) @pytest.fixture(scope="module") diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 681c19d7..6d60fef5 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -16,33 +16,73 @@ "type": "Synchronous/HTTP" }, { - "description": "a request for the hello world message", + "description": "a request for structured record", "pending": false, "request": { "body": { "content": { - "payload": "World" + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ], + "resourceType": "Parameters" }, - "contentType": "application/json", + "contentType": "application/fhir+json", "encoded": false }, "headers": { "Content-Type": [ - "application/json" + "application/fhir+json" ] }, "method": "POST", - "path": "/2015-03-31/functions/function/invocations" + "path": "/patient/$gpc.getstructuredrecord" }, "response": { "body": { - "content": "{\"statusCode\": 200, \"headers\": {\"Content-Type\": \"application/json\"}, \"body\": \"Hello, World!\"}", - "contentType": "text/plain;charset=utf-8", + "content": { + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "birthDate": "1985-04-12", + "gender": "male", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + ], + "name": [ + { + "family": "Doe", + "given": [ + "John" + ], + "use": "official" + } + ], + "resourceType": "Patient" + } + } + ], + "id": "example-patient-bundle", + "resourceType": "Bundle", + "timestamp": "2026-01-12T10:00:00Z", + "type": "collection" + }, + "contentType": "application/fhir+json", "encoded": false }, "headers": { "Content-Type": [ - "text/plain;charset=utf-8" + "application/fhir+json" ] }, "status": 200 diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index ac0d11d1..2f828234 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -4,6 +4,8 @@ interactions with the provider (the Flask API). """ +import json + import requests from pact import Pact @@ -11,50 +13,102 @@ class TestConsumerContract: """Consumer contract tests to define expected API behavior.""" - def test_get_hello_world(self) -> None: - """Test the consumer's expectation of the hello world endpoint. + def test_get_structured_record(self) -> None: + """Test the consumer's expectation of the get structured record endpoint. This test defines the contract: when the consumer requests - GET/PUT/POST/PATCH/TRACE/DELETE to the - /2015-03-31/functions/function/invocations endpoint, with a payload of "World", - a 200 response containing "Hello, World!" is returned. + POST to the /patient/$gpc.getstructuredrecord endpoint, + a 200 response containing a FHIR Bundle is returned. """ pact = Pact(consumer="GatewayAPIConsumer", provider="GatewayAPIProvider") + expected_bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + {"use": "official", "family": "Doe", "given": ["John"]} + ], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + # Define the expected interaction ( - pact.upon_receiving("a request for the hello world message") - .with_body({"payload": "World"}) - .with_request( - method="POST", - path="/2015-03-31/functions/function/invocations", - ) - .will_respond_with(status=200) + pact.upon_receiving("a request for structured record") .with_body( { - "statusCode": 200, - "headers": {"Content-Type": "application/json"}, - "body": "Hello, World!", + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], }, - content_type="text/plain;charset=utf-8", + content_type="application/fhir+json", ) + .with_header("Content-Type", "application/fhir+json") + .with_request( + method="POST", + path="/patient/$gpc.getstructuredrecord", + ) + .will_respond_with(status=200) + .with_body(expected_bundle, content_type="application/fhir+json") + .with_header("Content-Type", "application/fhir+json") ) # Start the mock server and execute the test with pact.serve() as server: # Make the actual request to the mock provider response = requests.post( - f"{server.url}/2015-03-31/functions/function/invocations", - json={"payload": "World"}, + f"{server.url}/patient/$gpc.getstructuredrecord", + data=json.dumps( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + ), + headers={"Content-Type": "application/fhir+json"}, timeout=10, ) # Verify the response matches expectations assert response.status_code == 200 body = response.json() - assert body["body"] == "Hello, World!" - assert body["statusCode"] == 200 - assert body["headers"] == {"Content-Type": "application/json"} + assert body["resourceType"] == "Bundle" + assert body["id"] == "example-patient-bundle" + assert body["type"] == "collection" + assert len(body["entry"]) == 1 + assert body["entry"][0]["resource"]["resourceType"] == "Patient" + assert body["entry"][0]["resource"]["id"] == "9999999999" # Write the pact file after the test pact.write_file("tests/contract/pacts") diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py new file mode 100644 index 00000000..0215d840 --- /dev/null +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -0,0 +1,40 @@ +"""Integration tests for the gateway API using pytest.""" + +import json + +from fhir.bundle import Bundle +from fhir.parameters import Parameters + +from tests.conftest import Client + + +class TestGetStructuredRecord: + def test_happy_path_returns_200( + self, client: Client, simple_request_payload: Parameters + ) -> None: + """Test that the root endpoint returns a 200 status code.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert response.status_code == 200 + + def test_happy_path_returns_correct_message( + self, + client: Client, + simple_request_payload: Parameters, + expected_response_payload: Bundle, + ) -> None: + """Test that the root endpoint returns the correct message.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert response.json() == expected_response_payload + + def test_happy_path_content_type( + self, client: Client, simple_request_payload: Parameters + ) -> None: + """Test that the response has the correct content type.""" + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload) + ) + assert "application/fhir+json" in response.headers["Content-Type"] diff --git a/gateway-api/tests/integration/test_main.py b/gateway-api/tests/integration/test_main.py deleted file mode 100644 index 18c71e09..00000000 --- a/gateway-api/tests/integration/test_main.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Integration tests for the gateway API using pytest.""" - -from tests.conftest import Client - - -class TestHelloWorld: - """Test suite for the hello world endpoint.""" - - def test_hello_world_returns_200(self, client: Client) -> None: - """Test that the root endpoint returns a 200 status code.""" - response = client.send("world") - assert response.status_code == 200 - - def test_hello_world_returns_correct_message(self, client: Client) -> None: - """Test that the root endpoint returns the correct message.""" - response = client.send("World") - assert response.json()["body"] == "Hello, World!" - - def test_hello_world_content_type(self, client: Client) -> None: - """Test that the response has the correct content type.""" - response = client.send("world") - assert "text/plain" in response.headers["Content-Type"] - - def test_nonexistent_returns_error(self, client: Client) -> None: - """Test that non-existent routes return 404.""" - response = client.send("nonexistent") - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Provided name cannot be found. name=nonexistent" - - status_code = response.json().get("statusCode") - assert status_code == 404 - - def test_no_payload_returns_error(self, client: Client) -> None: - """Test that an error is returned when no payload is provided.""" - response = client.send_without_payload() - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Name is required" - - def test_empty_name_returns_error(self, client: Client) -> None: - """Test that an error is returned when an empty name is provided.""" - response = client.send("") - assert response.status_code == 200 - - body = response.json().get("body") - assert body == "Name cannot be empty" diff --git a/infrastructure/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] } diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 121dc611..54824a4b 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -1,11 +1,24 @@ # Retrieve the python version from build arguments, deliberately set to "invalid" by default to highlight when no version is provided when building the container. ARG PYTHON_VERSION=invalid -# Use the specified python version to retrieve the required base lambda image. -ARG url=public.ecr.aws/lambda/python:${PYTHON_VERSION} -FROM $url +FROM python:${PYTHON_VERSION}-alpine3.23 AS gateway-api + +RUN addgroup -S nonroot \ + && adduser -S gateway_api_user -G nonroot COPY resources/ /resources -COPY /resources/build/gateway-api ${LAMBDA_TASK_ROOT} +WORKDIR /resources/build/gateway-api + +ENV PYTHONPATH=/resources/build/gateway-api +ENV FLASK_HOST="0.0.0.0" +ENV FLASK_PORT="8080" + +ARG COMMIT_VERSION +ENV COMMIT_VERSION=$COMMIT_VERSION +ARG BUILD_DATE +ENV BUILD_DATE=$BUILD_DATE + +USER gateway_api_user +ENTRYPOINT ["python"] +CMD ["gateway_api/app.py"] -CMD [ "lambda_handler.handler" ] diff --git a/scripts/tests/run-test.sh b/scripts/tests/run-test.sh index d2c3177c..8d1cefec 100755 --- a/scripts/tests/run-test.sh +++ b/scripts/tests/run-test.sh @@ -25,7 +25,7 @@ cd "$(git rev-parse --show-toplevel)" # Determine test path based on test type if [[ "$TEST_TYPE" = "unit" ]]; then - TEST_PATH="test_*.py src/*/test_*.py" + TEST_PATH="src" else TEST_PATH="tests/${TEST_TYPE}/" fi