From 6b0f8259c939c781975b7736feb816904a0edb46 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:54:17 +0100 Subject: [PATCH 01/15] Move towards using URL-based env var names. --- .github/workflows/stage-2-test.yaml | 6 +++--- Makefile | 12 ++++++------ README.md | 6 +++--- gateway-api/src/gateway_api/conftest.py | 3 +++ gateway-api/src/gateway_api/pds/client.py | 12 +++++------- gateway-api/src/gateway_api/provider/client.py | 4 ++-- gateway-api/src/gateway_api/sds/client.py | 6 +++--- infrastructure/README.md | 2 +- infrastructure/images/gateway-api/Dockerfile | 6 +++--- 9 files changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index e7267f40..2adbcd39 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -3,9 +3,9 @@ name: "Test stage" env: BASE_URL: "http://localhost:5000" HOST: "localhost" - STUB_SDS: "true" - STUB_PDS: "true" - STUB_PROVIDER: "true" + SDS_URL: "stub" + PDS_URL: "stub" + PROVIDER_URL: "stub" on: workflow_call: diff --git a/Makefile b/Makefile index 16da7fd5..a398a11c 100644 --- a/Makefile +++ b/Makefile @@ -70,14 +70,14 @@ deploy: clean build # Deploy the project artefact to the target environment @Pip @$(docker) network inspect gateway-local >/dev/null 2>&1 || $(docker) network create gateway-local # Build up list of environment variables to pass to the container @ENVIRONMENT_STRING="" ; \ - if [[ -n "$${STUB_PROVIDER}" ]]; then \ - ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e STUB_PROVIDER=$${STUB_PROVIDER}" ; \ + if [[ -n "$${PROVIDER_URL}" ]]; then \ + ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e PROVIDER_URL=$${PROVIDER_URL}" ; \ fi ; \ - if [[ -n "$${STUB_PDS}" ]]; then \ - ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e STUB_PDS=$${STUB_PDS}" ; \ + if [[ -n "$${PDS_URL}" ]]; then \ + ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e PDS_URL=$${PDS_URL}" ; \ fi ; \ - if [[ -n "$${STUB_SDS}" ]]; then \ - ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e STUB_SDS=$${STUB_SDS}" ; \ + if [[ -n "$${SDS_URL}" ]]; then \ + ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e SDS_URL=$${SDS_URL}" ; \ fi ; \ if [[ -n "$${CDG_DEBUG}" ]]; then \ ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e CDG_DEBUG=$${CDG_DEBUG}" ; \ diff --git a/README.md b/README.md index a237a767..25805e54 100644 --- a/README.md +++ b/README.md @@ -152,9 +152,9 @@ The full API schema is defined in [gateway-api/openapi.yaml](gateway-api/openapi | `HOST` | hostname portion of `BASE_URL` | | `FLASK_HOST` | Host the Flask app binds to | | `FLASK_PORT` | Port the Flask app listens on | -| `STUB_PDS` | `true`, use the stubs/stubs/pds/stub.py to return stubbed responses for PDS FHIR API; otherwise, not. | -| `STUB_SDS` | `true`, use the stubs/stubs/sds/stub.py to return stubbed responses for SDS FHIR API; otherwise, not. | -| `STUB_PROVIDER` | `true`, use the stubs/stubs/provider/stub.py to return stubbed responses for the provider system; otherwise, not. | +| `PDS_URL` | The URL for the PDS FHIR API; set as `stub` to use development stub. | +| `SDS_URL` | The URL for the SDS FHIR API; set as `stub` to use development stub. | +| `PROVIDER_URL` | The URL for the GP Provider; set as `stub` to use development stub. | | `CDG_DEBUG` | `true`, Return additional debug information when the call to the GP provider returns an error. Note if set true causes the unit tests to fail, because expected return values are changed. | Environment variables also control whether stubs are used in place of the real PDS, SDS, and Provider services during local development. diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index ed7844f0..77c5ddbe 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -6,6 +6,7 @@ import pytest import requests +from dotenv import find_dotenv, load_dotenv from fhir.constants import FHIRSystem from flask import Request from requests.structures import CaseInsensitiveDict @@ -13,6 +14,8 @@ from gateway_api.clinical_jwt import JWT +load_dotenv(find_dotenv(usecwd=True)) + @dataclass class FakeResponse: diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 303671bf..323d27ae 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -20,7 +20,6 @@ import os import uuid -from collections.abc import Callable import requests from fhir.r4 import Patient @@ -29,18 +28,17 @@ from gateway_api.common.error import PdsRequestFailedError # TODO [GPCAPIM-359]: Once stub servers/containers made for PDS, SDS and provider -# we should remove the STUB_PDS environment variable and just +# we should remove the PDS_URL environment variable and just # use the stub client -STUB_PDS = os.environ.get("STUB_PDS", "false").lower() == "true" +STUB_PDS = os.environ["PDS_URL"].lower() == "stub" -get: Callable[..., requests.Response] if not STUB_PDS: - get = requests.get + from requests import get else: from stubs.pds.stub import PdsFhirApiStub pds = PdsFhirApiStub() - get = pds.get + get = pds.get # type: ignore class PdsClient: @@ -123,7 +121,7 @@ def search_patient_by_nhs_number( url = f"{self.base_url}/Patient/{nhs_number}" - # This normally calls requests.get, but if STUB_PDS is set it uses the stub. + # This normally calls requests.get, but if PDS_URL is set it uses the stub. response = get( url, headers=headers, diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 731bcafc..13b376e2 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -33,9 +33,9 @@ from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID # TODO [GPCAPIM-359]: Once stub servers/containers made for PDS, SDS and provider -# we should remove the STUB_PROVIDER environment variable and just +# we should remove the PROVIDER_URL environment variable and just # use the stub client -STUB_PROVIDER = os.environ.get("STUB_PROVIDER", "false").lower() == "true" +STUB_PROVIDER = os.environ["PROVIDER_URL"].lower() == "stub" if not STUB_PROVIDER: from requests import post else: diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index 22de0009..436a613f 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -22,9 +22,9 @@ from gateway_api.sds.search_results import SdsSearchResults # TODO [GPCAPIM-359]: Once stub servers/containers made for PDS, SDS and provider -# we should remove the STUB_SDS environment variable and just +# we should remove the SDS_URL environment variable and just # use the stub client -STUB_SDS = os.environ.get("STUB_SDS", "false").lower() == "true" +STUB_SDS = os.environ["SDS_URL"].lower() == "stub" if not STUB_SDS: from requests import get else: @@ -54,7 +54,7 @@ class SdsClient: **Stubbing**: - For testing, set the environment variable ``$STUB_SDS`` to use the + For testing, set the environment variable ``$SDS_URL` to use the :class:`SdsFhirApiStub` instead of making real HTTP requests. **Usage example**:: diff --git a/infrastructure/README.md b/infrastructure/README.md index 4994b8ec..99dd0afe 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -60,7 +60,7 @@ A lightweight Alpine-based Python image that runs the Flask application. Built w - A configurable `PYTHON_VERSION` build argument - A non-root user (`gateway_api_user`) -- Stubs enabled by default (`STUB_PDS`, `STUB_SDS`, `STUB_PROVIDER` all set to `true`) +- Stubs enabled by default (`PDS_URL`, `SDS_URL`, `PROVIDER_URL` all set to `stub`) - Flask listening on `0.0.0.0:8080` ### `build-container` diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 1a5c94b2..527e0f52 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -26,9 +26,9 @@ WORKDIR /resources/build/gateway-api ENV PYTHONPATH=/resources/build/gateway-api ENV FLASK_HOST="0.0.0.0" ENV FLASK_PORT="8080" -ENV STUB_PDS="true" -ENV STUB_PROVIDER="true" -ENV STUB_SDS="true" +ENV PDS_URL="stub" +ENV PROVIDER_URL="stub" +ENV SDS_URL="stub" ARG COMMIT_VERSION ENV COMMIT_VERSION=$COMMIT_VERSION From a4675a8d29962bc2baeebfa52ac68ccccac821f7 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:25:21 +0100 Subject: [PATCH 02/15] Resolve this TODO later. --- .github/workflows/preview-env.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 2fab475a..fed28082 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -248,6 +248,7 @@ jobs: --services ${{ steps.tf-output.outputs.ecs_service }} \ --region ${{ env.AWS_REGION }} + # TODO: We don't need these anymore, as we are testing against the proxy. - name: Get mTLS certs for testing if: github.event.action != 'closed' id: mtls-certs From 0202c90752232b37d2ff190ff7e1ca5059748ac1 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:33:26 +0100 Subject: [PATCH 03/15] Broaden vocabulary. --- .vscode/cspell-dictionary.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/cspell-dictionary.txt b/.vscode/cspell-dictionary.txt index 85932785..1a219d09 100644 --- a/.vscode/cspell-dictionary.txt +++ b/.vscode/cspell-dictionary.txt @@ -3,6 +3,7 @@ fhir getstructuredrecord GPCAPIM gpconnect -searchset +orangebox proxygen +searchset usefixtures From 8d2f8a1af5a8e0c30f54222b2d98580e15ef5e33 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:54:42 +0100 Subject: [PATCH 04/15] Log the client details. --- gateway-api/src/gateway_api/pds/client.py | 10 ++++++++++ gateway-api/src/gateway_api/provider/client.py | 12 ++++++++++++ gateway-api/src/gateway_api/sds/client.py | 10 ++++++++++ 3 files changed, 32 insertions(+) diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 323d27ae..221d5672 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -33,8 +33,18 @@ STUB_PDS = os.environ["PDS_URL"].lower() == "stub" if not STUB_PDS: + log_details = { + "description": "Using real PDS client", + "pds_url": os.environ["PDS_URL"], + } + print(log_details, flush=True) from requests import get else: + log_details = { + "description": "Using stub PDS client", + "pds_url": "stub", + } + print(log_details, flush=True) from stubs.pds.stub import PdsFhirApiStub pds = PdsFhirApiStub() diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 13b376e2..c9bf5d1c 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -37,8 +37,20 @@ # use the stub client STUB_PROVIDER = os.environ["PROVIDER_URL"].lower() == "stub" if not STUB_PROVIDER: + log_details = { + "description": "Using real GP Provider client", + # TODO: There is a nuance to this: the URL is actually from SDS. + # Find a better wording for this. + "provider_url": os.environ["PROVIDER_URL"], + } + print(log_details, flush=True) from requests import post else: + log_details = { + "description": "Using stub GP Provider client", + "provider_url": "stub", + } + print(log_details, flush=True) from stubs.provider.stub import GpProviderStub provider_stub = GpProviderStub() diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index 436a613f..ce5b64c8 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -26,8 +26,18 @@ # use the stub client STUB_SDS = os.environ["SDS_URL"].lower() == "stub" if not STUB_SDS: + log_details = { + "description": "Using real SDS client", + "sds_url": os.environ["SDS_URL"], + } + print(log_details, flush=True) from requests import get else: + log_details = { + "description": "Using stub SDS client", + "sds_url": "stub", + } + print(log_details, flush=True) from stubs import SdsFhirApiStub sds = SdsFhirApiStub() From dea2c64f3629c2df08a694f9ff4c85c4b0ccf350 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:23:19 +0100 Subject: [PATCH 05/15] Use env vars and pass those through an .env file in the make deploy command. --- .github/actions/start-app/action.yaml | 2 +- .github/instructions/copilot-instructions.md | 2 +- Makefile | 22 ++++----------- README.md | 5 ++-- gateway-api/src/gateway_api/app.py | 22 +++++++++++++-- gateway-api/src/gateway_api/conftest.py | 7 +++-- gateway-api/tests/README.md | 4 ++- .../integration/test_get_structured_record.py | 5 ++++ infrastructure/images/gateway-api/Dockerfile | 7 ++--- scripts/env/env.mk | 28 +++++++++++++++++++ scripts/init.mk | 1 + 11 files changed, 74 insertions(+), 31 deletions(-) create mode 100644 scripts/env/env.mk diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml index 27b0ba56..d9ee75ca 100644 --- a/.github/actions/start-app/action.yaml +++ b/.github/actions/start-app/action.yaml @@ -4,7 +4,7 @@ inputs: deploy-command: description: "Command to start app" required: false - default: "make deploy" + default: "make deploy-dev" health-path: description: "Health check path" required: false diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md index 9d2ebc7e..00004bd4 100644 --- a/.github/instructions/copilot-instructions.md +++ b/.github/instructions/copilot-instructions.md @@ -8,7 +8,7 @@ This repository is for handling HTTP requests from "Consumer systems" and forwar We use other NHSE services to assist in the validation and processing of the requests including PDS FHIR API for obtaining GP practice codes for the patient, SDS FHIR API for obtaining the "Provider system" details of that GP practice and Healthcare Worker FHIR API for obtaining details of the requesting practitioner using the "Consumer System" that will then be added to the forwarded request. -`make deploy` will build and start a container running Gateway API at `localhost:5000`. +`make deploy-dev` will build and start a container running Gateway API at `localhost:5000`. After deploying the container locally, `make test` will run all tests and capture their coverage. Note: env variables control the use of stubs for the PDS FHIR API, SDS FHIR API, Healthcare Worker FHIR API and Provider system services. diff --git a/Makefile b/Makefile index a398a11c..b4ed852b 100644 --- a/Makefile +++ b/Makefile @@ -66,29 +66,19 @@ build: build-gateway-api # Build the project artefact @Pipeline publish: # Publish the project artefact @Pipeline # TODO [GPCAPIM-283]: Implement the artefact publishing step -deploy: clean build # Deploy the project artefact to the target environment @Pipeline +deploy: clean build # Build project artefact and deploy locally @Pipeline @$(docker) network inspect gateway-local >/dev/null 2>&1 || $(docker) network create gateway-local # Build up list of environment variables to pass to the container - @ENVIRONMENT_STRING="" ; \ - if [[ -n "$${PROVIDER_URL}" ]]; then \ - ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e PROVIDER_URL=$${PROVIDER_URL}" ; \ - fi ; \ - if [[ -n "$${PDS_URL}" ]]; then \ - ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e PDS_URL=$${PDS_URL}" ; \ - fi ; \ - if [[ -n "$${SDS_URL}" ]]; then \ - ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e SDS_URL=$${SDS_URL}" ; \ - fi ; \ - if [[ -n "$${CDG_DEBUG}" ]]; then \ - ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e CDG_DEBUG=$${CDG_DEBUG}" ; \ - fi ; \ if [[ -n "$${IN_BUILD_CONTAINER}" ]]; then \ echo "Starting using local docker network ..." ; \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local $${ENVIRONMENT_STRING} -d ${IMAGE_NAME} ; \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local --env-file .env -d ${IMAGE_NAME} ; \ else \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 $${ENVIRONMENT_STRING} -d ${IMAGE_NAME} ; \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --env-file .env -d ${IMAGE_NAME} ; \ fi +deploy-%: # Build project artefact and deploy locally as specified environment - mandatory: name=[name of the environment, e.g. 'dev'] @Pipeline + make env-$* deploy + clean:: stop # Clean-up project resources (main) @Operations @echo "Removing Gateway API container..." @$(docker) rm gateway-api || echo "No Gateway API container currently exists." diff --git a/README.md b/README.md index 25805e54..c9767283 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,8 @@ The project uses `make` targets to build, deploy, and manage the application. Ru | --- | --- | | `make dependencies` | Install all project dependencies via Poetry | | `make build` | Type-check, package, and build the Docker image | -| `make deploy` | Build and start the Gateway API container at `localhost:5000` | +| `make deploy` | Build and start the Gateway API container using the environment variables defined in `.env` | +| `make deploy-*` | Create the `.env` file for the `` environment, then build and start the Gateway API container with those variables. | | `make clean` | Stop and remove the Gateway API container | | `make config` | Configure the development environment | @@ -155,7 +156,7 @@ The full API schema is defined in [gateway-api/openapi.yaml](gateway-api/openapi | `PDS_URL` | The URL for the PDS FHIR API; set as `stub` to use development stub. | | `SDS_URL` | The URL for the SDS FHIR API; set as `stub` to use development stub. | | `PROVIDER_URL` | The URL for the GP Provider; set as `stub` to use development stub. | -| `CDG_DEBUG` | `true`, Return additional debug information when the call to the GP provider returns an error. Note if set true causes the unit tests to fail, because expected return values are changed. | +| `CDG_DEBUG` | `true`, return additional debug information when the call to the GP provider returns an error. | Environment variables also control whether stubs are used in place of the real PDS, SDS, and Provider services during local development. diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 4edd0f83..93a1b8e1 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -19,7 +19,6 @@ 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 @@ -27,7 +26,6 @@ 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) @@ -51,6 +49,23 @@ def log_error(error: AbstractCDGError) -> None: app.logger.error(log_details) +def log_env_vars() -> None: + log_details = { + "description": "Initializing Flask app", + "env_vars": os.environ.items(), + } + app.logger.info(log_details) + + +def log_starting_app(host: str, port: int) -> None: + log_details = { + "description": "Starting Flask app", + "host": host, + "port": port, + } + app.logger.info(log_details) + + @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Response: log_request_received(request) @@ -86,6 +101,7 @@ def health_check() -> dict[str, str]: if __name__ == "__main__": + log_env_vars() host, port = get_app_host(), get_app_port() - print(f"Version: {os.getenv('COMMIT_VERSION')}") + log_starting_app(host, port) app.run(host=host, port=port) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 77c5ddbe..a0fa64b1 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -1,12 +1,12 @@ """Pytest configuration and shared fixtures for gateway API tests.""" import json +import os from dataclasses import dataclass from typing import Any import pytest import requests -from dotenv import find_dotenv, load_dotenv from fhir.constants import FHIRSystem from flask import Request from requests.structures import CaseInsensitiveDict @@ -14,7 +14,10 @@ from gateway_api.clinical_jwt import JWT -load_dotenv(find_dotenv(usecwd=True)) +# TODO: Do this better. +os.environ["PDS_URL"] = "stub" +os.environ["PROVIDER_URL"] = "not-stub" +os.environ["SDS_URL"] = "stub" @dataclass diff --git a/gateway-api/tests/README.md b/gateway-api/tests/README.md index 7a86fd15..77e6dac0 100644 --- a/gateway-api/tests/README.md +++ b/gateway-api/tests/README.md @@ -27,7 +27,7 @@ tests/ > [!NOTE]
> When running tests the following environment variables need to be provided: > -> - `BASE_URL` - defines the protocol, hostname and port that should used to access the running APIs. Should be included as a URL in the format ::, for example "" if the APIs are available on the "localhost" host via HTTP using port 5000. If running locally in a dev container, using gateway-api as the host will allow the tests to communicate with an instance launched from `make deploy`. +> - `BASE_URL` - defines the protocol, hostname and port that should used to access the running APIs. Should be included as a URL in the format ::, for example "" if the APIs are available on the "localhost" host via HTTP using port 5000. If running locally in a dev container, using gateway-api as the host will allow the tests to communicate with an instance launched from `make deploy-dev`. > - `HOST` - defines the hostname that should be used to access the running APIs. This should match the host portion of the URL provided in the `BASE_URL` environment variable above. ### Install Dependencies (if not using Dev container) @@ -67,6 +67,8 @@ pytest gateway-api/tests/schema/ -v Run `make deploy` before running any tests that hit the real API. +This command requires a `.env` file to set the app's behaviour - see `env.mk` on how to make `.env` files. + - **Requires deployed/running app (`make deploy`):** - Acceptance tests (`tests/acceptance/`) - Integration tests (`tests/integration/`) diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 480ca4f5..752a154f 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -150,6 +150,11 @@ def test_502_status_code_return_when_provider_returns_error( def test_internal_server_error_message_returned_when_provider_returns_error( self, response_when_provider_returns_error: Response ) -> None: + """ + WARNING: This can fail if the CDG_DEBUG env var is set to true in the + deployed app. + TODO [GPCAPIM-353]: Ensure integration tests are not affected by CDG_DEBUG + """ expected = { "resourceType": "OperationOutcome", "issue": [ diff --git a/infrastructure/images/gateway-api/Dockerfile b/infrastructure/images/gateway-api/Dockerfile index 527e0f52..083fa70b 100644 --- a/infrastructure/images/gateway-api/Dockerfile +++ b/infrastructure/images/gateway-api/Dockerfile @@ -24,15 +24,12 @@ RUN if [ "$INCLUDE_DEV_CERTS" = "true" ] && [ -d /resources/dev-certificates ]; WORKDIR /resources/build/gateway-api ENV PYTHONPATH=/resources/build/gateway-api -ENV FLASK_HOST="0.0.0.0" -ENV FLASK_PORT="8080" -ENV PDS_URL="stub" -ENV PROVIDER_URL="stub" -ENV SDS_URL="stub" ARG COMMIT_VERSION +# TODO: Do we want to do something with this env var? ENV COMMIT_VERSION=$COMMIT_VERSION ARG BUILD_DATE +# TODO: Do we want to do something with this env var? ENV BUILD_DATE=$BUILD_DATE USER gateway_api_user diff --git a/scripts/env/env.mk b/scripts/env/env.mk new file mode 100644 index 00000000..6c93b1f7 --- /dev/null +++ b/scripts/env/env.mk @@ -0,0 +1,28 @@ +.PHONY: env env-% _env + +env: + make env-dev # TODO: Make this interactive + +env-%: # Create .env file with environment variables - optional: name=[name of the environment, e.g. 'dev'] @Configuration + make _env name="$*" # TODO: Implement difference envs + +_env: + echo "# ENVIRONMENT VARIABLES" > .env + echo "# ---------------------" >> .env + echo "# This file is generated by 'make env'/'make env-*'." >> .env + + echo "FLASK_PORT=8080" >> .env + echo "FLASK_HOST=0.0.0.0" >> .env + + echo "BASE_URL=http://gateway-api:8080" >> .env + + echo "PDS_URL=stub" >> .env + echo "PROVIDER_URL=stub" >> .env + echo "SDS_URL=stub" >> .env + + echo "CDG_DEBUG=false" >> .env + +${VERBOSE}.SILENT: \ + _env \ + env \ + env-% \ diff --git a/scripts/init.mk b/scripts/init.mk index bcd51800..39a28d18 100644 --- a/scripts/init.mk +++ b/scripts/init.mk @@ -2,6 +2,7 @@ include scripts/docker/docker.mk include scripts/tests/test.mk +include scripts/env/env.mk -include scripts/terraform/terraform.mk # ============================================================================== From 42043084821a79c56b597c1df2af9ee4703be82d Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:34:58 +0100 Subject: [PATCH 06/15] Make number go up. --- gateway-api/src/gateway_api/app.py | 16 ++++--- gateway-api/src/gateway_api/conftest.py | 23 ++++++++++ gateway-api/src/gateway_api/test_app.py | 56 ++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 93a1b8e1..caef905a 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -15,6 +15,13 @@ app.logger.setLevel("INFO") +def start_app(app: Flask) -> None: + log_env_vars(app) + host, port = get_app_host(), get_app_port() + log_starting_app(app, host, port) + app.run(host=host, port=port) + + def get_app_host() -> str: host = os.getenv("FLASK_HOST") if host is None: @@ -49,7 +56,7 @@ def log_error(error: AbstractCDGError) -> None: app.logger.error(log_details) -def log_env_vars() -> None: +def log_env_vars(app: Flask) -> None: log_details = { "description": "Initializing Flask app", "env_vars": os.environ.items(), @@ -57,7 +64,7 @@ def log_env_vars() -> None: app.logger.info(log_details) -def log_starting_app(host: str, port: int) -> None: +def log_starting_app(app: Flask, host: str, port: int) -> None: log_details = { "description": "Starting Flask app", "host": host, @@ -101,7 +108,4 @@ def health_check() -> dict[str, str]: if __name__ == "__main__": - log_env_vars() - host, port = get_app_host(), get_app_port() - log_starting_app(host, port) - app.run(host=host, port=port) + start_app(app) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index a0fa64b1..ee8c581a 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -3,6 +3,7 @@ import json import os from dataclasses import dataclass +from types import TracebackType from typing import Any import pytest @@ -20,6 +21,28 @@ os.environ["SDS_URL"] = "stub" +class NewEnvVars: + def __init__(self, new_env_vars: dict[str, str]) -> None: + self.new_env_vars = new_env_vars + self.original_env_vars = {key: os.environ.get(key) for key in new_env_vars} + + def __enter__(self) -> "NewEnvVars": + os.environ.update(self.new_env_vars) + return self + + def __exit__( + self, + _type: type[BaseException] | None, + _value: BaseException | None, + _traceback: TracebackType | None, + ) -> None: + for key, value in self.original_env_vars.items(): + if value is not None: + os.environ[key] = value + else: + del os.environ[key] + + @dataclass class FakeResponse: """ diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index e6f808e0..dd3f272e 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -12,7 +12,15 @@ from flask.testing import FlaskClient from pytest_mock import MockerFixture -from gateway_api.app import app, get_app_host, get_app_port +from gateway_api.app import ( + app, + get_app_host, + get_app_port, + log_env_vars, + log_starting_app, + start_app, +) +from gateway_api.conftest import NewEnvVars @pytest.fixture @@ -47,6 +55,52 @@ def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None: with pytest.raises(RuntimeError): _ = get_app_port() + def test_logging_app_startup_details_on_app_initialization( + self, mocker: MockerFixture + ) -> None: + log_info_mock = mocker.patch.object(app.logger, "info") + + host = "test_host" + port = 1234 + log_starting_app(app, host, port) + + # Check that the app startup details were logged + log_info_mock.assert_called_with( + { + "description": "Starting Flask app", + "host": host, + "port": port, + } + ) + + def test_logging_environment_variables_on_app_initialization( + self, mocker: MockerFixture + ) -> None: + log_info_mock = mocker.patch.object(app.logger, "info") + + log_env_vars(app) + + # Check that the environment variables were logged + log_info_mock.assert_called_with( + { + "description": "Initializing Flask app", + "env_vars": os.environ.items(), + } + ) + + def test_start_app_logs_startup_details(self) -> None: + test_app = Mock() + + test_env_vars = { + "FLASK_HOST": "test_host", + "FLASK_PORT": "1234", + } + + with NewEnvVars(test_env_vars): + start_app(test_app) + + test_app.run.assert_called_with(host="test_host", port=1234) + class TestGetStructuredRecord: @pytest.mark.usefixtures("mock_positive_return_value_from_controller_run") From 899ba50a6df6d6ef222db552d5052230d950e750 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:34:56 +0100 Subject: [PATCH 07/15] Make number go up. --- .github/actions/start-app/action.yaml | 1 + gateway-api/src/gateway_api/pds/client.py | 14 ++++---------- gateway-api/src/gateway_api/provider/client.py | 16 ++++------------ gateway-api/src/gateway_api/sds/client.py | 14 ++++---------- scripts/tests/test.mk | 1 + 5 files changed, 14 insertions(+), 32 deletions(-) diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml index d9ee75ca..a50a727e 100644 --- a/.github/actions/start-app/action.yaml +++ b/.github/actions/start-app/action.yaml @@ -4,6 +4,7 @@ inputs: deploy-command: description: "Command to start app" required: false + # TODO: rename this make deploy-ci? default: "make deploy-dev" health-path: description: "Health check path" diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 221d5672..4e255f41 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -33,18 +33,8 @@ STUB_PDS = os.environ["PDS_URL"].lower() == "stub" if not STUB_PDS: - log_details = { - "description": "Using real PDS client", - "pds_url": os.environ["PDS_URL"], - } - print(log_details, flush=True) from requests import get else: - log_details = { - "description": "Using stub PDS client", - "pds_url": "stub", - } - print(log_details, flush=True) from stubs.pds.stub import PdsFhirApiStub pds = PdsFhirApiStub() @@ -92,6 +82,8 @@ def __init__( self.timeout = timeout self.ignore_dates = ignore_dates + # TODO: Add logging to show stub behaviour + def _build_headers( self, request_id: str | None = None, @@ -132,6 +124,7 @@ def search_patient_by_nhs_number( url = f"{self.base_url}/Patient/{nhs_number}" # This normally calls requests.get, but if PDS_URL is set it uses the stub. + # TODO: Log request to confirm client behaviour response = get( url, headers=headers, @@ -141,6 +134,7 @@ def search_patient_by_nhs_number( try: response.raise_for_status() + # TODO: Log response to confirm stub behaviour except requests.HTTPError as err: raise PdsRequestFailedError(error_reason=err.response.reason) from err diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index c9bf5d1c..5f403465 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -37,20 +37,8 @@ # use the stub client STUB_PROVIDER = os.environ["PROVIDER_URL"].lower() == "stub" if not STUB_PROVIDER: - log_details = { - "description": "Using real GP Provider client", - # TODO: There is a nuance to this: the URL is actually from SDS. - # Find a better wording for this. - "provider_url": os.environ["PROVIDER_URL"], - } - print(log_details, flush=True) from requests import post else: - log_details = { - "description": "Using stub GP Provider client", - "provider_url": "stub", - } - print(log_details, flush=True) from stubs.provider.stub import GpProviderStub provider_stub = GpProviderStub() @@ -95,6 +83,8 @@ def __init__( self.token = token self.endpoint_path = endpoint_path + # TODO: Add logging to show stub behaviour + def _build_headers(self, trace_id: str) -> dict[str, str]: """ Build the headers required for the GPProvider FHIR API request. @@ -129,6 +119,7 @@ def access_structured_record( base_endpoint = self.provider_endpoint.rstrip("/") + "/" url = urljoin(base_endpoint, self.endpoint_path) + # TODO: Log request to confirm client behaviour response = post( url, headers=headers, @@ -138,6 +129,7 @@ def access_structured_record( try: response.raise_for_status() + # TODOL: Log response to confirm stub behaviour except HTTPError as err: # TODO: GPCAPIM-353 Consider what error information we want to return here. # Post-steel-thread we probably want to log rather than dumping like this diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index ce5b64c8..871e858d 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -26,18 +26,8 @@ # use the stub client STUB_SDS = os.environ["SDS_URL"].lower() == "stub" if not STUB_SDS: - log_details = { - "description": "Using real SDS client", - "sds_url": os.environ["SDS_URL"], - } - print(log_details, flush=True) from requests import get else: - log_details = { - "description": "Using stub SDS client", - "sds_url": "stub", - } - print(log_details, flush=True) from stubs import SdsFhirApiStub sds = SdsFhirApiStub() @@ -101,6 +91,8 @@ def __init__( ) self.api_key = self._get_api_key() + # TODO: Add logging to show stub behaviour + def _build_headers(self, correlation_id: str | None = None) -> dict[str, str]: """ Build mandatory and optional headers for an SDS request. @@ -204,6 +196,7 @@ def _query_sds( if party_key is not None: params["identifier"].append(f"{FHIRSystem.NHS_MHS_PARTY_KEY}|{party_key}") + # TODO: Log request to confirm stub behaviour response = get( url, headers=headers, @@ -213,6 +206,7 @@ def _query_sds( try: response.raise_for_status() + # TODO: Log response to confirm stub behaviour except HTTPError as e: raise SdsRequestFailedError(error_reason=str(e)) from e diff --git a/scripts/tests/test.mk b/scripts/tests/test.mk index fd822b1c..a49dd659 100644 --- a/scripts/tests/test.mk +++ b/scripts/tests/test.mk @@ -69,6 +69,7 @@ test: # Run all the test tasks @Testing test-acceptance\ test-schema +# TODO: have _test target="proxy"/"local"? _test: set -e script="./scripts/tests/${name}.sh" From 4f47fa950f39ed931dcf4153d123fcac90a2a3dc Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:12:53 +0100 Subject: [PATCH 08/15] Make number go up. --- gateway-api/src/gateway_api/app.py | 27 +++++++++++++++--- gateway-api/src/gateway_api/conftest.py | 18 ++++++------ gateway-api/src/gateway_api/test_app.py | 38 +++++++++---------------- 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index caef905a..12ce1ebe 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -1,5 +1,7 @@ import os import traceback +from collections.abc import Callable +from typing import Any from flask import Flask, Request, request from flask.wrappers import Response @@ -17,9 +19,22 @@ def start_app(app: Flask) -> None: log_env_vars(app) - host, port = get_app_host(), get_app_port() - log_starting_app(app, host, port) - app.run(host=host, port=port) + app_host = get_env_var("FLASK_HOST", str) + app_port = get_env_var("FLASK_PORT", int) + pds_base_url = get_env_var("PDS_URL", str) + sds_base_url = get_env_var("SDS_URL", str) + log_starting_app(app, app_host, app_port, pds_base_url, sds_base_url) + app.run(host=app_host, port=app_port) + + +def get_env_var(name: str, loader: Callable[[str], Any]) -> Any: + value = os.getenv(name) + if value is None: + raise RuntimeError(f"{name} environment variable is not set.") + try: + return loader(value) + except Exception as e: + raise RuntimeError(f"Error loading {name} environment variable: {e}") from e def get_app_host() -> str: @@ -64,11 +79,15 @@ def log_env_vars(app: Flask) -> None: app.logger.info(log_details) -def log_starting_app(app: Flask, host: str, port: int) -> None: +def log_starting_app( + app: Flask, host: str, port: int, pds_base_url: str, sds_base_url: str +) -> None: log_details = { "description": "Starting Flask app", "host": host, "port": port, + "pds_base_url": pds_base_url, + "sds_base_url": sds_base_url, } app.logger.info(log_details) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index ee8c581a..93ccd86d 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -22,12 +22,18 @@ class NewEnvVars: - def __init__(self, new_env_vars: dict[str, str]) -> None: + def __init__(self, new_env_vars: dict[str, str | None]) -> None: self.new_env_vars = new_env_vars - self.original_env_vars = {key: os.environ.get(key) for key in new_env_vars} + self.original_env_vars = { + key: os.environ.get(key) for key in new_env_vars if key in os.environ + } def __enter__(self) -> "NewEnvVars": - os.environ.update(self.new_env_vars) + for key, value in self.new_env_vars.items(): + if value is None and key in os.environ: + del os.environ[key] + elif value is not None: + os.environ[key] = value return self def __exit__( @@ -36,11 +42,7 @@ def __exit__( _value: BaseException | None, _traceback: TracebackType | None, ) -> None: - for key, value in self.original_env_vars.items(): - if value is not None: - os.environ[key] = value - else: - del os.environ[key] + os.environ.update(self.original_env_vars) @dataclass diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index dd3f272e..474de189 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -14,8 +14,7 @@ from gateway_api.app import ( app, - get_app_host, - get_app_port, + get_env_var, log_env_vars, log_starting_app, start_app, @@ -31,29 +30,14 @@ def client() -> Generator[FlaskClient[Flask]]: class TestAppInitialization: - def test_get_app_host_returns_set_host_name(self) -> None: - os.environ["FLASK_HOST"] = "host_is_set" + def test_get_env_var_when_env_var_is_set(self) -> None: + with NewEnvVars({"FLASK_HOST": "host_is_set"}): + actual = get_env_var("FLASK_HOST", str) + assert actual == "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() + def test_get_env_var_raises_runtime_error_if_env_var_not_set(self) -> None: + with NewEnvVars({"FLASK_HOST": None}), pytest.raises(RuntimeError): + _ = get_env_var("FLASK_HOST", str) def test_logging_app_startup_details_on_app_initialization( self, mocker: MockerFixture @@ -62,7 +46,9 @@ def test_logging_app_startup_details_on_app_initialization( host = "test_host" port = 1234 - log_starting_app(app, host, port) + pds_base_url = "test_pds_url" + sds_base_url = "test_sds_url" + log_starting_app(app, host, port, pds_base_url, sds_base_url) # Check that the app startup details were logged log_info_mock.assert_called_with( @@ -70,6 +56,8 @@ def test_logging_app_startup_details_on_app_initialization( "description": "Starting Flask app", "host": host, "port": port, + "pds_base_url": pds_base_url, + "sds_base_url": sds_base_url, } ) From d80e86e2739a5a2216683faf6e49b51b5eca2a1f Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:13:31 +0100 Subject: [PATCH 09/15] Make explicit environment variables being passed to container. --- Makefile | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index b4ed852b..0384262a 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,8 @@ publish: # Publish the project artefact @Pipeline deploy: clean build # Build project artefact and deploy locally @Pipeline @$(docker) network inspect gateway-local >/dev/null 2>&1 || $(docker) network create gateway-local - # Build up list of environment variables to pass to the container + @echo "Using environment variables from .env" + @cat .env if [[ -n "$${IN_BUILD_CONTAINER}" ]]; then \ echo "Starting using local docker network ..." ; \ $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local --env-file .env -d ${IMAGE_NAME} ; \ diff --git a/README.md b/README.md index c9767283..0dfb7912 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,9 @@ The project uses `make` targets to build, deploy, and manage the application. Ru | `make dependencies` | Install all project dependencies via Poetry | | `make build` | Type-check, package, and build the Docker image | | `make deploy` | Build and start the Gateway API container using the environment variables defined in `.env` | -| `make deploy-*` | Create the `.env` file for the `` environment, then build and start the Gateway API container with those variables. | | `make clean` | Stop and remove the Gateway API container | | `make config` | Configure the development environment | +| `make env` | Create a `.env` to be consumed when starting the app, e.g. `make deploy` | ### API Endpoints From 67ef071ff72b226be8e7d8be5b60f6226103fd89 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:25:01 +0100 Subject: [PATCH 10/15] Make mypy happy again. --- gateway-api/src/gateway_api/conftest.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 93ccd86d..f38f7e88 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -2,6 +2,7 @@ import json import os +from collections.abc import Mapping from dataclasses import dataclass from types import TracebackType from typing import Any @@ -22,11 +23,12 @@ class NewEnvVars: - def __init__(self, new_env_vars: dict[str, str | None]) -> None: + def __init__(self, new_env_vars: Mapping[str, str | None]) -> None: self.new_env_vars = new_env_vars - self.original_env_vars = { - key: os.environ.get(key) for key in new_env_vars if key in os.environ - } + self.original_env_vars = {} + for key in new_env_vars: + if key in os.environ: + self.original_env_vars[key] = os.environ[key] def __enter__(self) -> "NewEnvVars": for key, value in self.new_env_vars.items(): From b289500228f6eec2e0147e176b9eca4abebd496d Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:51:59 +0100 Subject: [PATCH 11/15] Place env vars in to app config. --- .vscode/cspell-dictionary.txt | 1 + gateway-api/pyproject.toml | 2 +- gateway-api/src/gateway_api/app.py | 45 ++++++++++--------------- gateway-api/src/gateway_api/test_app.py | 43 ++++++++++++----------- 4 files changed, 43 insertions(+), 48 deletions(-) diff --git a/.vscode/cspell-dictionary.txt b/.vscode/cspell-dictionary.txt index 1a219d09..6cb2d09a 100644 --- a/.vscode/cspell-dictionary.txt +++ b/.vscode/cspell-dictionary.txt @@ -1,4 +1,5 @@ asid +conftest fhir getstructuredrecord GPCAPIM diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index cf3cac15..7770de11 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -23,7 +23,7 @@ packages = [{include = "gateway_api", from = "src"}, [tool.coverage.run] relative_files = true -omit = ["*/tests/*", "*/features/*", "*/test_*.py"] +omit = ["*/tests/*", "*/features/*", "*/test_*.py", "*/conftest.py"] [tool.coverage.paths] source = [ diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 12ce1ebe..62bb21ab 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -19,12 +19,19 @@ def start_app(app: Flask) -> None: log_env_vars(app) - app_host = get_env_var("FLASK_HOST", str) - app_port = get_env_var("FLASK_PORT", int) - pds_base_url = get_env_var("PDS_URL", str) - sds_base_url = get_env_var("SDS_URL", str) - log_starting_app(app, app_host, app_port, pds_base_url, sds_base_url) - app.run(host=app_host, port=app_port) + configure_app(app) + log_starting_app(app) + app.run(host=app.config["FLASK_HOST"], port=app.config["FLASK_PORT"]) + + +def configure_app(app: Flask) -> None: + config = { + "FLASK_HOST": get_env_var("FLASK_HOST", str), + "FLASK_PORT": get_env_var("FLASK_PORT", int), + "PDS_URL": get_env_var("PDS_URL", str), + "SDS_URL": get_env_var("SDS_URL", str), + } + app.config.update(config) def get_env_var(name: str, loader: Callable[[str], Any]) -> Any: @@ -37,20 +44,6 @@ def get_env_var(name: str, loader: Callable[[str], Any]) -> Any: raise RuntimeError(f"Error loading {name} environment variable: {e}") from e -def get_app_host() -> str: - host = os.getenv("FLASK_HOST") - if host is None: - raise RuntimeError("FLASK_HOST environment variable is not set.") - return host - - -def get_app_port() -> int: - port = os.getenv("FLASK_PORT") - if port is None: - raise RuntimeError("FLASK_PORT environment variable is not set.") - return int(port) - - def log_request_received(request: Request) -> None: log_details = { "description": "Received request", @@ -79,15 +72,13 @@ def log_env_vars(app: Flask) -> None: app.logger.info(log_details) -def log_starting_app( - app: Flask, host: str, port: int, pds_base_url: str, sds_base_url: str -) -> None: +def log_starting_app(app: Flask) -> None: log_details = { "description": "Starting Flask app", - "host": host, - "port": port, - "pds_base_url": pds_base_url, - "sds_base_url": sds_base_url, + "host": app.config["FLASK_HOST"], + "port": app.config["FLASK_PORT"], + "pds_base_url": app.config["PDS_URL"], + "sds_base_url": app.config["SDS_URL"], } app.logger.info(log_details) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 474de189..336212e0 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -14,9 +14,9 @@ from gateway_api.app import ( app, + configure_app, get_env_var, log_env_vars, - log_starting_app, start_app, ) from gateway_api.conftest import NewEnvVars @@ -39,27 +39,29 @@ def test_get_env_var_raises_runtime_error_if_env_var_not_set(self) -> None: with NewEnvVars({"FLASK_HOST": None}), pytest.raises(RuntimeError): _ = get_env_var("FLASK_HOST", str) - def test_logging_app_startup_details_on_app_initialization( - self, mocker: MockerFixture - ) -> None: - log_info_mock = mocker.patch.object(app.logger, "info") + def test_get_env_var_raises_runtime_error_if_loader_fails(self) -> None: + with NewEnvVars({"FLASK_PORT": "not_an_int"}), pytest.raises(RuntimeError): + _ = get_env_var("FLASK_PORT", int) - host = "test_host" - port = 1234 - pds_base_url = "test_pds_url" - sds_base_url = "test_sds_url" - log_starting_app(app, host, port, pds_base_url, sds_base_url) + def test_configure_app(self) -> None: + test_app = Mock() + config = { + "FLASK_HOST": "test_host", + "FLASK_PORT": "1234", + "PDS_URL": "test_pds_url", + "SDS_URL": "test_sds_url", + } - # Check that the app startup details were logged - log_info_mock.assert_called_with( - { - "description": "Starting Flask app", - "host": host, - "port": port, - "pds_base_url": pds_base_url, - "sds_base_url": sds_base_url, - } - ) + with NewEnvVars(config): + configure_app(test_app) + + expected = { + "FLASK_HOST": "test_host", + "FLASK_PORT": 1234, + "PDS_URL": "test_pds_url", + "SDS_URL": "test_sds_url", + } + test_app.config.update.assert_called_with(expected) def test_logging_environment_variables_on_app_initialization( self, mocker: MockerFixture @@ -78,6 +80,7 @@ def test_logging_environment_variables_on_app_initialization( def test_start_app_logs_startup_details(self) -> None: test_app = Mock() + test_app.config = {} test_env_vars = { "FLASK_HOST": "test_host", From 69a2929a3e3daa5ef353389f9c624f615e19e43a Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:06:17 +0100 Subject: [PATCH 12/15] Pass URLs to controller. --- gateway-api/src/gateway_api/app.py | 4 ++- gateway-api/src/gateway_api/controller.py | 4 +-- gateway-api/src/gateway_api/test_app.py | 11 ++++++- .../src/gateway_api/test_controller.py | 29 ++++++++++++------- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 62bb21ab..49b4c412 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -90,7 +90,9 @@ def get_structured_record() -> Response: response.mirror_headers(request) try: get_structured_record_request = GetStructuredRecordRequest(request) - controller = Controller() + controller = Controller( + pds_base_url=app.config["PDS_URL"], sds_base_url=app.config["SDS_URL"] + ) provider_response = controller.run(request=get_structured_record_request) response.add_provider_response(provider_response) except AbstractCDGError as e: diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index ac37c16e..539bea71 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -27,8 +27,8 @@ class Controller: def __init__( self, - pds_base_url: str = PdsClient.SANDBOX_URL, - sds_base_url: str = SdsClient.SANDBOX_URL, + pds_base_url: str, + sds_base_url: str, timeout: int = 10, ) -> None: """ diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 336212e0..5edfbfb5 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -24,7 +24,16 @@ @pytest.fixture def client() -> Generator[FlaskClient[Flask]]: - app.config["TESTING"] = True + with NewEnvVars( + { + "FLASK_HOST": "localhost", + "FLASK_PORT": "5000", + "PDS_URL": "http://test-pds-url", + "SDS_URL": "http://test-sds-url", + } + ): + configure_app(app) + app.config["TESTING"] = True with app.test_client() as client: yield client diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index d6cfb1c1..38201c9a 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -42,12 +42,19 @@ def _create_patient(nhs_number: str, gp_ods_code: str | None) -> Patient: ) +def create_test_controller( + pds_base_url: str = "https://example.test/pds", + sds_base_url: str = "https://example.test/sds", +) -> Controller: + return Controller(pds_base_url=pds_base_url, sds_base_url=sds_base_url) + + def test_controller_run_happy_path_returns_200_status_code( mock_happy_path_get_structured_record_request: Request, ) -> None: request = GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) - controller = Controller() + controller = create_test_controller() actual_response = controller.run(request) assert actual_response.status_code == 200 @@ -59,7 +66,7 @@ def test_controller_run_happy_path_returns_returns_expected_body( ) -> None: request = GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) - controller = Controller() + controller = create_test_controller() actual_response = controller.run(request) assert actual_response.json() == valid_simple_response_payload @@ -74,7 +81,7 @@ def test_get_pds_details_returns_provider_ods_code_for_happy_path( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", return_value=_create_patient(nhs_number, "A12345"), ) - controller = Controller(pds_base_url="https://example.test/pds", timeout=7) + controller = create_test_controller() actual = controller._get_pds_details(auth_token, nhs_number) # noqa: SLF001 testing private method @@ -91,7 +98,7 @@ def test_get_pds_details_raises_no_current_provider_when_ods_code_missing_in_pds return_value=_create_patient(nhs_number, None), ) - controller = Controller() + controller = create_test_controller() with pytest.raises( NoCurrentProviderError, @@ -117,7 +124,7 @@ def test_get_sds_details_returns_consumer_and_provider_details_for_happy_path( side_effect=sds_results, ) - controller = Controller() + controller = create_test_controller() expected = ("ConsumerASID", "ProviderASID", "https://example.provider.org/endpoint") actual = controller._get_sds_details(consumer_ods, provider_ods) # noqa: SLF001 testing private method @@ -135,7 +142,7 @@ def test_get_sds_details_raises_no_organisation_found_when_sds_returns_none( return_value=no_results_for_provider, ) - controller = Controller() + controller = create_test_controller() with pytest.raises( NoOrganisationFoundError, @@ -157,7 +164,7 @@ def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_asid( return_value=blank_asid_sds_result, ) - controller = Controller() + controller = create_test_controller() with pytest.raises( NoAsidFoundError, @@ -180,7 +187,7 @@ def test_get_sds_details_raises_no_current_endpoint_when_sds_returns_empty_endpo return_value=blank_endpoint_sds_result, ) - controller = Controller() + controller = create_test_controller() with pytest.raises( NoCurrentEndpointError, @@ -207,7 +214,7 @@ def test_get_sds_details_raises_no_org_found_when_sds_returns_none_for_consumer( side_effect=[happy_path_provider_sds_result, none_result_for_consumer], ) - controller = Controller() + controller = create_test_controller() with pytest.raises( NoOrganisationFoundError, @@ -233,7 +240,7 @@ def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_consumer_as side_effect=[happy_path_provider_sds_result, consumer_asid_blank_sds_result], ) - controller = Controller() + controller = create_test_controller() with pytest.raises( NoAsidFoundError, @@ -337,7 +344,7 @@ def test_controller_creates_jwt_token_with_correct_claims( get_structured_record_request = GetStructuredRecordRequest(request) - controller = Controller() + controller = create_test_controller() controller.run(get_structured_record_request) # Verify that GpProviderClient was called and extract the JWT token From 5268215faabf9d02ff33e42d4f85267085679071 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:13:32 +0100 Subject: [PATCH 13/15] Remove default URLs --- gateway-api/src/gateway_api/pds/client.py | 2 +- .../src/gateway_api/pds/test_client.py | 12 ++++----- gateway-api/src/gateway_api/sds/client.py | 4 +-- .../src/gateway_api/sds/test_client.py | 26 +++++++++---------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 4e255f41..3ffa1199 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -73,7 +73,7 @@ class PdsClient: def __init__( self, auth_token: str, - base_url: str = SANDBOX_URL, + base_url: str, timeout: int = 10, ignore_dates: bool = False, ) -> None: diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index 0263ea89..414ceefc 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -24,7 +24,7 @@ def test_search_patient_by_nhs_number_happy_path( ) mocker.patch("gateway_api.pds.client.get", return_value=happy_path_response) - client = PdsClient(auth_token) + client = PdsClient(auth_token, base_url="https://test.com") patient = client.search_patient_by_nhs_number("9999999999") assert isinstance(patient, Patient) @@ -44,7 +44,7 @@ def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( ) mocker.patch("gateway_api.pds.client.get", return_value=gp_less_response) - client = PdsClient(auth_token) + client = PdsClient(auth_token, base_url="https://test.com") patient = client.search_patient_by_nhs_number("9999999999") assert isinstance(patient, Patient) @@ -67,7 +67,7 @@ def test_search_patient_by_nhs_number_sends_expected_headers( request_id = str(uuid4()) correlation_id = "corr-123" - client = PdsClient(auth_token) + client = PdsClient(auth_token, base_url="https://test.com") _ = client.search_patient_by_nhs_number( "9000000009", request_id=request_id, @@ -96,7 +96,7 @@ def test_search_patient_by_nhs_number_generates_request_id( "gateway_api.pds.client.get", return_value=happy_path_response ) - client = PdsClient(auth_token) + client = PdsClient(auth_token, base_url="https://test.com") _ = client.search_patient_by_nhs_number("9000000009") @@ -117,7 +117,7 @@ def test_search_patient_by_nhs_number_not_found_raises_error( reason="Not Found", ) mocker.patch("gateway_api.pds.client.get", return_value=not_found_response) - pds = PdsClient(auth_token) + pds = PdsClient(auth_token, base_url="https://test.com") with pytest.raises( PdsRequestFailedError, match="PDS FHIR API request failed: Not Found" @@ -140,7 +140,7 @@ def test_search_patient_by_nhs_number_missing_nhs_number_raises_error( ) mocker.patch("gateway_api.pds.client.get", return_value=response) - client = PdsClient(auth_token) + client = PdsClient(auth_token, base_url="https://test.com") with pytest.raises(PdsRequestFailedError) as error: client.search_patient_by_nhs_number("9999999999") diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index 871e858d..82c8c9e8 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -54,7 +54,7 @@ class SdsClient: **Stubbing**: - For testing, set the environment variable ``$SDS_URL` to use the + For testing, set the environment variable ``$SDS_URL`` to use the :class:`SdsFhirApiStub` instead of making real HTTP requests. **Usage example**:: @@ -80,7 +80,7 @@ class SdsClient: def __init__( self, - base_url: str = SANDBOX_URL, + base_url: str, timeout: int = 10, service_interaction_id: str | None = None, ) -> None: diff --git a/gateway-api/src/gateway_api/sds/test_client.py b/gateway-api/src/gateway_api/sds/test_client.py index 76019991..a10bc023 100644 --- a/gateway-api/src/gateway_api/sds/test_client.py +++ b/gateway-api/src/gateway_api/sds/test_client.py @@ -36,7 +36,7 @@ def test_sds_client_get_org_details_success( :param stub: SDS stub fixture. """ - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") result = client.get_org_details(ods_code="PROVIDER") @@ -113,7 +113,7 @@ def test_sds_client_get_org_details_with_endpoint( }, ) - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") result = client.get_org_details(ods_code="TESTORG") assert result is not None @@ -130,7 +130,7 @@ def test_sds_client_sends_correct_headers( :param stub: SDS stub fixture. :param mock_requests_get: Capture fixture for request details. """ - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") correlation_id = "test-correlation-123" client.get_org_details(ods_code="PROVIDER", correlation_id=correlation_id) @@ -152,7 +152,7 @@ def test_sds_client_timeout_parameter( :param stub: SDS stub fixture. :param mock_requests_get: Capture fixture for request details. """ - client = SdsClient(base_url=SdsClient.SANDBOX_URL, timeout=30) + client = SdsClient(base_url="https://test.com", timeout=30) client.get_org_details(ods_code="PROVIDER", timeout=60) @@ -195,7 +195,7 @@ def test_sds_client_custom_service_interaction_id( ) client = SdsClient( - base_url=SdsClient.SANDBOX_URL, + base_url="https://test.com", service_interaction_id=custom_interaction, ) @@ -221,7 +221,7 @@ def test_sds_client_builds_correct_device_query_params( :param stub: SDS stub fixture. :param mock_requests_get: Capture fixture for request details. """ - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") client.get_org_details(ods_code="PROVIDER") @@ -249,7 +249,7 @@ def test_sds_client_extract_party_key_from_device( :param mock_requests_get: Capture fixture for request details. """ # The default seeded PROVIDER device has a party key - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") stub.upsert_device( organization_ods="WITHPARTYKEY", @@ -322,7 +322,7 @@ def get_without_apikey( monkeypatch.setattr("gateway_api.sds.client.get", get_without_apikey) - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") with pytest.raises(SdsRequestFailedError, match="SDS FHIR API request failed"): client.get_org_details(ods_code="PROVIDER") @@ -365,7 +365,7 @@ def test_sds_client_endpoint_entry_without_address_returns_none( }, ) - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") result = client.get_org_details(ods_code="NOADDR") assert result.asid == "111111111111" @@ -381,9 +381,9 @@ def test_sds_client_empty_device_bundle_returns_none_asid() -> None: :param stub: SDS stub fixture. """ - client = SdsClient(base_url=SdsClient.SANDBOX_URL) - # "UNKNOWNORG" has no seeded devices, so the bundle entry list will be empty - result = client.get_org_details(ods_code="UNKNOWNORG", get_endpoint=False) + client = SdsClient(base_url="https://test.com") + # "UNKNOWN_ORG" has no seeded devices, so the bundle entry list will be empty + result = client.get_org_details(ods_code="UNKNOWN_ORG", get_endpoint=False) assert result.asid is None @@ -412,7 +412,7 @@ def test_sds_client_no_endpoint_bundle_entries_returns_none_endpoint( ) # Deliberately do not seed any endpoint for NOENDPOINT - client = SdsClient(base_url=SdsClient.SANDBOX_URL) + client = SdsClient(base_url="https://test.com") result = client.get_org_details(ods_code="NOENDPOINT") assert result.asid == "222222222222" From 3cdecdbb395849ab1f7fa24e33e485cefd5a7732 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:22:52 +0100 Subject: [PATCH 14/15] Tidy up. --- gateway-api/src/gateway_api/pds/client.py | 5 ----- gateway-api/src/gateway_api/sds/client.py | 4 ---- 2 files changed, 9 deletions(-) diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 3ffa1199..b848c9a6 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -65,11 +65,6 @@ class PdsClient: print(result) """ - # URLs for different PDS environments. Requires authentication to use live. - SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4" - INT_URL = "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" - PROD_URL = "https://api.service.nhs.uk/personal-demographics/FHIR/R4" - def __init__( self, auth_token: str, diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index 82c8c9e8..f4da64ae 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -71,10 +71,6 @@ class SdsClient: print(f"ASID: {result.asid}, Endpoint: {result.endpoint}") """ - # URLs for different SDS environments. Will move to a config file eventually. - SANDBOX_URL = "https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4" - INT_URL = "https://int.api.service.nhs.uk/spine-directory/FHIR/R4" - # Default service interaction ID for GP Connect DEFAULT_SERVICE_INTERACTION_ID = ACCESS_RECORD_STRUCTURED_INTERACTION_ID From ba67227f535d12f006d48903cbf79ca38bc47262 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:38:50 +0100 Subject: [PATCH 15/15] Improve make commands to build up env files for different environments. --- scripts/env/env.mk | 19 +++--------------- scripts/env/env.sh | 43 +++++++++++++++++++++++++++++++++++++++++ scripts/env/pds.sh | 21 ++++++++++++++++++++ scripts/env/provider.sh | 18 +++++++++++++++++ scripts/env/sds.sh | 21 ++++++++++++++++++++ 5 files changed, 106 insertions(+), 16 deletions(-) create mode 100755 scripts/env/env.sh create mode 100755 scripts/env/pds.sh create mode 100755 scripts/env/provider.sh create mode 100755 scripts/env/sds.sh diff --git a/scripts/env/env.mk b/scripts/env/env.mk index 6c93b1f7..db7a267e 100644 --- a/scripts/env/env.mk +++ b/scripts/env/env.mk @@ -1,26 +1,13 @@ .PHONY: env env-% _env env: - make env-dev # TODO: Make this interactive + make _env env-%: # Create .env file with environment variables - optional: name=[name of the environment, e.g. 'dev'] @Configuration - make _env name="$*" # TODO: Implement difference envs + make _env env="$*" # TODO: Implement difference envs _env: - echo "# ENVIRONMENT VARIABLES" > .env - echo "# ---------------------" >> .env - echo "# This file is generated by 'make env'/'make env-*'." >> .env - - echo "FLASK_PORT=8080" >> .env - echo "FLASK_HOST=0.0.0.0" >> .env - - echo "BASE_URL=http://gateway-api:8080" >> .env - - echo "PDS_URL=stub" >> .env - echo "PROVIDER_URL=stub" >> .env - echo "SDS_URL=stub" >> .env - - echo "CDG_DEBUG=false" >> .env + scripts/env/env.sh "$(env)" ${VERBOSE}.SILENT: \ _env \ diff --git a/scripts/env/env.sh b/scripts/env/env.sh new file mode 100755 index 00000000..772dc476 --- /dev/null +++ b/scripts/env/env.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +source scripts/env/pds.sh +source scripts/env/sds.sh +source scripts/env/provider.sh + +ENV_FILE=".env" + +if [ -f "$ENV_FILE" ]; then + printf "'%s' already exists. Overwrite? [y/N]: " "$ENV_FILE" + read -r answer + case "$answer" in + [yY]|[yY][eE][sS]) + echo "Overwriting $ENV_FILE..." + ;; + *) + echo "Aborted." + exit 0 + ;; + esac +fi + +PDS_URL=$(prompt_pds_url "$env") +SDS_URL=$(prompt_sds_url "$env") +PROVIDER_URL=$(prompt_provider_url "$env") + +cat > "$ENV_FILE" <