From 4093d6ad42375c305c9e332102bbd1fbfb61198c Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:19:38 +0000 Subject: [PATCH 1/6] [CDAPI-85]: Initial introduction of APIM Authenticator class Initial Introduction of the ApimAuthenticator class, handling authentication with the API Management platform utilising Signed JWT application restricted access. This commit also includes the creation of a `SessionManager` class handling the creation of a `request.Session` object with appropriate default configuration. --- .github/workflows/preview-env.yaml | 22 +- .github/workflows/stage-2-test.yaml | 92 +----- .vscode/settings.json | 4 + pathology-api/lambda_handler.py | 1 - pathology-api/poetry.lock | 190 ++++++++----- pathology-api/pyproject.toml | 6 +- pathology-api/src/pathology_api/apim.py | 147 ++++++++++ pathology-api/src/pathology_api/config.py | 78 ++++++ pathology-api/src/pathology_api/handler.py | 72 +++++ pathology-api/src/pathology_api/http.py | 95 +++++++ pathology-api/src/pathology_api/test_apim.py | 264 ++++++++++++++++++ .../src/pathology_api/test_config.py | 217 ++++++++++++++ .../src/pathology_api/test_handler.py | 99 ++++++- pathology-api/src/pathology_api/test_http.py | 179 ++++++++++++ pathology-api/test_lambda_handler.py | 36 ++- pathology-api/tests/conftest.py | 2 +- .../tests/contract/test_provider_contract.py | 1 + schemathesis.toml | 2 + scripts/get_apigee_token.sh | 0 scripts/tests/run-test.sh | 2 +- 20 files changed, 1335 insertions(+), 174 deletions(-) create mode 100644 pathology-api/src/pathology_api/apim.py create mode 100644 pathology-api/src/pathology_api/config.py create mode 100644 pathology-api/src/pathology_api/http.py create mode 100644 pathology-api/src/pathology_api/test_apim.py create mode 100644 pathology-api/src/pathology_api/test_config.py create mode 100644 pathology-api/src/pathology_api/test_http.py mode change 100644 => 100755 scripts/get_apigee_token.sh diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 376ca283..84169756 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -43,6 +43,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 # Full history required for accurate sonar analysis. - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 @@ -137,6 +139,8 @@ jobs: APIM_APIKEY: ${{ secrets.APIM_APIKEY }} API_MTLS_CERT: ${{ secrets.API_MTLS_CERT }} API_MTLS_KEY: ${{ secrets.API_MTLS_KEY }} + APIM_KEY_ID: ${{ secrets.APIM_KEY_ID }} + CLIENT_REQUEST_TIMEOUT: ${{ secrets.CLIENT_REQUEST_TIMEOUT }} run: | cd pathology-api/target/ FN="${{ steps.names.outputs.function_name }}" @@ -146,6 +150,8 @@ jobs: API_KEY="${APIM_APIKEY:-/cds/pathology/dev/apim/api-key}" MTLS_CERT="${API_MTLS_CERT:-/cds/pathology/dev/mtls/client1-key-public}" MTLS_KEY="${API_MTLS_KEY:-/cds/pathology/dev/mtls/client1-key-secret}" + KEY_ID="${APIM_KEY_ID:-DEV-1}" + CLIENT_TIMEOUT="${CLIENT_REQUEST_TIMEOUT:-10s}" echo "Deploying preview function: $FN" wait_for_lambda_ready() { while true; do @@ -167,14 +173,18 @@ jobs: wait_for_lambda_ready aws lambda update-function-configuration --function-name "$FN" \ --handler "${{ env.LAMBDA_HANDLER }}" \ + --memory-size 512 \ + --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ APIM_API_KEY_NAME=$API_KEY, \ APIM_MTLS_CERT_NAME=$MTLS_CERT, \ APIM_MTLS_KEY_NAME=$MTLS_KEY, \ - APIM_TOKEN_URL=$MOCK_URL/apim, \ - PDM_BUNDLE_URL=$MOCK_URL/pdm, \ + APIM_KEY_ID=$KEY_ID, \ + APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \ + PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \ MNS_EVENT_URL=$MOCK_URL/mns, \ + CLIENT_TIMEOUT=$CLIENT_TIMEOUT, \ JWKS_SECRET_NAME=$JWKS_SECRET}" || true wait_for_lambda_ready aws lambda update-function-code --function-name "$FN" \ @@ -186,14 +196,18 @@ jobs: --handler "${{ env.LAMBDA_HANDLER }}" \ --zip-file "fileb://artifact.zip" \ --role "${{ steps.role-select.outputs.lambda_role }}" \ + --memory-size 512 \ + --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ APIM_API_KEY_NAME=$API_KEY, \ + APIM_KEY_ID=$KEY_ID, \ APIM_MTLS_CERT_NAME=$MTLS_CERT, \ APIM_MTLS_KEY_NAME=$MTLS_KEY, \ - APIM_TOKEN_URL=$MOCK_URL/apim, \ - PDM_BUNDLE_URL=$MOCK_URL/pdm, \ + APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \ + PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \ MNS_EVENT_URL=$MOCK_URL/mns, \ + CLIENT_TIMEOUT=$CLIENT_TIMEOUT, \ JWKS_SECRET_NAME=$JWKS_SECRET}" \ --publish wait_for_lambda_ready diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 58b3b2c6..c5ec3a3d 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -50,7 +50,7 @@ jobs: retention-days: 30 - name: "Upload unit test results for mocks" if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: mock-unit-test-results path: mocks/test-artefacts/ @@ -60,93 +60,3 @@ jobs: uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 with: paths: pathology-api/test-artefacts/unit-tests.xml - - test-contract: - name: "Contract tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda - with: - python-version: ${{ inputs.python_version }} - - name: "Run contract tests" - run: make test-contract - - name: "Upload contract test results" - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: contract-test-results - path: pathology-api/test-artefacts/ - retention-days: 30 - - name: "Publish contract test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: pathology-api/test-artefacts/contract-tests.xml - - test-schema: - name: "Schema validation tests" - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda - with: - python-version: ${{ inputs.python_version }} - - name: "Run schema validation tests" - run: make test-schema - - name: "Upload schema test results" - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: schema-test-results - path: pathology-api/test-artefacts/ - retention-days: 30 - - name: "Publish schema test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: pathology-api/test-artefacts/schema-tests.xml - - test-integration: - name: "Integration tests" - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Setup Python project" - uses: ./.github/actions/setup-python-project - with: - python-version: ${{ inputs.python_version }} - - name: "Start local Lambda" - uses: ./.github/actions/start-local-lambda - with: - python-version: ${{ inputs.python_version }} - - name: "Run integration test" - run: make test-integration - - name: "Upload integration test results" - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0 - with: - name: integration-test-results - path: pathology-api/test-artefacts/ - retention-days: 30 - - name: "Publish integration test results to summary" - if: always() - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 - with: - paths: pathology-api/test-artefacts/integration-tests.xml diff --git a/.vscode/settings.json b/.vscode/settings.json index 0b6d5526..31386651 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,6 +54,10 @@ "gitlens.ai.enabled": false, "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "pathology-api", + "mocks" + ], "git.enableCommitSigning": true, "sonarlint.connectedMode.project": { "connectionId": "nhsdigital", diff --git a/pathology-api/lambda_handler.py b/pathology-api/lambda_handler.py index a769da89..54658b5e 100644 --- a/pathology-api/lambda_handler.py +++ b/pathology-api/lambda_handler.py @@ -15,7 +15,6 @@ from pathology_api.logging import get_logger _logger = get_logger(__name__) - app = APIGatewayHttpResolver() type _ExceptionHandler[T: Exception] = Callable[[T], Response[str]] diff --git a/pathology-api/poetry.lock b/pathology-api/poetry.lock index 585ff4b6..d31ca1fe 100644 --- a/pathology-api/poetry.lock +++ b/pathology-api/poetry.lock @@ -119,13 +119,53 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "boto3" +version = "1.42.64" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "boto3-1.42.64-py3-none-any.whl", hash = "sha256:2ca6b472937a54ba74af0b4bede582ba98c070408db1061fc26d5c3aa8e6e7e6"}, + {file = "boto3-1.42.64.tar.gz", hash = "sha256:58d47897a26adbc22f6390d133dab772fb606ba72695291a8c9e20cba1c7fd23"}, +] + +[package.dependencies] +botocore = ">=1.42.64,<1.43.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.16.0,<0.17.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.42.64" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "botocore-1.42.64-py3-none-any.whl", hash = "sha256:f77c5cb76ed30576ed0bc73b591265d03dddffff02a9208d3ee0c790f43d3cd2"}, + {file = "botocore-1.42.64.tar.gz", hash = "sha256:4ee2aece227b9171ace8b749af694a77ab984fceab1639f2626bd0d6fb1aa69d"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.31.2)"] + [[package]] name = "certifi" version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, @@ -137,7 +177,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -224,6 +264,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] +markers = {main = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -234,7 +275,7 @@ version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -485,61 +526,61 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "46.0.4" +version = "46.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ - {file = "cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32"}, - {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616"}, - {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0"}, - {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0"}, - {file = "cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5"}, - {file = "cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b"}, - {file = "cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e"}, - {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f"}, - {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82"}, - {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c"}, - {file = "cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061"}, - {file = "cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7"}, - {file = "cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b"}, - {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019"}, - {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4"}, - {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b"}, - {file = "cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc"}, - {file = "cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947"}, - {file = "cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3"}, - {file = "cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59"}, + {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, + {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, + {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, + {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, + {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, + {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, + {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, + {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, + {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, + {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, ] [package.dependencies] @@ -552,7 +593,7 @@ nox = ["nox[uv] (>=2024.4.15)"] pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -744,7 +785,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1666,12 +1707,12 @@ version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "implementation_name != \"PyPy\"" +groups = ["main", "dev"] files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] +markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", dev = "implementation_name != \"PyPy\""} [[package]] name = "pycryptodome" @@ -1925,12 +1966,15 @@ version = "2.11.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, ] +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + [package.extras] crypto = ["cryptography (>=3.4.0)"] dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] @@ -2121,7 +2165,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2250,7 +2294,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -2437,6 +2481,24 @@ files = [ {file = "rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea"}, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, + {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] + [[package]] name = "schemathesis" version = "4.4.1" @@ -2504,7 +2566,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2667,7 +2729,7 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, @@ -2875,4 +2937,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "16c10c515c7ceb2070612adce26f5c9caa927e32950de99b0fc78209520c6d29" +content-hash = "be09d7dd0dfdbbd7e96fb53c81834218363383494569278f2e651c9eb6761c25" diff --git a/pathology-api/pyproject.toml b/pathology-api/pyproject.toml index 0ef04856..66a7799a 100644 --- a/pathology-api/pyproject.toml +++ b/pathology-api/pyproject.toml @@ -9,7 +9,10 @@ readme = "README.md" requires-python = ">3.13,<4.0.0" dependencies = [ "aws-lambda-powertools (>=3.24.0,<4.0.0)", - "pydantic (>=2.12.5,<3.0.0)" + "pydantic (>=2.12.5,<3.0.0)", + "pyjwt[crypto] (>=2.11.0,<3.0.0)", + "requests>=2.31.0", + "boto3 (>=1.42.64,<2.0.0)" ] [tool.poetry] @@ -47,7 +50,6 @@ dev = [ "pytest-cov (>=7.0.0,<8.0.0)", "pytest-html (>=4.1.1,<5.0.0)", "pact-python>=2.0.0", - "requests>=2.31.0", "schemathesis>=4.4.1", "types-requests (>=2.32.4.20250913,<3.0.0.0)", "types-pyyaml (>=6.0.12.20250915,<7.0.0.0)", diff --git a/pathology-api/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py new file mode 100644 index 00000000..14a5d461 --- /dev/null +++ b/pathology-api/src/pathology_api/apim.py @@ -0,0 +1,147 @@ +import functools +import uuid +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from typing import Any, TypedDict + +import jwt +import requests + +from pathology_api.http import RequestMethod, SessionManager +from pathology_api.logging import get_logger + +_logger = get_logger(__name__) + + +class ApimAuthenticationException(Exception): + pass + + +class ApimAuthenticator: + class __AccessToken(TypedDict): + value: str + expiry: datetime + + def __init__( + self, + private_key: str, + key_id: str, + api_key: str, + token_validity_threshold: timedelta, + token_endpoint: str, + session_manager: SessionManager, + ): + self._private_key = private_key + self._key_id = key_id + self._api_key = api_key + self._token_validity_threshold = token_validity_threshold + self._token_endpoint = token_endpoint + self._session_manager = session_manager + + self._access_token: ApimAuthenticator.__AccessToken | None = None + + def auth[**P, S](self, func: RequestMethod[P, S]) -> Callable[P, S]: + """ + Decorate a given function with APIM authentication. This authentication will be + provided via a `requests.Session` object. + """ + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + @self._session_manager.with_session + def with_session( + session: requests.Session, access_token: ApimAuthenticator.__AccessToken + ) -> S: + session.headers.update( + {"Authorization": f"Bearer {access_token['value']}"} + ) + return func(session, *args, **kwargs) + + # If there isn't an access token yet, or the token will expire within the + # token validity threshold, reauthenticate. + if ( + self._access_token is None + or self._access_token["expiry"] - datetime.now(tz=timezone.utc) + < self._token_validity_threshold + ): + _logger.debug("Authenticating with APIM...") + self._access_token = self._authenticate() + + return with_session(self._access_token) + + return wrapper + + def _create_client_assertion(self) -> str: + _logger.debug("Creating client assertion JWT for APIM authentication") + claims = { + "sub": self._api_key, + "iss": self._api_key, + "jti": str(uuid.uuid4()), + "aud": self._token_endpoint, + "exp": int( + (datetime.now(tz=timezone.utc) + timedelta(seconds=30)).timestamp() + ), + } + _logger.debug( + "Created client claims. jti: %s, exp: %s, aud: %s", + claims["jti"], + claims["exp"], + claims["aud"], + ) + + client_assertion = jwt.encode( + claims, + self._private_key, + algorithm="RS512", + headers={"kid": self._key_id}, + ) + + _logger.debug("Created client assertion. kid: %s", self._key_id) + + return client_assertion + + def _authenticate(self) -> __AccessToken: + @self._session_manager.with_session + def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken: + client_assertion = self._create_client_assertion() + + _logger.debug("Sending token request with created session.") + + response = session.post( + self._token_endpoint, + data={ + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth" + ":client-assertion-type:jwt-bearer", + "client_assertion": client_assertion, + }, + ) + + _logger.debug( + "Response received from APIM token endpoint. Status code: %s", + response.status_code, + ) + + if response.status_code != 200: + raise ApimAuthenticationException( + f"Failed to authenticate with APIM. " + f"Status code: {response.status_code}" + f", Response: {response.text}" + ) + + response_data = response.json() + _logger.debug( + "APIM authentication successful. Expiry: %s", + response_data["expires_in"], + ) + + return { + "value": response_data["access_token"], + "expiry": datetime.now(tz=timezone.utc) + + timedelta(seconds=int(response_data["expires_in"])), + } + + _logger.debug( + "Sending authentication request to APIM: %s", self._token_endpoint + ) + return with_session() diff --git a/pathology-api/src/pathology_api/config.py b/pathology-api/src/pathology_api/config.py new file mode 100644 index 00000000..97b41fb4 --- /dev/null +++ b/pathology-api/src/pathology_api/config.py @@ -0,0 +1,78 @@ +import os +import re +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +from enum import StrEnum +from typing import Any, cast + + +class ConfigError(Exception): + pass + + +class DurationUnit(StrEnum): + SECONDS = "s" + MINUTES = "m" + + +@dataclass(frozen=True) +class Duration: + unit: DurationUnit + value: int + + @property + def timedelta(self) -> timedelta: + match self.unit: + case DurationUnit.SECONDS: + return timedelta(seconds=self.value) + case DurationUnit.MINUTES: + return timedelta(minutes=self.value) + + +_SUPPORTED_PRIMITIVES: dict[type[Any], Callable[[str], Any]] = { + str: str, + int: int, +} + + +def get_optional_environment_variable[T](name: str, _type: type[T]) -> T | None: + value = os.getenv(name) + + match _type: + case _ if _type is Duration: + if value is None: + return None + + parsed = re.fullmatch(r"(?P\d+)(?P[sm])", value) + if parsed is None: + raise ConfigError(f"Invalid duration value: {value!r}") + + raw_value = parsed.group("value") + raw_unit = parsed.group("unit") + + return cast( + "T", + Duration( + unit=DurationUnit(raw_unit), + value=int(raw_value), + ), + ) + + case _ if _type in _SUPPORTED_PRIMITIVES: + if value is None: + return None + + return cast("T", _SUPPORTED_PRIMITIVES[_type](value)) + + case _: + raise ValueError( + f"Required type {_type} is not supported for config values" + ) + + +def get_environment_variable[T](name: str, _type: type[T]) -> T: + value = get_optional_environment_variable(name=name, _type=_type) + if value is None: + raise ConfigError(f"Environment variable {name!r} is not set") + return value diff --git a/pathology-api/src/pathology_api/handler.py b/pathology-api/src/pathology_api/handler.py index 6a4a7d8d..96340325 100644 --- a/pathology-api/src/pathology_api/handler.py +++ b/pathology-api/src/pathology_api/handler.py @@ -1,13 +1,73 @@ import uuid from collections.abc import Callable +import requests +from aws_lambda_powertools.utilities import parameters + +from pathology_api.apim import ApimAuthenticator +from pathology_api.config import ( + Duration, + get_environment_variable, + get_optional_environment_variable, +) from pathology_api.exception import ValidationError from pathology_api.fhir.r4.elements import Meta from pathology_api.fhir.r4.resources import Bundle, Composition +from pathology_api.http import ClientCertificate, SessionManager from pathology_api.logging import get_logger _logger = get_logger(__name__) +CLIENT_TIMEOUT = get_environment_variable("CLIENT_TIMEOUT", Duration) + +CLIENT_CERTIFICATE_NAME = get_optional_environment_variable("APIM_MTLS_CERT_NAME", str) +CLIENT_KEY_NAME = get_optional_environment_variable("APIM_MTLS_KEY_NAME", str) + +APIM_TOKEN_URL = get_environment_variable("APIM_TOKEN_URL", str) +APIM_PRIVATE_KEY_NAME = get_environment_variable("APIM_PRIVATE_KEY_NAME", str) +APIM_API_KEY_NAME = get_environment_variable("APIM_API_KEY_NAME", str) +APIM_TOKEN_EXPIRY_THRESHOLD = get_environment_variable( + "APIM_TOKEN_EXPIRY_THRESHOLD", Duration +) +APIM_KEY_ID = get_environment_variable("APIM_KEY_ID", str) + +PDM_URL = get_environment_variable("PDM_BUNDLE_URL", str) + + +def _create_client_certificate( + certificate_name: str, key_name: str +) -> ClientCertificate: + certificate = parameters.get_secret(certificate_name) + key = parameters.get_secret(key_name) + + return { + "certificate": certificate, + "key": key, + } + + +if CLIENT_CERTIFICATE_NAME and CLIENT_KEY_NAME: + CLIENT_CERTIFICATE: ClientCertificate | None = _create_client_certificate( + CLIENT_CERTIFICATE_NAME, CLIENT_KEY_NAME + ) +else: + CLIENT_CERTIFICATE = None + + +session_manager = SessionManager( + client_timeout=CLIENT_TIMEOUT.timedelta, + client_certificate=CLIENT_CERTIFICATE, +) + +apim_authenticator = ApimAuthenticator( + private_key=parameters.get_secret(APIM_PRIVATE_KEY_NAME), + key_id=APIM_KEY_ID, + api_key=parameters.get_secret(APIM_API_KEY_NAME), + token_endpoint=APIM_TOKEN_URL, + token_validity_threshold=APIM_TOKEN_EXPIRY_THRESHOLD.timedelta, + session_manager=session_manager, +) + def _validate_composition(bundle: Bundle) -> None: compositions = bundle.find_resources(t=Composition) @@ -48,4 +108,16 @@ def handle_request(bundle: Bundle) -> Bundle: ) _logger.debug("Return bundle: %s", return_bundle) + auth_response = _send_request(PDM_URL) + _logger.debug( + "Result of authenticated request. status_code=%s data=%s", + auth_response.status_code, + auth_response.text, + ) + return return_bundle + + +@apim_authenticator.auth +def _send_request(session: requests.Session, url: str) -> requests.Response: + return session.post(url) diff --git a/pathology-api/src/pathology_api/http.py b/pathology-api/src/pathology_api/http.py new file mode 100644 index 00000000..0d39e4a2 --- /dev/null +++ b/pathology-api/src/pathology_api/http.py @@ -0,0 +1,95 @@ +import functools +import tempfile +from collections.abc import Callable +from contextlib import ExitStack +from datetime import timedelta +from typing import Any, Concatenate, TypedDict + +import requests +from requests.adapters import HTTPAdapter + +from pathology_api.logging import get_logger + +_logger = get_logger(__name__) + +# Type alias describing the expected signature for a request making a HTTP request. +# Any function that takes a `requests.Session` as its first argument, followed by any +# number of additional arguments, and returns any type of value. +type RequestMethod[**P, S] = Callable[Concatenate[requests.Session, P], S] + + +class ClientCertificate(TypedDict): + certificate: str + key: str + + +class SessionManager: + class _Adapter(HTTPAdapter): + """ + HTTPAdapter to apply default configuration to apply to all created + `request.Session` objects. + """ + + def __init__(self, timeout: float): + self._timeout = timeout + super().__init__() + + def send( + self, + request: requests.PreparedRequest, + *args: Any, + **kwargs: Any, + ) -> requests.Response: + _logger.debug( + "Applying default timeout of %s seconds to request", self._timeout + ) + kwargs["timeout"] = self._timeout + return super().send(request, *args, **kwargs) + + def __init__( + self, + client_timeout: timedelta, + client_certificate: ClientCertificate | None = None, + ): + self._client_adapter = self._Adapter(timeout=client_timeout.total_seconds()) + self._client_certificate = client_certificate + + def with_session[**P, S](self, func: RequestMethod[P, S]) -> Callable[P, S]: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + with ExitStack() as stack: + _logger.debug("Creating new session for request") + session = requests.Session() + stack.enter_context(session) + + _logger.debug("Mounted default settings to session") + session.mount("https://", self._client_adapter) + + if self._client_certificate is not None: + _logger.debug("Configuring session with client certificate...") + + # File added to Exit stack and will be automatically cleaned up with + # the stack. + cert_file = tempfile.NamedTemporaryFile( # noqa: SIM115 + delete=True + ) + stack.enter_context(cert_file) + + # File added to Exit stack and will be automatically cleaned up with + # the stack. + key_file = tempfile.NamedTemporaryFile( # noqa: SIM115 + delete=True + ) + stack.enter_context(key_file) + + cert_file.write(self._client_certificate["certificate"].encode()) + cert_file.flush() + key_file.write(self._client_certificate["key"].encode()) + key_file.flush() + + session.cert = (cert_file.name, key_file.name) + _logger.debug("Client certificate added.") + + return func(session, *args, **kwargs) + + return wrapper diff --git a/pathology-api/src/pathology_api/test_apim.py b/pathology-api/src/pathology_api/test_apim.py new file mode 100644 index 00000000..bd3c663b --- /dev/null +++ b/pathology-api/src/pathology_api/test_apim.py @@ -0,0 +1,264 @@ +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from typing import Any +from unittest.mock import MagicMock, Mock, patch + +import pytest +import requests +from jwt import InvalidKeyError + +from pathology_api.apim import ApimAuthenticationException, ApimAuthenticator + + +class TestApimAuthenticator: + def setup_method(self) -> None: + self.mock_session = Mock() + + def mock_with_session(self, func: Callable[..., Any]) -> Callable[..., Any]: + + def wrapper(*args: Any, **kwargs: Any) -> Any: + return func(self.mock_session, *args, **kwargs) + + return wrapper + + @patch("pathology_api.http.SessionManager") + @patch("pathology_api.apim.jwt.encode") + def test_auth(self, mock_jwt: MagicMock, mock_session_manager: MagicMock) -> None: + mock_session_manager.with_session = self.mock_with_session + + expected_client_assertion = "client_assertion" + mock_jwt.return_value = expected_client_assertion + + expected_access_token = "access_token" # noqa S105 - Dummy value + expected_expires_in = timedelta(seconds=5) + + self.mock_session.post.return_value.json.return_value = { + "access_token": expected_access_token, + "expires_in": expected_expires_in.total_seconds(), + } + self.mock_session.post.return_value.status_code = 200 + + expected_api_key = "api_key" + expected_token_endpoint = "token_endpoint" # noqa S106 - Dummy value + expected_key_id = "key_id" + apim_authenticator = ApimAuthenticator( + private_key="private_key", + key_id=expected_key_id, + api_key=expected_api_key, + token_validity_threshold=timedelta(minutes=5), + token_endpoint=expected_token_endpoint, + session_manager=mock_session_manager, + ) + + @apim_authenticator.auth + def method(_: requests.Session) -> None: + self.mock_session.headers.update.assert_called_once_with( + {"Authorization": f"Bearer {expected_access_token}"} + ) + + mock_jwt.assert_called_once() + args, kwargs = mock_jwt.call_args + + provided_claims = args[0] + assert provided_claims["sub"] == expected_api_key + assert provided_claims["iss"] == expected_api_key + assert provided_claims["aud"] == expected_token_endpoint + assert provided_claims["jti"] is not None + assert provided_claims["exp"] < int( + (datetime.now(tz=timezone.utc) + timedelta(seconds=31)).timestamp() + ) + + assert kwargs == {"algorithm": "RS512", "headers": {"kid": expected_key_id}} + + # SLF001: Private access to support testing + stored_access_token = apim_authenticator._access_token # noqa SLF001 + assert stored_access_token is not None + assert stored_access_token["value"] == expected_access_token + assert stored_access_token["expiry"] < ( + datetime.now(tz=timezone.utc) + expected_expires_in + ) + + method() + + @patch("pathology_api.http.SessionManager") + @patch("pathology_api.apim.jwt.encode") + def test_auth_existing_valid_token( + self, mock_jwt: MagicMock, mock_session_manager: MagicMock + ) -> None: + mock_session_manager.with_session = self.mock_with_session + + apim_authenticator = ApimAuthenticator( + private_key="private_key", + key_id="key_id", + api_key="api_key", + token_validity_threshold=timedelta(minutes=5), + token_endpoint="token_endpoint", # noqa S106 - Dummy value + session_manager=mock_session_manager, + ) + + expected_access_token = "access_token" # noqa S105 - Dummy value + apim_authenticator._access_token = { # noqa SLF001 - Private access to support testing + "value": expected_access_token, + "expiry": datetime.now(tz=timezone.utc) + timedelta(minutes=10), + } + + @apim_authenticator.auth + def method(_: requests.Session) -> None: + self.mock_session.headers.update.assert_called_once_with( + {"Authorization": f"Bearer {expected_access_token}"} + ) + + mock_jwt.assert_not_called() + + method() + + @patch("pathology_api.http.SessionManager") + @patch("pathology_api.apim.jwt.encode") + def test_auth_existing_invalid_token( + self, mock_jwt: MagicMock, mock_session_manager: MagicMock + ) -> None: + mock_session_manager.with_session = self.mock_with_session + + expected_client_assertion = "client_assertion" + mock_jwt.return_value = expected_client_assertion + + expected_access_token = "access_token" # noqa S105 - Dummy value + expected_expires_in = timedelta(seconds=5) + + self.mock_session.post.return_value.json.return_value = { + "access_token": expected_access_token, + "expires_in": expected_expires_in.total_seconds(), + } + self.mock_session.post.return_value.status_code = 200 + + expected_api_key = "api_key" + expected_token_endpoint = "token_endpoint" # noqa S106 - Dummy value + expected_key_id = "key_id" + apim_authenticator = ApimAuthenticator( + private_key="private_key", + key_id=expected_key_id, + api_key=expected_api_key, + token_validity_threshold=timedelta(minutes=5), + token_endpoint=expected_token_endpoint, + session_manager=mock_session_manager, + ) + + apim_authenticator._access_token = { # noqa SLF001 - Private access to support testing + "value": "old_access_token", + "expiry": datetime.now(tz=timezone.utc) - timedelta(seconds=1), + } + + @apim_authenticator.auth + def method(_: requests.Session) -> None: + self.mock_session.headers.update.assert_called_once_with( + {"Authorization": f"Bearer {expected_access_token}"} + ) + + mock_jwt.assert_called_once() + args, kwargs = mock_jwt.call_args + + provided_claims = args[0] + assert provided_claims["sub"] == expected_api_key + assert provided_claims["iss"] == expected_api_key + assert provided_claims["aud"] == expected_token_endpoint + assert provided_claims["jti"] is not None + assert provided_claims["exp"] < int( + (datetime.now(tz=timezone.utc) + timedelta(seconds=31)).timestamp() + ) + + assert kwargs == {"algorithm": "RS512", "headers": {"kid": expected_key_id}} + + # SLF001: Private access to support testing + stored_access_token = apim_authenticator._access_token # noqa SLF001 + assert stored_access_token is not None + assert stored_access_token["value"] == expected_access_token + assert stored_access_token["expiry"] < ( + datetime.now(tz=timezone.utc) + expected_expires_in + ) + + method() + + @patch("pathology_api.http.SessionManager") + @patch("pathology_api.apim.jwt.encode") + def test_auth_unsuccessful_status_code( + self, mock_jwt: MagicMock, mock_session_manager: MagicMock + ) -> None: + mock_session_manager.with_session = self.mock_with_session + + mock_jwt.return_value = "client_assertion" + + self.mock_session.post.return_value.status_code = 401 + self.mock_session.post.return_value.text = "Unauthorized" + + apim_authenticator = ApimAuthenticator( + private_key="private_key", + key_id="key_id", + api_key="api_key", + token_validity_threshold=timedelta(minutes=5), + token_endpoint="token_endpoint", # noqa S106 - Dummy value + session_manager=mock_session_manager, + ) + + @apim_authenticator.auth + def method(_: requests.Session) -> None: + """Dummy method just to apply the auth decorator""" + pass + + with pytest.raises(ApimAuthenticationException): + method() + + @patch("pathology_api.http.SessionManager") + @patch("pathology_api.apim.jwt.encode") + def test_auth_session_post_raises_exception( + self, mock_jwt: MagicMock, mock_session_manager: MagicMock + ) -> None: + mock_session_manager.with_session = self.mock_with_session + + mock_jwt.return_value = "client_assertion" + + self.mock_session.post.side_effect = requests.RequestException( + "Connection failed" + ) + + apim_authenticator = ApimAuthenticator( + private_key="private_key", + key_id="key_id", + api_key="api_key", + token_validity_threshold=timedelta(minutes=5), + token_endpoint="token_endpoint", # noqa S106 - Dummy value + session_manager=mock_session_manager, + ) + + @apim_authenticator.auth + def method(_: requests.Session) -> None: + """Dummy method just to apply the auth decorator""" + pass + + with pytest.raises(requests.RequestException, match="Connection failed"): + method() + + @patch("pathology_api.http.SessionManager") + @patch("pathology_api.apim.jwt.encode") + def test_auth_jwt_encode_raises_exception( + self, mock_jwt: MagicMock, mock_session_manager: MagicMock + ) -> None: + mock_session_manager.with_session = self.mock_with_session + + mock_jwt.side_effect = InvalidKeyError("JWT encoding failed") + + apim_authenticator = ApimAuthenticator( + private_key="private_key", + key_id="key_id", + api_key="api_key", + token_validity_threshold=timedelta(minutes=5), + token_endpoint="token_endpoint", # noqa S106 - Dummy value + session_manager=mock_session_manager, + ) + + @apim_authenticator.auth + def method(_: requests.Session) -> None: + """Dummy method just to apply the auth decorator""" + pass + + with pytest.raises(InvalidKeyError, match="JWT encoding failed"): + method() diff --git a/pathology-api/src/pathology_api/test_config.py b/pathology-api/src/pathology_api/test_config.py new file mode 100644 index 00000000..818b26d4 --- /dev/null +++ b/pathology-api/src/pathology_api/test_config.py @@ -0,0 +1,217 @@ +import os +from datetime import timedelta +from typing import Any + +import pytest + +from pathology_api.config import ( + ConfigError, + Duration, + DurationUnit, + get_environment_variable, + get_optional_environment_variable, +) + + +class TestGetEnvironmentVariables: + __ENV_VAR_NAME = "TEST_VARIABLE" + + def teardown_method(self) -> None: + os.environ.pop(self.__ENV_VAR_NAME, None) + + @pytest.mark.parametrize( + ("expected_value", "_type"), + [ + pytest.param("test_value", str, id="String variable"), + pytest.param(123, int, id="Integer variable"), + ], + ) + def test_get_environment_variable( + self, expected_value: Any, _type: type[Any] + ) -> None: + os.environ[self.__ENV_VAR_NAME] = str(expected_value) + + value = get_environment_variable(name=self.__ENV_VAR_NAME, _type=_type) + + assert value == expected_value + + @pytest.mark.parametrize( + ("_type"), + [ + pytest.param(str, id="String variable"), + pytest.param(int, id="Integer variable"), + ], + ) + def test_get_environment_variable_no_config_value(self, _type: type[Any]) -> None: + with pytest.raises( + ConfigError, + match=f"Environment variable '{self.__ENV_VAR_NAME}' is not set", + ): + get_environment_variable(name=self.__ENV_VAR_NAME, _type=_type) + + @pytest.mark.parametrize( + ("environment_variable", "expected_result"), + [ + pytest.param( + "5m", + Duration(unit=DurationUnit.MINUTES, value=5), + id="Minutes duration", + ), + pytest.param( + "30s", + Duration(unit=DurationUnit.SECONDS, value=30), + id="Seconds duration", + ), + ], + ) + def test_get_duration_environment_variable( + self, environment_variable: str, expected_result: Duration + ) -> None: + os.environ[self.__ENV_VAR_NAME] = environment_variable + + value = get_environment_variable(name=self.__ENV_VAR_NAME, _type=Duration) + + assert value == expected_result + + @pytest.mark.parametrize( + ("environment_variable", "expected_error"), + [ + pytest.param( + "5x", + "Invalid duration value: '5x'", + id="Unknown unit type", + ), + pytest.param( + "invalids", + "Invalid duration value: 'invalids'", + id="Unknown unit", + ), + pytest.param( + "not a duration", + "Invalid duration value: 'not a duration'", + id="Not a duration format", + ), + pytest.param( + None, + "Environment variable 'TEST_VARIABLE' is not set", + id="No value", + ), + ], + ) + def test_get_duration_environment_variable_invalid( + self, environment_variable: str, expected_error: str + ) -> None: + if environment_variable is not None: + os.environ[self.__ENV_VAR_NAME] = environment_variable + + with pytest.raises(ConfigError, match=expected_error): + get_environment_variable(name=self.__ENV_VAR_NAME, _type=Duration) + + def test_get_environment_variable_unsupported_type(self) -> None: + with pytest.raises( + ValueError, + match=f"Required type {float!r} is not supported for config values", + ): + get_environment_variable(name=self.__ENV_VAR_NAME, _type=float) + + @pytest.mark.parametrize( + ("expected_value", "_type"), + [ + pytest.param("test_value", str, id="String variable"), + pytest.param(123, int, id="Integer variable"), + ], + ) + def test_get_optional_environment_variable( + self, expected_value: Any, _type: type[Any] + ) -> None: + os.environ[self.__ENV_VAR_NAME] = str(expected_value) + + value = get_optional_environment_variable(name=self.__ENV_VAR_NAME, _type=_type) + + assert value == expected_value + + @pytest.mark.parametrize( + ("_type"), + [ + pytest.param(str, id="String variable"), + pytest.param(int, id="Integer variable"), + pytest.param(Duration, id="Duration variable"), + ], + ) + def test_get_optional_environment_variable_no_config_value( + self, _type: type[Any] + ) -> None: + value = get_optional_environment_variable(name=self.__ENV_VAR_NAME, _type=_type) + + assert value is None + + @pytest.mark.parametrize( + ("environment_variable", "expected_result"), + [ + pytest.param( + "5m", + Duration(unit=DurationUnit.MINUTES, value=5), + id="Minutes duration", + ), + pytest.param( + "30s", + Duration(unit=DurationUnit.SECONDS, value=30), + id="Seconds duration", + ), + ], + ) + def test_get_optional_duration_environment_variable( + self, environment_variable: str, expected_result: Duration + ) -> None: + os.environ[self.__ENV_VAR_NAME] = environment_variable + + value = get_optional_environment_variable( + name=self.__ENV_VAR_NAME, _type=Duration + ) + + assert value == expected_result + + @pytest.mark.parametrize( + ("environment_variable", "expected_error"), + [ + pytest.param( + "5x", + "Invalid duration value: '5x'", + id="Unknown unit", + ), + ], + ) + def test_get_optional_duration_environment_variable_invalid( + self, environment_variable: str, expected_error: str + ) -> None: + os.environ[self.__ENV_VAR_NAME] = environment_variable + + with pytest.raises(ConfigError, match=expected_error): + get_optional_environment_variable(name=self.__ENV_VAR_NAME, _type=Duration) + + def test_get_optional_environment_variable_unsupported_type(self) -> None: + with pytest.raises( + ValueError, + match=f"Required type {float!r} is not supported for config values", + ): + get_optional_environment_variable(name=self.__ENV_VAR_NAME, _type=float) + + +class TestDuration: + @pytest.mark.parametrize( + ("duration", "expected_timedelta"), + [ + pytest.param( + Duration(unit=DurationUnit.MINUTES, value=5), + timedelta(minutes=5), + id="Minutes duration", + ), + pytest.param( + Duration(unit=DurationUnit.SECONDS, value=30), + timedelta(seconds=30), + id="Seconds duration", + ), + ], + ) + def test_timedelta(self, duration: Duration, expected_timedelta: timedelta) -> None: + assert duration.timedelta == expected_timedelta diff --git a/pathology-api/src/pathology_api/test_handler.py b/pathology-api/src/pathology_api/test_handler.py index d649d4a4..265834fe 100644 --- a/pathology-api/src/pathology_api/test_handler.py +++ b/pathology-api/src/pathology_api/test_handler.py @@ -1,6 +1,19 @@ import datetime +import os +from collections.abc import Callable +from typing import Any +from unittest.mock import MagicMock, Mock, call, patch import pytest +from requests.exceptions import RequestException + +os.environ["CLIENT_TIMEOUT"] = "1s" +os.environ["APIM_TOKEN_URL"] = "apim_url" # noqa S105 - dummy value +os.environ["APIM_PRIVATE_KEY_NAME"] = "apim_private_key_name" +os.environ["APIM_API_KEY_NAME"] = "apim_api_key_name" +os.environ["APIM_TOKEN_EXPIRY_THRESHOLD"] = "1s" # noqa S105 - dummy value +os.environ["APIM_KEY_ID"] = "apim_key" +os.environ["PDM_BUNDLE_URL"] = "pdm_bundle_url" from pathology_api.exception import ValidationError from pathology_api.fhir.r4.elements import ( @@ -8,10 +21,37 @@ PatientIdentifier, ) from pathology_api.fhir.r4.resources import Bundle, Composition -from pathology_api.handler import handle_request + +mock_session = Mock() + + +def mock_auth(func: Callable[..., Any]) -> Callable[..., Any]: + + def wrapper(*args: Any, **kwargs: Any) -> Any: + return func(mock_session, *args, **kwargs) + + return wrapper + + +with ( + patch("aws_lambda_powertools.utilities.parameters.get_secret") as get_secret_mock, + patch("pathology_api.apim.ApimAuthenticator") as apim_authenticator_mock, + patch("pathology_api.http.SessionManager") as session_manager_mock, +): + apim_authenticator_mock.return_value.auth = mock_auth + get_secret_mock.side_effect = lambda secret_name: { + os.environ["APIM_PRIVATE_KEY_NAME"]: "private_key", + os.environ["APIM_API_KEY_NAME"]: "api_key", + "mtls_cert_name": "mtls_cert", + "mtls_key_name": "mtls_key", + }[secret_name] + from pathology_api.handler import _create_client_certificate, handle_request class TestHandleRequest: + def setup_method(self) -> None: + mock_session.reset() + def test_handle_request(self) -> None: # Arrange bundle = Bundle.create( @@ -49,6 +89,43 @@ def test_handle_request(self) -> None: assert created_meta.version_id is None + mock_session.post.assert_called_once_with(os.environ["PDM_BUNDLE_URL"]) + + session_manager_mock.assert_called_once_with( + client_timeout=datetime.timedelta(seconds=1), client_certificate=None + ) + + apim_authenticator_mock.assert_called_once_with( + private_key="private_key", + key_id=os.environ["APIM_KEY_ID"], + api_key="api_key", + token_endpoint=os.environ["APIM_TOKEN_URL"], + token_validity_threshold=datetime.timedelta(seconds=1), + session_manager=session_manager_mock.return_value, + ) + + def test_handle_request_raises_error_when_send_request_fails(self) -> None: + # Arrange + bundle = Bundle.create( + type="document", + entry=[ + Bundle.Entry( + fullUrl="patient", + resource=Composition.create( + subject=LogicalReference( + PatientIdentifier.from_nhs_number("nhs_number") + ) + ), + ) + ], + ) + + expected_error_message = "Failed to send request" + mock_session.post.side_effect = RequestException(expected_error_message) + + with pytest.raises(RequestException, match=expected_error_message): + handle_request(bundle) + def test_handle_request_raises_error_when_no_composition_resource(self) -> None: bundle = Bundle.create( type="document", @@ -153,3 +230,23 @@ def test_handle_request_raises_error_when_bundle_not_document_type( match="Resource must be a bundle of type 'document'", ): handle_request(bundle) + + +@patch("pathology_api.handler.parameters.get_secret") +def test_create_client_certificate(get_secret_mock: MagicMock) -> None: + get_secret_mock.side_effect = lambda secret_name: { + "mtls_cert_name": "mtls_cert", + "mtls_key_name": "mtls_key", + }[secret_name] + + certificate_name = "mtls_cert_name" + key_name = "mtls_key_name" + + client_certificate = _create_client_certificate(certificate_name, key_name) + + assert client_certificate == { + "certificate": "mtls_cert", + "key": "mtls_key", + } + + get_secret_mock.assert_has_calls([call(certificate_name), call(key_name)]) diff --git a/pathology-api/src/pathology_api/test_http.py b/pathology-api/src/pathology_api/test_http.py new file mode 100644 index 00000000..cc537f89 --- /dev/null +++ b/pathology-api/src/pathology_api/test_http.py @@ -0,0 +1,179 @@ +from datetime import timedelta +from unittest.mock import MagicMock, Mock, patch + +import pytest +import requests +import requests.adapters + +from pathology_api.http import SessionManager + + +class TestSessionManager: + @patch("tempfile.NamedTemporaryFile") + @patch("requests.Session") + def test_with_session( + self, mock_session: MagicMock, mock_tempfile: MagicMock + ) -> None: + expected_timeout = timedelta(seconds=30) + session_manager = SessionManager( + client_timeout=expected_timeout, + client_certificate=None, + ) + + @session_manager.with_session + def mock_function(_: requests.Session) -> None: + mock_session.return_value.mount.assert_called_once() + args, _ = mock_session.return_value.mount.call_args + + assert args[0] == "https://" + + adapter = args[1] + assert adapter is not None + assert isinstance(adapter, SessionManager._Adapter) # noqa: SLF001 - Private access for testing + + assert adapter._timeout == expected_timeout.total_seconds() # noqa: SLF001 - Private access for testing + + mock_tempfile.assert_not_called() + + mock_function() + mock_session.return_value.__exit__.assert_called_once() + + @patch("tempfile.NamedTemporaryFile") + @patch("requests.Session") + def test_with_session_with_client_cert( + self, mock_session: MagicMock, mock_tempfile: MagicMock + ) -> None: + expected_timeout = timedelta(seconds=30) + expected_cert = "cert_content" + expected_key = "key_content" + + session_manager = SessionManager( + client_timeout=expected_timeout, + client_certificate={ + "certificate": expected_cert, + "key": expected_key, + }, + ) + + mock_cert_file = MagicMock() + mock_cert_file.name = "cert_file_name" + + mock_key_file = MagicMock() + mock_key_file.name = "key_file_name" + + mock_tempfile.side_effect = [mock_cert_file, mock_key_file] + + @session_manager.with_session + def mock_function(_: requests.Session) -> None: + mock_session.return_value.mount.assert_called_once() + args, _ = mock_session.return_value.mount.call_args + + assert args[0] == "https://" + + adapter = args[1] + assert adapter is not None + assert isinstance(adapter, SessionManager._Adapter) # noqa: SLF001 - Private access for testing + + assert adapter._timeout == expected_timeout.total_seconds() # noqa: SLF001 - Private access for testing + + assert mock_tempfile.call_count == 2 + + assert mock_session.return_value.cert == ( + mock_cert_file.name, + mock_key_file.name, + ) + + mock_cert_file.write.assert_called_once_with(expected_cert.encode()) + mock_cert_file.flush.assert_called_once() + + mock_key_file.write.assert_called_once_with(expected_key.encode()) + mock_key_file.flush.assert_called_once() + + mock_function() + mock_session.return_value.__exit__.assert_called_once() + mock_cert_file.__exit__.assert_called_once() + mock_key_file.__exit__.assert_called_once() + + @patch("tempfile.NamedTemporaryFile") + @patch("requests.Session") + def test_with_session_raises_when_tempfile_creation_fails( + self, mock_session: MagicMock, mock_tempfile: MagicMock + ) -> None: + expected_timeout = timedelta(seconds=30) + + session_manager = SessionManager( + client_timeout=expected_timeout, + client_certificate={ + "certificate": "cert_content", + "key": "key_content", + }, + ) + + mock_tempfile.side_effect = OSError("unable to create temporary file") + + @session_manager.with_session + def mock_function(_: requests.Session) -> None: + msg = "Wrapped function should not be called when tempfile creation fails" + raise AssertionError(msg) + + with pytest.raises(OSError, match="unable to create temporary file"): + mock_function() + + mock_session.return_value.mount.assert_called_once() + mock_session.return_value.__exit__.assert_called_once() + assert mock_tempfile.call_count == 1 + + @patch("requests.Session") + def test_with_session_raises_when_wrapped_function_fails( + self, mock_session: MagicMock + ) -> None: + expected_timeout = timedelta(seconds=30) + session_manager = SessionManager( + client_timeout=expected_timeout, + client_certificate=None, + ) + + @session_manager.with_session + def mock_function(_: requests.Session) -> None: + raise RuntimeError("request handling failed") + + with pytest.raises(RuntimeError, match="request handling failed"): + mock_function() + + mock_session.return_value.mount.assert_called_once() + mock_session.return_value.__exit__.assert_called_once() + + def test_adapter_applies_timeout(self) -> None: + with patch.object( + requests.adapters.HTTPAdapter, "send", autospec=True + ) as mock_send: + expected_timeout = timedelta(seconds=30) + adapter = SessionManager._Adapter(timeout=expected_timeout.total_seconds()) # noqa: SLF001 - Private access for testing + + mock_request = Mock() + + expected_response = Mock() + mock_send.return_value = expected_response + + response = adapter.send(mock_request, verify=True) + assert response == expected_response + + mock_send.assert_called_once_with( + adapter, + mock_request, + verify=True, + timeout=expected_timeout.total_seconds(), + ) + + def test_adapter_request_error(self) -> None: + with patch.object( + requests.adapters.HTTPAdapter, "send", autospec=True + ) as mock_send: + mock_send.side_effect = requests.RequestException("request failed") + expected_timeout = timedelta(seconds=30) + adapter = SessionManager._Adapter(timeout=expected_timeout.total_seconds()) # noqa: SLF001 - Private access for testing + + mock_request = Mock() + + with pytest.raises(requests.RequestException, match="request failed"): + adapter.send(mock_request) diff --git a/pathology-api/test_lambda_handler.py b/pathology-api/test_lambda_handler.py index 7f867aea..bf635a55 100644 --- a/pathology-api/test_lambda_handler.py +++ b/pathology-api/test_lambda_handler.py @@ -1,12 +1,24 @@ +import os from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pydantic import pytest + +os.environ["CLIENT_TIMEOUT"] = "1s" +os.environ["APIM_TOKEN_URL"] = "apim_url" # noqa S105 - dummy value +os.environ["APIM_PRIVATE_KEY_NAME"] = "apim_private_key_name" +os.environ["APIM_API_KEY_NAME"] = "apim_api_key_name" +os.environ["APIM_TOKEN_EXPIRY_THRESHOLD"] = "1s" # noqa S105 - dummy value +os.environ["APIM_KEY_ID"] = "apim_key" +os.environ["PDM_BUNDLE_URL"] = "pdm_bundle_url" + from aws_lambda_powertools.utilities.typing import LambdaContext -from lambda_handler import handler + +with patch("aws_lambda_powertools.utilities.parameters.get_secret") as get_secret_mock: + from lambda_handler import handler from pathology_api.exception import ValidationError -from pathology_api.fhir.r4.elements import LogicalReference, PatientIdentifier +from pathology_api.fhir.r4.elements import LogicalReference, Meta, PatientIdentifier from pathology_api.fhir.r4.resources import Bundle, Composition, OperationOutcome @@ -40,7 +52,8 @@ def _parse_returned_issue(self, response: str) -> OperationOutcome.Issue: returned_issue = response_outcome.issue[0] return returned_issue - def test_create_test_result_success(self) -> None: + @patch("lambda_handler.handle_request") + def test_create_test_result_success(self, handle_request_mock: MagicMock) -> None: bundle = Bundle.create( type="document", entry=[ @@ -54,6 +67,15 @@ def test_create_test_result_success(self) -> None: ) ], ) + + expected_response = Bundle.create( + id="test-id", + type="document", + meta=Meta.with_last_updated(), + entry=bundle.entries, + ) + handle_request_mock.return_value = expected_response + event = self._create_test_event( body=bundle.model_dump_json(by_alias=True), path_params="FHIR/R4/Bundle", @@ -70,11 +92,7 @@ def test_create_test_result_success(self) -> None: assert isinstance(response_body, str) response_bundle = Bundle.model_validate_json(response_body, by_alias=True) - assert response_bundle.bundle_type == bundle.bundle_type - assert response_bundle.entries == bundle.entries - - # A UUID value so can only check its presence. - assert response_bundle.id is not None + assert response_bundle == expected_response def test_create_test_result_no_payload(self) -> None: event = self._create_test_event( diff --git a/pathology-api/tests/conftest.py b/pathology-api/tests/conftest.py index 191c21d6..8f92319b 100644 --- a/pathology-api/tests/conftest.py +++ b/pathology-api/tests/conftest.py @@ -110,7 +110,7 @@ def __init__( self, api_url: str, auth_headers: dict[str, str], - timeout: timedelta = timedelta(seconds=5), + timeout: timedelta = timedelta(seconds=20), ): self._api_url = api_url self._default_headers = auth_headers | {"Content-Type": "application/fhir+json"} diff --git a/pathology-api/tests/contract/test_provider_contract.py b/pathology-api/tests/contract/test_provider_contract.py index 53efb1da..ece4b119 100644 --- a/pathology-api/tests/contract/test_provider_contract.py +++ b/pathology-api/tests/contract/test_provider_contract.py @@ -35,6 +35,7 @@ def test_provider_honors_consumer_contract( verifier.add_source( "tests/contract/pacts/PathologyAPIConsumer-PathologyAPIProvider.json" ) + verifier.set_request_timeout(20000) # Verify the provider against the pact verifier.verify() diff --git a/schemathesis.toml b/schemathesis.toml index ac9da2d4..a9d4ec39 100644 --- a/schemathesis.toml +++ b/schemathesis.toml @@ -1,2 +1,4 @@ +request-timeout = 20.0 + [generation] mode = "positive" diff --git a/scripts/get_apigee_token.sh b/scripts/get_apigee_token.sh old mode 100644 new mode 100755 diff --git a/scripts/tests/run-test.sh b/scripts/tests/run-test.sh index daecd4b1..7094d2dc 100755 --- a/scripts/tests/run-test.sh +++ b/scripts/tests/run-test.sh @@ -33,7 +33,7 @@ cd "$(git rev-parse --show-toplevel)" # Determine test path based on test type if [[ "$TEST_TYPE" = "unit" ]]; then - TEST_PATH="test_*.py src/" + TEST_PATH="src/ test_*.py" else TEST_PATH="tests/${TEST_TYPE}/" fi From e41e93a9162a80089bbc3f1dbc7b941e0f02c932 Mon Sep 17 00:00:00 2001 From: MohammadPatelNHS <247976665+MohammadPatelNHS@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:25:10 +0000 Subject: [PATCH 2/6] [CDAPI-71]: WIP PDM Mock --- .github/workflows/preview-env.yaml | 8 ++- bruno/PDM/Document/Post_a_Document.bru | 1 - bruno/PDM/Document/Retrieve_Document_mock.bru | 27 ++++++++ infrastructure/images/mocks/Dockerfile | 4 ++ mocks/lambda_handler.py | 62 ++++++++++++++++--- mocks/pyproject.toml | 5 +- mocks/src/aws_helper/__init__.py | 0 mocks/src/aws_helper/dynamo_helper.py | 13 ++++ mocks/src/aws_helper/test_dynamo_helper.py | 0 mocks/src/pdm_mock/__init__.py | 20 ++++++ mocks/src/pdm_mock/handler.py | 62 +++++++++++++++++++ mocks/src/pdm_mock/test_handler.py | 0 12 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 bruno/PDM/Document/Retrieve_Document_mock.bru create mode 100644 mocks/src/aws_helper/__init__.py create mode 100644 mocks/src/aws_helper/dynamo_helper.py create mode 100644 mocks/src/aws_helper/test_dynamo_helper.py create mode 100644 mocks/src/pdm_mock/__init__.py create mode 100644 mocks/src/pdm_mock/handler.py create mode 100644 mocks/src/pdm_mock/test_handler.py diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 84169756..a4a90567 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -326,7 +326,7 @@ jobs: AUTH_URL: "${{ steps.names.outputs.mock_preview_url }}/apim/oauth2/token" JWKS_SECRET: ${{ env._cds_pathology_dev_jwks_secret }} PUBLIC_KEY_URL: "https://example.com" - TOKEN_TABLE_NAME: "mock_services_dev" + DYNAMODB_TABLE_NAME: "mock_services_dev" run: | cd mocks/target/ MFN="${{ steps.names.outputs.mock_function_name }}" @@ -357,7 +357,8 @@ jobs: AUTH_URL=$AUTH_URL, \ PUBLIC_KEY_URL=$PUBLIC_KEY_URL, \ API_KEY=$JWKS_SECRET, \ - TOKEN_TABLE_NAME=$TOKEN_TABLE_NAME \ + TOKEN_TABLE_NAME=$DYNAMODB_TABLE_NAME \ + PDM_TABLE_NAME=$DYNAMODB_TABLE_NAME \ }" || true wait_for_lambda_ready aws lambda update-function-code --function-name "$MFN" --zip-file "fileb://artifact.zip" --publish @@ -373,7 +374,8 @@ jobs: AUTH_URL=$AUTH_URL, \ PUBLIC_KEY_URL=$PUBLIC_KEY_URL, \ API_KEY=$JWKS_SECRET, \ - TOKEN_TABLE_NAME=$TOKEN_TABLE_NAME, \ + TOKEN_TABLE_NAME=$DYNAMODB_TABLE_NAME, \ + PDM_TABLE_NAME=$DYNAMODB_TABLE_NAME \ }" \ --publish wait_for_lambda_ready diff --git a/bruno/PDM/Document/Post_a_Document.bru b/bruno/PDM/Document/Post_a_Document.bru index c8435ff8..13302205 100644 --- a/bruno/PDM/Document/Post_a_Document.bru +++ b/bruno/PDM/Document/Post_a_Document.bru @@ -17,7 +17,6 @@ headers { body:json { { "resourceType": "Bundle", - "id": "ab3c95c5-9484-4ba5-b04e-49e148411387", "identifier": { "system": "http://healthintersections.com.au/test", "value": "test_value" diff --git a/bruno/PDM/Document/Retrieve_Document_mock.bru b/bruno/PDM/Document/Retrieve_Document_mock.bru new file mode 100644 index 00000000..7e53ea8d --- /dev/null +++ b/bruno/PDM/Document/Retrieve_Document_mock.bru @@ -0,0 +1,27 @@ +meta { + name: Retrieve Document from mock + type: http + seq: 4 +} + +get { + url: http://localhost:5005/pdm/FHIR/R4/Bundle/7abe3c91-19d8-42b8-ad76-7ead70cd70ba + body: none + auth: inherit +} + +headers { + X-Request-ID: Set By Script + X-Correlation-ID: Set By Script +} + +script:pre-request { + const crypto = require("crypto"); + req.setHeader('X-Request-ID', crypto.randomUUID()) + req.setHeader('X-Correlation-ID', crypto.randomUUID()) +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/infrastructure/images/mocks/Dockerfile b/infrastructure/images/mocks/Dockerfile index a5f3b638..f5da9c7b 100644 --- a/infrastructure/images/mocks/Dockerfile +++ b/infrastructure/images/mocks/Dockerfile @@ -8,4 +8,8 @@ COPY resources/ /resources COPY /resources/build/mocks ${LAMBDA_TASK_ROOT} +# RUN mkdir -p /.aws + +# COPY /resources/.aws/ /root/.aws/ + CMD [ "lambda_handler.handler" ] diff --git a/mocks/lambda_handler.py b/mocks/lambda_handler.py index cf647b18..d3cb3c1a 100644 --- a/mocks/lambda_handler.py +++ b/mocks/lambda_handler.py @@ -11,6 +11,8 @@ ) from aws_lambda_powertools.utilities.typing import LambdaContext from jwt.exceptions import InvalidTokenError +from pdm_mock.handler import handle_get_request as handle_pdm_get_request +from pdm_mock.handler import handle_post_request as handle_pdm_post_request _logger = logging.getLogger(__name__) @@ -25,6 +27,9 @@ def _with_default_headers(status_code: int, body: str) -> Response[str]: ) +###### Health Checks ###### + + @app.get("/_status") def status() -> Response[str]: _logger.debug("Status check endpoint called") @@ -61,6 +66,9 @@ def root() -> Response[str]: return _with_default_headers(200, body=json.dumps(response_body, indent=2)) +##### APIM Mock ##### + + @app.post("/apim/oauth2/token") def post_auth() -> Response[str]: _logger.debug("Authentication Mock called") @@ -97,21 +105,61 @@ def post_auth() -> Response[str]: ) -@app.route("/apim/check_auth", method=["POST", "GET"]) -def check_auth() -> Response[str]: +def auth_check() -> bool: headers = app.current_event.headers token = headers.get("Authorization", "").replace("Bearer ", "") - if check_authenticated(token): - return _with_default_headers( - status_code=200, body=json.dumps({"message": "ok"}) - ) - else: + return check_authenticated(token) + + +##### PDM Mock ##### + + +@app.post("/pdm/FHIR/R4/Bundle") +def create_document() -> Response[str]: + _logger.debug("Post a document endpoint called") + + if not auth_check(): return _with_default_headers( status_code=401, body=json.dumps({"message": "Unauthorized"}) ) + try: + payload = app.current_event.json_body + except json.JSONDecodeError as err: + _logger.error("Error decoding JSON payload. error: %s", err) + return _with_default_headers(status_code=400, body="Invalid Payload provided.") + _logger.debug("Payload received: %s", payload) + + if not payload: + _logger.error("No payload provided.") + return _with_default_headers(status_code=400, body="No payload provided.") + + try: + response = handle_pdm_post_request(payload) + except Exception as err: + _logger.error("Error handling PDM request. error: %s", err) + return _with_default_headers(status_code=500, body="Internal Server Error") + + return _with_default_headers(status_code=200, body=json.dumps(response)) + + +@app.get("/pdm/FHIR/R4/Bundle/") +def get_document(document_id: str) -> Response[str]: + _logger.debug("Get a document endpoint called with document_id: %s", document_id) + + try: + response = handle_pdm_get_request(document_id) + except Exception as err: + _logger.error("Error handling PDM request. error: %s", err) + return _with_default_headers(status_code=500, body="Internal Server Error") + + return _with_default_headers(status_code=200, body=json.dumps(response)) + + +########## + def handler(data: dict[str, Any], context: LambdaContext) -> dict[str, Any]: return app.resolve(data, context) diff --git a/mocks/pyproject.toml b/mocks/pyproject.toml index 1ee1501a..ce9f747f 100644 --- a/mocks/pyproject.toml +++ b/mocks/pyproject.toml @@ -15,7 +15,10 @@ dependencies = [ ] [tool.poetry] -packages = [{include = "apim_mock", from = "src"}] +packages = [ + {include = "apim_mock", from = "src"}, + {include = "pdm_mock", from = "src"} +] [tool.coverage.run] relative_files = true diff --git a/mocks/src/aws_helper/__init__.py b/mocks/src/aws_helper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mocks/src/aws_helper/dynamo_helper.py b/mocks/src/aws_helper/dynamo_helper.py new file mode 100644 index 00000000..87ee37ec --- /dev/null +++ b/mocks/src/aws_helper/dynamo_helper.py @@ -0,0 +1,13 @@ +from typing import Any + +import boto3 + + +class DynamoHelper: + def __init__(self, table_name: str): + self.table_name = table_name + self.dynamodb = boto3.resource("dynamodb") + self.table = self.dynamodb.Table(self.table_name) + + def put_item(self, document: dict[str, Any]) -> None: + self.table.put_item(Item=document) diff --git a/mocks/src/aws_helper/test_dynamo_helper.py b/mocks/src/aws_helper/test_dynamo_helper.py new file mode 100644 index 00000000..e69de29b diff --git a/mocks/src/pdm_mock/__init__.py b/mocks/src/pdm_mock/__init__.py new file mode 100644 index 00000000..87a7a951 --- /dev/null +++ b/mocks/src/pdm_mock/__init__.py @@ -0,0 +1,20 @@ +import logging.config + +logging.config.dictConfig( + { + "version": 1, + "formatters": { + "default": { + "format": "[%(asctime)s] %(levelname)s - %(module)s: %(message)s", + }, + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "default", + } + }, + "root": {"level": "DEBUG", "handlers": ["stdout"]}, + } +) diff --git a/mocks/src/pdm_mock/handler.py b/mocks/src/pdm_mock/handler.py new file mode 100644 index 00000000..011c1485 --- /dev/null +++ b/mocks/src/pdm_mock/handler.py @@ -0,0 +1,62 @@ +import os +from datetime import datetime, timezone +from time import time +from typing import Any +from uuid import uuid4 + +import boto3 + +PDM_TABLE_NAME = os.environ.get("PDM_TABLE_NAME", "table_name") +BRANCH_NAME = os.environ.get("DDB_INDEX_TAG", "branch_name") + + +def handle_post_request(payload: dict[str, Any]) -> dict[str, Any]: + + document_id = str(uuid4()) + created_document = { + **payload, + "id": document_id, + "meta": { + "versionId": "1", + "last_updated": datetime.now(tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + } + item = { + "sessionId": document_id, + "expiresAt": int(time()) + 600, + "document": created_document, + "ddb_index": BRANCH_NAME, + "type": "pdm_document", + } + + write_document_to_table(item) + + return created_document + + +def handle_get_request(document_id: str) -> Any: + + table_item = get_document_from_table(document_id) + document = table_item["document"] + + return document + + +def write_document_to_table(item: dict[str, Any]) -> None: + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(PDM_TABLE_NAME) + table.put_item(Item=item) + + +def get_document_from_table(document_id: str) -> Any: + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(PDM_TABLE_NAME) + + response = table.get_item(Key={"sessionId": document_id}) + + if "Item" not in response: + return {"error": "Document not found"} + + return response["Item"] diff --git a/mocks/src/pdm_mock/test_handler.py b/mocks/src/pdm_mock/test_handler.py new file mode 100644 index 00000000..e69de29b From 2650a43b015fe59230f1060a23c97d561c94c4b3 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:27:41 +0000 Subject: [PATCH 3/6] [CDAPI-71]: Moved PDM routes into separate module and added error scenarios to PDM endpoint --- mocks/lambda_handler.py | 53 +----------- mocks/src/apim_mock/py.typed | 0 mocks/src/pdm_mock/handler.py | 148 ++++++++++++++++++++++++++++++++-- mocks/src/pdm_mock/py.typed | 0 4 files changed, 147 insertions(+), 54 deletions(-) create mode 100644 mocks/src/apim_mock/py.typed create mode 100644 mocks/src/pdm_mock/py.typed diff --git a/mocks/lambda_handler.py b/mocks/lambda_handler.py index d3cb3c1a..6247fb94 100644 --- a/mocks/lambda_handler.py +++ b/mocks/lambda_handler.py @@ -1,22 +1,22 @@ import json -import logging from typing import Any from urllib.parse import parse_qs from apim_mock.auth_check import check_authenticated from apim_mock.handler import handle_request as handle_apim_request +from apim_mock.logging import get_logger from aws_lambda_powertools.event_handler import ( APIGatewayHttpResolver, Response, ) from aws_lambda_powertools.utilities.typing import LambdaContext from jwt.exceptions import InvalidTokenError -from pdm_mock.handler import handle_get_request as handle_pdm_get_request -from pdm_mock.handler import handle_post_request as handle_pdm_post_request +from pdm_mock.handler import pdm_routes -_logger = logging.getLogger(__name__) +_logger = get_logger(__name__) app = APIGatewayHttpResolver() +app.include_router(pdm_routes) def _with_default_headers(status_code: int, body: str) -> Response[str]: @@ -113,51 +113,6 @@ def auth_check() -> bool: return check_authenticated(token) -##### PDM Mock ##### - - -@app.post("/pdm/FHIR/R4/Bundle") -def create_document() -> Response[str]: - _logger.debug("Post a document endpoint called") - - if not auth_check(): - return _with_default_headers( - status_code=401, body=json.dumps({"message": "Unauthorized"}) - ) - - try: - payload = app.current_event.json_body - except json.JSONDecodeError as err: - _logger.error("Error decoding JSON payload. error: %s", err) - return _with_default_headers(status_code=400, body="Invalid Payload provided.") - _logger.debug("Payload received: %s", payload) - - if not payload: - _logger.error("No payload provided.") - return _with_default_headers(status_code=400, body="No payload provided.") - - try: - response = handle_pdm_post_request(payload) - except Exception as err: - _logger.error("Error handling PDM request. error: %s", err) - return _with_default_headers(status_code=500, body="Internal Server Error") - - return _with_default_headers(status_code=200, body=json.dumps(response)) - - -@app.get("/pdm/FHIR/R4/Bundle/") -def get_document(document_id: str) -> Response[str]: - _logger.debug("Get a document endpoint called with document_id: %s", document_id) - - try: - response = handle_pdm_get_request(document_id) - except Exception as err: - _logger.error("Error handling PDM request. error: %s", err) - return _with_default_headers(status_code=500, body="Internal Server Error") - - return _with_default_headers(status_code=200, body=json.dumps(response)) - - ########## diff --git a/mocks/src/apim_mock/py.typed b/mocks/src/apim_mock/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/mocks/src/pdm_mock/handler.py b/mocks/src/pdm_mock/handler.py index 011c1485..6e03e41a 100644 --- a/mocks/src/pdm_mock/handler.py +++ b/mocks/src/pdm_mock/handler.py @@ -1,16 +1,94 @@ +import json import os +from collections.abc import Callable from datetime import datetime, timezone from time import time -from typing import Any +from typing import Any, TypedDict from uuid import uuid4 import boto3 +from apim_mock.auth_check import check_authenticated +from apim_mock.logging import get_logger +from aws_lambda_powertools.event_handler import Response +from aws_lambda_powertools.event_handler.router import APIGatewayHttpRouter PDM_TABLE_NAME = os.environ.get("PDM_TABLE_NAME", "table_name") BRANCH_NAME = os.environ.get("DDB_INDEX_TAG", "branch_name") +# Constructor for APIGatewayHttpRouter leads to untyped code. +pdm_routes = APIGatewayHttpRouter() # type: ignore -def handle_post_request(payload: dict[str, Any]) -> dict[str, Any]: +_logger = get_logger(__name__) + + +class PDMResponse(TypedDict): + status_code: int + response: dict[str, Any] + + +type RequestHandler = Callable[[], PDMResponse] + + +def _create_operation_outcome( + status_code: int, error_text: str, error_code: str +) -> PDMResponse: + return { + "status_code": status_code, + "response": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": error_code, + "details": { + "text": error_text, + }, + } + ], + }, + } + + +def _raise_error(status_code: int, error_text: str, error_code: str) -> RequestHandler: + return lambda: _create_operation_outcome(status_code, error_text, error_code) + + +REQUEST_HANDLERS: dict[str, RequestHandler] = { + "PDM_VALIDATION_ERROR": _raise_error( + 422, + "Bundle size exceeds maximum allowed size or number of entries.", + "invariant", + ), + "PDM_SERVER_ERROR": _raise_error( + 500, + "Internal server error", + "exception", + ), +} + + +def _fetch_patient_from_payload(payload: dict[str, Any]) -> str | None: + patient_values = [ + str(patient) + for entry in payload.get("entry", []) + if (resource := entry.get("resource")) + and resource.get("resourceType") == "Patient" + and "identifier" in resource + and (patient := resource.get("identifier", {}).get("value")) + ] + + if not patient_values: + return None + + if len(patient_values) > 1: + raise ValueError("Multiple patients referenced within the same bundle") + + return str(next(iter(patient_values))) + + +def handle_post_request(payload: dict[str, Any]) -> PDMResponse: + if (patient := _fetch_patient_from_payload(payload)) in REQUEST_HANDLERS: + return REQUEST_HANDLERS[patient]() document_id = str(uuid4()) created_document = { @@ -33,15 +111,15 @@ def handle_post_request(payload: dict[str, Any]) -> dict[str, Any]: write_document_to_table(item) - return created_document + return {"status_code": 200, "response": created_document} -def handle_get_request(document_id: str) -> Any: +def handle_get_request(document_id: str) -> PDMResponse: table_item = get_document_from_table(document_id) document = table_item["document"] - return document + return {"status_code": 200, "response": document} def write_document_to_table(item: dict[str, Any]) -> None: @@ -60,3 +138,63 @@ def get_document_from_table(document_id: str) -> Any: return {"error": "Document not found"} return response["Item"] + + +def _with_default_headers(response: PDMResponse) -> Response[str]: + return Response( + body=json.dumps(response["response"]), + status_code=response["status_code"], + headers={"Content-Type": "application/fhir+json"}, + ) + + +@pdm_routes.post("/pdm/FHIR/R4/Bundle") +def create_document() -> Response[str]: + _logger.debug("Post a document endpoint called") + + if not check_authenticated( + pdm_routes.current_event.headers.get("Authorization", "").replace("Bearer ", "") + ): + return _with_default_headers( + _create_operation_outcome(401, "Unauthorized", "access_denied") + ) + + try: + payload = pdm_routes.current_event.json_body + except json.JSONDecodeError as err: + _logger.error("Error decoding JSON payload. error: %s", err) + return _with_default_headers( + _create_operation_outcome( + 400, "Invalid Payload provided.", "invalid_request" + ) + ) + _logger.debug("Payload received: %s", payload) + + if not payload: + _logger.error("No payload provided.") + return _with_default_headers( + _create_operation_outcome(400, "No payload provided.", "invalid_request") + ) + + try: + response = handle_post_request(payload) + except Exception as err: + _logger.error("Error handling PDM request. error: %s", err) + return _with_default_headers( + _create_operation_outcome(500, "Internal Server Error", "exception") + ) + + return _with_default_headers(response) + + +@pdm_routes.get("/pdm/mock/Bundle/") +def get_document(document_id: str) -> Response[str]: + _logger.debug("Get a document endpoint called with document_id: %s", document_id) + + try: + response = handle_get_request(document_id) + except Exception as err: + _logger.error("Error handling PDM request. error: %s", err) + return Response(status_code=500, body="Internal Server Error") + + return _with_default_headers(response) diff --git a/mocks/src/pdm_mock/py.typed b/mocks/src/pdm_mock/py.typed new file mode 100644 index 00000000..e69de29b From 80b016fda29f4b53d71638cf182081603548bbf1 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:47:16 +0000 Subject: [PATCH 4/6] [CDAPI-71]: Improved PDM mock error handling --- mocks/src/pdm_mock/handler.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/mocks/src/pdm_mock/handler.py b/mocks/src/pdm_mock/handler.py index 6e03e41a..e65ef350 100644 --- a/mocks/src/pdm_mock/handler.py +++ b/mocks/src/pdm_mock/handler.py @@ -12,8 +12,8 @@ from aws_lambda_powertools.event_handler import Response from aws_lambda_powertools.event_handler.router import APIGatewayHttpRouter -PDM_TABLE_NAME = os.environ.get("PDM_TABLE_NAME", "table_name") -BRANCH_NAME = os.environ.get("DDB_INDEX_TAG", "branch_name") +PDM_TABLE_NAME = os.environ["PDM_TABLE_NAME"] +BRANCH_NAME = os.environ["DDB_INDEX_TAG"] # Constructor for APIGatewayHttpRouter leads to untyped code. pdm_routes = APIGatewayHttpRouter() # type: ignore @@ -83,7 +83,7 @@ def _fetch_patient_from_payload(payload: dict[str, Any]) -> str | None: if len(patient_values) > 1: raise ValueError("Multiple patients referenced within the same bundle") - return str(next(iter(patient_values))) + return str(patient_values[0]) def handle_post_request(payload: dict[str, Any]) -> PDMResponse: @@ -178,11 +178,9 @@ def create_document() -> Response[str]: try: response = handle_post_request(payload) - except Exception as err: - _logger.error("Error handling PDM request. error: %s", err) - return _with_default_headers( - _create_operation_outcome(500, "Internal Server Error", "exception") - ) + except Exception: + _logger.exception("Error handling PDM request") + raise return _with_default_headers(response) @@ -193,8 +191,8 @@ def get_document(document_id: str) -> Response[str]: try: response = handle_get_request(document_id) - except Exception as err: - _logger.error("Error handling PDM request. error: %s", err) - return Response(status_code=500, body="Internal Server Error") + except Exception: + _logger.exception("Error handling PDM request") + raise return _with_default_headers(response) From 6720177b1deafee1c21a0fd86095a4fd99cad6b4 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:46:00 +0000 Subject: [PATCH 5/6] [CDAPI-71]: Fixed PDM_TABLE_NAME environment variable when redeploying mock lambda --- .github/workflows/preview-env.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index a4a90567..6ead7c26 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -357,7 +357,7 @@ jobs: AUTH_URL=$AUTH_URL, \ PUBLIC_KEY_URL=$PUBLIC_KEY_URL, \ API_KEY=$JWKS_SECRET, \ - TOKEN_TABLE_NAME=$DYNAMODB_TABLE_NAME \ + TOKEN_TABLE_NAME=$DYNAMODB_TABLE_NAME, \ PDM_TABLE_NAME=$DYNAMODB_TABLE_NAME \ }" || true wait_for_lambda_ready From a19eef92b0ff3750340915b6488b8e5c5775375b Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:04:10 +0000 Subject: [PATCH 6/6] [CDAPI-71]: Added specific error response for mock errors --- mocks/src/pdm_mock/handler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mocks/src/pdm_mock/handler.py b/mocks/src/pdm_mock/handler.py index e65ef350..565d2b58 100644 --- a/mocks/src/pdm_mock/handler.py +++ b/mocks/src/pdm_mock/handler.py @@ -178,9 +178,9 @@ def create_document() -> Response[str]: try: response = handle_post_request(payload) - except Exception: + except Exception as err: _logger.exception("Error handling PDM request") - raise + return Response(status_code=500, body=json.dumps({"error": str(err)})) return _with_default_headers(response) @@ -191,8 +191,8 @@ def get_document(document_id: str) -> Response[str]: try: response = handle_get_request(document_id) - except Exception: + except Exception as err: _logger.exception("Error handling PDM request") - raise + return Response(status_code=500, body=json.dumps({"error": str(err)})) return _with_default_headers(response)