diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml new file mode 100644 index 00000000..97a88061 --- /dev/null +++ b/.github/actions/start-app/action.yaml @@ -0,0 +1,50 @@ +name: "Start local app" +description: "Start Flask app that will handle requests" +inputs: + deploy-command: + description: "Command to start app" + required: false + default: "make deploy" + health-path: + description: "Health probe path to POST" + required: false + default: "/2015-03-31/functions/function/invocations" + max-seconds: + description: "Maximum seconds to wait for readiness" + required: false + default: "60" + python-version: + description: "Python version to install" + required: true +runs: + using: "composite" + steps: + - name: "Start app" + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python-version }} + run: | + set -euo pipefail + echo "Starting app: '${{ inputs.deploy-command }}'" + nohup ${{ inputs.deploy-command }} >/tmp/app.log 2>&1 & + echo $! > /tmp/app.pid + echo "PID: $(cat /tmp/app.pid)" + - name: "Wait for app to be ready" + shell: bash + run: | + set -euo pipefail + BASE_URL="${BASE_URL:-http://localhost:5000}" + HEALTH_URL="${BASE_URL}${{ inputs.health-path }}" + MAX="${{ inputs.max-seconds }}" + echo "Waiting for app at ${HEALTH_URL} (max ${MAX}s)..." + for i in $(seq 1 "${MAX}"); do + if curl -sSf -X POST "${HEALTH_URL}" -d '{}' >/dev/null; then + echo "App is ready" + exit 0 + fi + sleep 1 + done + echo "App did not become ready in time" + echo "---- recent app log ----" + tail -n 200 /tmp/app.log || true + exit 1 diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 66e4a240..32a5fd2b 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -68,8 +68,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run contract tests" @@ -98,8 +98,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run schema validation tests" @@ -128,8 +128,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} - name: "Run integration test" @@ -158,8 +158,8 @@ jobs: uses: ./.github/actions/setup-python-project with: python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda + - name: "Start app" + uses: ./.github/actions/start-app with: python-version: ${{ inputs.python_version }} max-seconds: 90 diff --git a/Makefile b/Makefile index 3e634518..598014d2 100644 --- a/Makefile +++ b/Makefile @@ -34,9 +34,8 @@ build-gateway-api: dependencies @poetry run mypy --no-namespace-packages . @echo "Packaging dependencies..." @poetry build --format=wheel - @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_1_x86_64 --only-binary=:all: + @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all: # Copy main file separately as it is not included within the package. - @cp lambda_handler.py ./target/gateway-api/ @rm -rf ../infrastructure/images/gateway-api/resources/build/ @mkdir ../infrastructure/images/gateway-api/resources/build/ @cp -r ./target/gateway-api ../infrastructure/images/gateway-api/resources/build/ diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index b6799f7c..a318d4d9 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -9,249 +9,145 @@ 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 - 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 - get: - 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/json] + required: true requestBody: - required: false + required: true content: application/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 - - '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: + 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/json] + required: true content: - text/plain: + application/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/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 - patch: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - - '404': - description: Route not found - content: - text/html: - schema: - type: string - delete: - summary: Get hello world message - description: Returns a simple hello world message - operationId: postHelloWorld - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - payload: - type: string - description: The payload to be processed - responses: - '200': - description: Successful response - content: - text/plain: - schema: - type: object - properties: - status_code: - type: integer - description: Status code of the interaction - body: - type: string - description: The output of the interaction - errorMessage: - type: string - description: Any error messages relating to errors encountered with the interaction - errorType: - type: string - description: The type of error encountered during the interaction, if an error has occurred - requestId: - type: string - format: uuid - description: The unique request ID for the interaction - stacktrace: - type: array - items: - type: string - description: The stack trace of the error, if an error has occurred - trace: + type: object + description: FHIR Bundle containing patient data + properties: + resourceType: + type: string + example: "Bundle" + id: + type: string + example: "example-patient-bundle" + type: + type: string + example: "collection" + timestamp: + type: string + format: date-time + example: "2026-01-12T10:00:00Z" + entry: + type: array + items: + type: object + properties: + fullUrl: + type: string + example: "urn:uuid:123e4567-e89b-12d3-a456-426614174000" + resource: + type: object + properties: + resourceType: + type: string + example: "Patient" + id: + type: string + example: "9999999999" + identifier: + type: array + items: + type: object + properties: + system: + type: string + example: "https://fhir.nhs.uk/Id/nhs-number" + value: + type: string + example: "9999999999" + name: + type: array + items: + type: object + properties: + use: + type: string + example: "official" + family: + type: string + example: "Doe" + given: + type: array + items: + type: string + example: ["John"] + gender: + type: string + example: "male" + birthDate: + type: string + format: date + example: "1985-04-12" + /2015-03-31/functions/function/invocations: + post: summary: Get hello world message description: Returns a simple hello world message operationId: postHelloWorld requestBody: - required: false + required: true content: application/json: schema: @@ -260,11 +156,12 @@ paths: payload: type: string description: The payload to be processed + example: "Alex" responses: '200': description: Successful response content: - text/plain: + application/json: schema: type: object properties: diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 338577d4..8ec2ddde 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -63,6 +63,18 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -301,7 +313,7 @@ version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -332,11 +344,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\""} [[package]] name = "coverage" @@ -443,6 +456,30 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "flask" +version = "3.1.2" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "fqdn" version = "1.5.1" @@ -668,13 +705,25 @@ files = [ [package.dependencies] arrow = ">=0.15.0" +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -808,7 +857,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -1981,6 +2030,62 @@ files = [ requests = "*" starlette = ">=0.20.1" +[[package]] +name = "types-click" +version = "7.1.8" +description = "Typing stubs for click" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-click-7.1.8.tar.gz", hash = "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"}, + {file = "types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81"}, +] + +[[package]] +name = "types-flask" +version = "1.1.6" +description = "Typing stubs for Flask" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Flask-1.1.6.tar.gz", hash = "sha256:aac777b3abfff9436e6b01f6d08171cf23ea6e5be71cbf773aaabb1c5763e9cf"}, + {file = "types_Flask-1.1.6-py3-none-any.whl", hash = "sha256:6ab8a9a5e258b76539d652f6341408867298550b19b81f0e41e916825fc39087"}, +] + +[package.dependencies] +types-click = "*" +types-Jinja2 = "*" +types-Werkzeug = "*" + +[[package]] +name = "types-jinja2" +version = "2.11.9" +description = "Typing stubs for Jinja2" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Jinja2-2.11.9.tar.gz", hash = "sha256:dbdc74a40aba7aed520b7e4d89e8f0fe4286518494208b35123bcf084d4b8c81"}, + {file = "types_Jinja2-2.11.9-py3-none-any.whl", hash = "sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2"}, +] + +[package.dependencies] +types-MarkupSafe = "*" + +[[package]] +name = "types-markupsafe" +version = "1.1.10" +description = "Typing stubs for MarkupSafe" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-MarkupSafe-1.1.10.tar.gz", hash = "sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1"}, + {file = "types_MarkupSafe-1.1.10-py3-none-any.whl", hash = "sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5"}, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -2008,6 +2113,18 @@ files = [ [package.dependencies] urllib3 = ">=2" +[[package]] +name = "types-werkzeug" +version = "1.0.9" +description = "Typing stubs for Werkzeug" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "types-Werkzeug-1.0.9.tar.gz", hash = "sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c"}, + {file = "types_Werkzeug-1.0.9-py3-none-any.whl", hash = "sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -2083,7 +2200,7 @@ version = "3.1.5" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, @@ -2243,4 +2360,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "67e8839de72625c8f7c4d42aea6ea55afaf9f738aef2267bb4dac2f83a389f8e" +content-hash = "30cdb09db37902c7051aa190c1e4c374dbfa6a14ca0c69131c0295ee33e7338f" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 2242551f..87f86635 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -10,6 +10,8 @@ requires-python = ">3.13,<4.0.0" [tool.poetry.dependencies] clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } +flask = "^3.1.2" +types-flask = "^1.1.6" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py new file mode 100644 index 00000000..f6fa3fcd --- /dev/null +++ b/gateway-api/src/gateway_api/app.py @@ -0,0 +1,128 @@ +import os +from typing import Any, TypedDict + +from flask import Flask, request + +from gateway_api.handler import User, greet + +app = Flask(__name__) + + +class APIMResponse[T](TypedDict): + """A API Management response including a body with a generic type.""" + + statusCode: int + headers: dict[str, str] + body: T + + +class Identifier(TypedDict): + """FHIR Identifier type.""" + + system: str + value: str + + +class HumanName(TypedDict): + """FHIR HumanName type.""" + + use: str + family: str + given: list[str] + + +class Patient(TypedDict): + """FHIR Patient resource.""" + + resourceType: str + id: str + identifier: list[Identifier] + name: list[HumanName] + gender: str + birthDate: str + + +class BundleEntry(TypedDict): + """FHIR Bundle entry.""" + + fullUrl: str + resource: Patient + + +class Bundle(TypedDict): + """FHIR Bundle resource.""" + + resourceType: str + id: str + type: str + timestamp: str + entry: list[BundleEntry] + + +@app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) +def get_structured_record() -> Bundle: + """Endpoint to get structured record, replicating lambda handler functionality.""" + bundle: Bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [{"use": "official", "family": "Doe", "given": ["John"]}], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + return bundle + + +@app.route("/2015-03-31/functions/function/invocations", methods=["POST"]) +def greet_endpoint() -> APIMResponse[str | dict[str, str]]: + """Greet endpoint that replicates the lambda handler functionality.""" + data = request.get_json(force=True) + if "payload" not in data: + return _with_default_headers(status_code=400, body="Name is required") + + name = data["payload"] + if not name: + return _with_default_headers(status_code=400, body="Name cannot be empty") + user = User(name=name) + + try: + return _with_default_headers(status_code=200, body=f"{greet(user)}") + except ValueError: + return _with_default_headers( + status_code=404, body=f"Provided name cannot be found. name={name}" + ) + + +def _with_default_headers[T](status_code: int, body: T) -> APIMResponse[T]: + return APIMResponse( + statusCode=status_code, headers={"Content-Type": "application/json"}, body=body + ) + + +@app.route("/health", methods=["GET"]) +def health_check() -> APIMResponse[dict[str, Any]]: + """Health check endpoint.""" + return _with_default_headers(status_code=200, body={"status": "healthy"}) + + +if __name__ == "__main__": + host = os.getenv("FLASK_HOST") + if host is None: + raise RuntimeError("FLASK_HOST environment variable is not set.") + app.run(host=host, port=8080) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py new file mode 100644 index 00000000..15d864c4 --- /dev/null +++ b/gateway-api/src/gateway_api/test_app.py @@ -0,0 +1,162 @@ +"""Unit tests for the Flask app endpoints.""" + +from collections.abc import Generator + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from gateway_api.app import app + + +@pytest.fixture +def client() -> Generator[FlaskClient[Flask], None, None]: + """Create a Flask test client.""" + app.config["TESTING"] = True + with app.test_client() as client: + yield client + + +class TestGetStructuredRecord: + """Unit tests for the get_structured_record function.""" + + def test_get_structured_record_returns_200_with_bundle( + self, client: FlaskClient[Flask] + ) -> None: + """Test that get_structured_record returns 200 with a bundle.""" + body = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + response = client.post("/patient/$gpc.getstructuredrecord", json=body) + + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, dict) + assert data.get("resourceType") == "Bundle" + assert data.get("id") == "example-patient-bundle" + assert data.get("type") == "collection" + assert "entry" in data + assert isinstance(data["entry"], list) + assert len(data["entry"]) > 0 + assert data["entry"][0]["resource"]["resourceType"] == "Patient" + assert data["entry"][0]["resource"]["id"] == "9999999999" + assert data["entry"][0]["resource"]["identifier"][0]["value"] == "9999999999" + + +class TestGreetEndpoint: + """Unit tests for the greet_endpoint function.""" + + def test_greet_endpoint_returns_greeting_for_valid_name( + self, client: FlaskClient[Flask] + ) -> None: + """Test that greet_endpoint returns a greeting for a valid name.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": "Alice"}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 200 + assert data["headers"]["Content-Type"] == "application/json" + assert "Alice" in data["body"] + assert data["body"].endswith("!") + + def test_greet_endpoint_returns_400_when_payload_missing( + self, client: FlaskClient[Flask] + ) -> None: + """Test that greet_endpoint returns 400 when payload is missing.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 400 + assert data["body"] == "Name is required" + assert data["headers"]["Content-Type"] == "application/json" + + def test_greet_endpoint_returns_400_when_name_is_empty( + self, client: FlaskClient[Flask] + ) -> None: + """Test that greet_endpoint returns 400 when name is empty.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": ""}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 400 + assert data["body"] == "Name cannot be empty" + assert data["headers"]["Content-Type"] == "application/json" + + def test_greet_endpoint_returns_404_for_nonexistent_user( + self, client: FlaskClient[Flask] + ) -> None: + """Test that greet_endpoint returns 404 for nonexistent user.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": "nonexistent"}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 404 + assert "cannot be found" in data["body"] + assert "nonexistent" in data["body"] + assert data["headers"]["Content-Type"] == "application/json" + + def test_greet_endpoint_returns_400_when_name_is_none( + self, client: FlaskClient[Flask] + ) -> None: + """Test that greet_endpoint returns 400 when name is None.""" + response = client.post( + "/2015-03-31/functions/function/invocations", + json={"payload": None}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 400 + assert data["body"] == "Name cannot be empty" + assert data["headers"]["Content-Type"] == "application/json" + + +class TestHealthCheck: + """Unit tests for the health_check function.""" + + def test_health_check_returns_200_and_healthy_status( + self, client: FlaskClient[Flask] + ) -> None: + """Test that health_check returns 200 with healthy status.""" + response = client.get("/health") + + assert response.status_code == 200 + data = response.get_json() + assert data["statusCode"] == 200 + assert data["body"]["status"] == "healthy" + assert data["headers"]["Content-Type"] == "application/json" + + def test_health_check_only_accepts_get_method( + self, client: FlaskClient[Flask] + ) -> None: + """Test that health_check only accepts GET method.""" + response = client.post("/health") + assert response.status_code == 405 # Method Not Allowed + + response = client.put("/health") + assert response.status_code == 405 + + response = client.delete("/health") + assert response.status_code == 405 diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index d5fba218..997b044d 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -21,7 +21,28 @@ def __init__(self, lambda_url: str, timeout: timedelta = timedelta(seconds=1)): self._lambda_url = lambda_url self._timeout = timeout.total_seconds() - def send(self, data: str) -> requests.Response: + def get_structured_record(self, nhs_number: str) -> requests.Response: + """ + Send a request to the get_structured_record endpoint with the given NHS number. + """ + payload = json.dumps( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": nhs_number, + }, + }, + ], + } + ) + url = f"{self._lambda_url}/patient/$gpc.getstructuredrecord" + return self._send(url=url, payload=payload) + + def send(self, message: str) -> requests.Response: """ Send a request to the APIs with some given parameters. Args: @@ -29,7 +50,9 @@ def send(self, data: str) -> requests.Response: Returns: Response object from the request """ - return self._send(data=data, include_payload=True) + payload = json.dumps({"payload": message}) + url = f"{self._lambda_url}/2015-03-31/functions/function/invocations" + return self._send(url=url, payload=payload) def send_without_payload(self) -> requests.Response: """ @@ -37,14 +60,14 @@ def send_without_payload(self) -> requests.Response: Returns: Response object from the request """ - return self._send(data=None, include_payload=False) - - def _send(self, data: str | None, include_payload: bool) -> requests.Response: - json_data = {"payload": data} if include_payload else {} + empty_payload = json.dumps({}) + url = f"{self._lambda_url}/2015-03-31/functions/function/invocations" + return self._send(url=url, payload=empty_payload) + def _send(self, url: str, payload: str) -> requests.Response: return requests.post( - f"{self._lambda_url}/2015-03-31/functions/function/invocations", - data=json.dumps(json_data), + url=url, + data=payload, timeout=self._timeout, ) diff --git a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json index 681c19d7..47af75f5 100644 --- a/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json +++ b/gateway-api/tests/contract/pacts/GatewayAPIConsumer-GatewayAPIProvider.json @@ -15,6 +15,80 @@ }, "type": "Synchronous/HTTP" }, + { + "description": "a request for structured record", + "pending": false, + "request": { + "body": { + "content": { + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + } + ], + "resourceType": "Parameters" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "method": "POST", + "path": "/patient/$gpc.getstructuredrecord" + }, + "response": { + "body": { + "content": { + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "birthDate": "1985-04-12", + "gender": "male", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + ], + "name": [ + { + "family": "Doe", + "given": [ + "John" + ], + "use": "official" + } + ], + "resourceType": "Patient" + } + } + ], + "id": "example-patient-bundle", + "resourceType": "Bundle", + "timestamp": "2026-01-12T10:00:00Z", + "type": "collection" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, { "description": "a request for the hello world message", "pending": false, @@ -36,13 +110,19 @@ }, "response": { "body": { - "content": "{\"statusCode\": 200, \"headers\": {\"Content-Type\": \"application/json\"}, \"body\": \"Hello, World!\"}", - "contentType": "text/plain;charset=utf-8", + "content": { + "body": "Hello, World!", + "headers": { + "Content-Type": "application/json" + }, + "statusCode": 200 + }, + "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "text/plain;charset=utf-8" + "application/json" ] }, "status": 200 diff --git a/gateway-api/tests/contract/test_consumer_contract.py b/gateway-api/tests/contract/test_consumer_contract.py index ac0d11d1..68b33aaa 100644 --- a/gateway-api/tests/contract/test_consumer_contract.py +++ b/gateway-api/tests/contract/test_consumer_contract.py @@ -4,6 +4,8 @@ interactions with the provider (the Flask API). """ +import json + import requests from pact import Pact @@ -36,7 +38,7 @@ def test_get_hello_world(self) -> None: "headers": {"Content-Type": "application/json"}, "body": "Hello, World!", }, - content_type="text/plain;charset=utf-8", + content_type="application/json", ) ) @@ -59,6 +61,106 @@ def test_get_hello_world(self) -> None: # Write the pact file after the test pact.write_file("tests/contract/pacts") + def test_get_structured_record(self) -> None: + """Test the consumer's expectation of the get structured record endpoint. + + This test defines the contract: when the consumer requests + POST to the /patient/$gpc.getstructuredrecord endpoint, + a 200 response containing a FHIR Bundle is returned. + """ + pact = Pact(consumer="GatewayAPIConsumer", provider="GatewayAPIProvider") + + expected_bundle = { + "resourceType": "Bundle", + "id": "example-patient-bundle", + "type": "collection", + "timestamp": "2026-01-12T10:00:00Z", + "entry": [ + { + "fullUrl": "urn:uuid:123e4567-e89b-12d3-a456-426614174000", + "resource": { + "resourceType": "Patient", + "id": "9999999999", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + } + ], + "name": [ + {"use": "official", "family": "Doe", "given": ["John"]} + ], + "gender": "male", + "birthDate": "1985-04-12", + }, + } + ], + } + + # Define the expected interaction + ( + pact.upon_receiving("a request for structured record") + .with_body( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + }, + content_type="application/json", + ) + .with_header("Content-Type", "application/json") + .with_request( + method="POST", + path="/patient/$gpc.getstructuredrecord", + ) + .will_respond_with(status=200) + .with_body(expected_bundle, content_type="application/json") + .with_header("Content-Type", "application/json") + ) + + # Start the mock server and execute the test + with pact.serve() as server: + # Make the actual request to the mock provider + response = requests.post( + f"{server.url}/patient/$gpc.getstructuredrecord", + data=json.dumps( + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", + }, + }, + ], + } + ), + headers={"Content-Type": "application/json"}, + timeout=10, + ) + + # Verify the response matches expectations + assert response.status_code == 200 + body = response.json() + assert body["resourceType"] == "Bundle" + assert body["id"] == "example-patient-bundle" + assert body["type"] == "collection" + assert len(body["entry"]) == 1 + assert body["entry"][0]["resource"]["resourceType"] == "Patient" + assert body["entry"][0]["resource"]["id"] == "9999999999" + + # Write the pact file after the test + pact.write_file("tests/contract/pacts") + def test_get_nonexistent_route(self) -> None: """Test the consumer's expectation when requesting a non-existent route. diff --git a/gateway-api/tests/integration/test_main.py b/gateway-api/tests/integration/test_main.py index 18c71e09..0ccb3786 100644 --- a/gateway-api/tests/integration/test_main.py +++ b/gateway-api/tests/integration/test_main.py @@ -19,7 +19,7 @@ def test_hello_world_returns_correct_message(self, client: Client) -> None: def test_hello_world_content_type(self, client: Client) -> None: """Test that the response has the correct content type.""" response = client.send("world") - assert "text/plain" in response.headers["Content-Type"] + assert "application/json" in response.headers["Content-Type"] def test_nonexistent_returns_error(self, client: Client) -> None: """Test that non-existent routes return 404.""" diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 121dc611..f3ce577f 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -1,11 +1,14 @@ # Retrieve the python version from build arguments, deliberately set to "invalid" by default to highlight when no version is provided when building the container. ARG PYTHON_VERSION=invalid -# Use the specified python version to retrieve the required base lambda image. -ARG url=public.ecr.aws/lambda/python:${PYTHON_VERSION} -FROM $url +FROM python:${PYTHON_VERSION}-slim AS gateway-api COPY resources/ /resources -COPY /resources/build/gateway-api ${LAMBDA_TASK_ROOT} +WORKDIR /resources/build/gateway-api + +ENV PYTHONPATH=/resources/build/gateway-api +ENV FLASK_HOST="0.0.0.0" + +ENTRYPOINT ["python"] +CMD ["gateway_api/app.py"] -CMD [ "lambda_handler.handler" ]