diff --git a/.github/actions/start-app/action.yaml b/.github/actions/start-app/action.yaml
index 27b0ba56..a50a727e 100644
--- a/.github/actions/start-app/action.yaml
+++ b/.github/actions/start-app/action.yaml
@@ -4,7 +4,8 @@ inputs:
deploy-command:
description: "Command to start app"
required: false
- default: "make deploy"
+ # TODO: rename this make deploy-ci?
+ 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 6999f07d..a8641e9e 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/.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
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/.vscode/cspell-dictionary.txt b/.vscode/cspell-dictionary.txt
index 004b5f0a..bfd7e5ce 100644
--- a/.vscode/cspell-dictionary.txt
+++ b/.vscode/cspell-dictionary.txt
@@ -73,6 +73,7 @@ NOSONAR
NPFIT
ONESHELL
opencollection
+orangebox
pipefail
PIPX
pkce
diff --git a/Makefile b/Makefile
index 16da7fd5..0384262a 100644
--- a/Makefile
+++ b/Makefile
@@ -66,29 +66,20 @@ 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 "$${STUB_PROVIDER}" ]]; then \
- ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e STUB_PROVIDER=$${STUB_PROVIDER}" ; \
- fi ; \
- if [[ -n "$${STUB_PDS}" ]]; then \
- ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e STUB_PDS=$${STUB_PDS}" ; \
- fi ; \
- if [[ -n "$${STUB_SDS}" ]]; then \
- ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e STUB_SDS=$${STUB_SDS}" ; \
- fi ; \
- if [[ -n "$${CDG_DEBUG}" ]]; then \
- ENVIRONMENT_STRING="$${ENVIRONMENT_STRING} -e CDG_DEBUG=$${CDG_DEBUG}" ; \
- fi ; \
+ @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 $${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 a237a767..0dfb7912 100644
--- a/README.md
+++ b/README.md
@@ -129,9 +129,10 @@ 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 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
@@ -152,10 +153,10 @@ 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. |
-| `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. |
+| `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. |
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/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 4edd0f83..49b4c412 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
@@ -15,20 +17,31 @@
app.logger.setLevel("INFO")
-def get_app_host() -> str:
- host = os.getenv("FLASK_HOST")
- if host is None:
- raise RuntimeError("FLASK_HOST environment variable is not set.")
- print(f"Starting Flask app on host: {host}")
- return host
+def start_app(app: Flask) -> None:
+ log_env_vars(app)
+ configure_app(app)
+ log_starting_app(app)
+ app.run(host=app.config["FLASK_HOST"], port=app.config["FLASK_PORT"])
-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)
+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:
+ 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 log_request_received(request: Request) -> None:
@@ -51,6 +64,25 @@ def log_error(error: AbstractCDGError) -> None:
app.logger.error(log_details)
+def log_env_vars(app: Flask) -> None:
+ log_details = {
+ "description": "Initializing Flask app",
+ "env_vars": os.environ.items(),
+ }
+ app.logger.info(log_details)
+
+
+def log_starting_app(app: Flask) -> None:
+ log_details = {
+ "description": "Starting Flask app",
+ "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)
+
+
@app.route("/patient/$gpc.getstructuredrecord", methods=["POST"])
def get_structured_record() -> Response:
log_request_received(request)
@@ -58,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:
@@ -86,6 +120,4 @@ def health_check() -> dict[str, str]:
if __name__ == "__main__":
- host, port = get_app_host(), get_app_port()
- print(f"Version: {os.getenv('COMMIT_VERSION')}")
- 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 ed7844f0..f38f7e88 100644
--- a/gateway-api/src/gateway_api/conftest.py
+++ b/gateway-api/src/gateway_api/conftest.py
@@ -1,7 +1,10 @@
"""Pytest configuration and shared fixtures for gateway API tests."""
import json
+import os
+from collections.abc import Mapping
from dataclasses import dataclass
+from types import TracebackType
from typing import Any
import pytest
@@ -13,6 +16,36 @@
from gateway_api.clinical_jwt import JWT
+# TODO: Do this better.
+os.environ["PDS_URL"] = "stub"
+os.environ["PROVIDER_URL"] = "not-stub"
+os.environ["SDS_URL"] = "stub"
+
+
+class NewEnvVars:
+ def __init__(self, new_env_vars: Mapping[str, str | None]) -> None:
+ self.new_env_vars = new_env_vars
+ 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():
+ 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__(
+ self,
+ _type: type[BaseException] | None,
+ _value: BaseException | None,
+ _traceback: TracebackType | None,
+ ) -> None:
+ os.environ.update(self.original_env_vars)
+
@dataclass
class FakeResponse:
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/pds/client.py b/gateway-api/src/gateway_api/pds/client.py
index 303671bf..b848c9a6 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:
@@ -67,15 +65,10 @@ 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,
- base_url: str = SANDBOX_URL,
+ base_url: str,
timeout: int = 10,
ignore_dates: bool = False,
) -> None:
@@ -84,6 +77,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,
@@ -123,7 +118,8 @@ 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.
+ # TODO: Log request to confirm client behaviour
response = get(
url,
headers=headers,
@@ -133,6 +129,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/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/provider/client.py b/gateway-api/src/gateway_api/provider/client.py
index 731bcafc..5f403465 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:
@@ -83,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.
@@ -117,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,
@@ -126,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 22de0009..f4da64ae 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**::
@@ -71,16 +71,12 @@ 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
def __init__(
self,
- base_url: str = SANDBOX_URL,
+ base_url: str,
timeout: int = 10,
service_interaction_id: str | None = None,
) -> None:
@@ -91,6 +87,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.
@@ -194,6 +192,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,
@@ -203,6 +202,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/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"
diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py
index e6f808e0..5edfbfb5 100644
--- a/gateway-api/src/gateway_api/test_app.py
+++ b/gateway-api/src/gateway_api/test_app.py
@@ -12,40 +12,94 @@
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,
+ configure_app,
+ get_env_var,
+ log_env_vars,
+ start_app,
+)
+from gateway_api.conftest import NewEnvVars
@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
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"
+
+ 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_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)
+
+ 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",
+ }
- actual = get_app_host()
- assert actual == "host_is_set"
+ with NewEnvVars(config):
+ configure_app(test_app)
- def test_get_app_host_raises_runtime_error_if_host_name_not_set(self) -> None:
- del os.environ["FLASK_HOST"]
+ 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)
- with pytest.raises(RuntimeError):
- _ = get_app_host()
+ def test_logging_environment_variables_on_app_initialization(
+ self, mocker: MockerFixture
+ ) -> None:
+ log_info_mock = mocker.patch.object(app.logger, "info")
- def test_get_app_port_returns_set_port_number(self) -> None:
- os.environ["FLASK_PORT"] = "8080"
+ log_env_vars(app)
- actual = get_app_port()
- assert actual == 8080
+ # 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_app.config = {}
+
+ test_env_vars = {
+ "FLASK_HOST": "test_host",
+ "FLASK_PORT": "1234",
+ }
- def test_get_app_port_raises_runtime_error_if_port_not_set(self) -> None:
- del os.environ["FLASK_PORT"]
+ with NewEnvVars(test_env_vars):
+ start_app(test_app)
- with pytest.raises(RuntimeError):
- _ = get_app_port()
+ test_app.run.assert_called_with(host="test_host", port=1234)
class TestGetStructuredRecord:
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
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/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 93cd2b71..cfbf2c50 100644
--- a/infrastructure/images/gateway-api/Dockerfile
+++ b/infrastructure/images/gateway-api/Dockerfile
@@ -23,15 +23,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 STUB_PDS="true"
-ENV STUB_PROVIDER="true"
-ENV STUB_SDS="true"
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..db7a267e
--- /dev/null
+++ b/scripts/env/env.mk
@@ -0,0 +1,15 @@
+.PHONY: env env-% _env
+
+env:
+ make _env
+
+env-%: # Create .env file with environment variables - optional: name=[name of the environment, e.g. 'dev'] @Configuration
+ make _env env="$*" # TODO: Implement difference envs
+
+_env:
+ scripts/env/env.sh "$(env)"
+
+${VERBOSE}.SILENT: \
+ _env \
+ env \
+ 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" <