diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index e9ac4ded..f9ce144a 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -154,14 +154,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=DEV-1, \ + APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \ + PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \ MNS_EVENT_URL=$MOCK_URL/mns, \ + CLIENT_TIMEOUT=1m, \ JWKS_SECRET_NAME=$JWKS_SECRET}" || true wait_for_lambda_ready aws lambda update-function-code --function-name "$FN" \ @@ -173,14 +177,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=DEV-1, \ 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=1m, \ JWKS_SECRET_NAME=$JWKS_SECRET}" \ --publish wait_for_lambda_ready 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 126a5204..42b9aae6 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 = "01a9d2938cce5c8af1bc4e03e7b03b21e69dd52554692ad5c032f344edeec0f3" +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..2a1dde30 --- /dev/null +++ b/pathology-api/src/pathology_api/apim.py @@ -0,0 +1,155 @@ +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"], + ) + + try: + 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 + except BaseException: + _logger.exception("Failed to create client assertion JWT") + raise + + 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.") + + try: + 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, + }, + ) + except BaseException: + _logger.exception("Failed to send authentication request to APIM") + raise + + _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..117b90a5 --- /dev/null +++ b/pathology-api/src/pathology_api/config.py @@ -0,0 +1,66 @@ +import os +import re +from dataclasses import dataclass +from datetime import timedelta +from enum import StrEnum +from typing import 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) + + +def get_optional_environment_variable[T](name: str, _type: type[T]) -> T | None: + value = os.getenv(name) + + if _type is Duration and value is not 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") + + if not raw_value or not raw_value.isdigit(): + raise ConfigError(f"Invalid duration value: {value!r}") + + return cast( + "T", + Duration( + unit=DurationUnit(raw_unit), + value=int(raw_value), + ), + ) + elif value is not None: + if not isinstance(value, _type): + raise ConfigError(f"Environment variable {name!r} is not of type {_type!r}") + + return value + else: + return None + + +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..48951469 100644 --- a/pathology-api/src/pathology_api/handler.py +++ b/pathology-api/src/pathology_api/handler.py @@ -1,13 +1,64 @@ 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) + +if CLIENT_CERTIFICATE_NAME and CLIENT_KEY_NAME: + certificate = parameters.get_secret(CLIENT_CERTIFICATE_NAME) + key = parameters.get_secret(CLIENT_KEY_NAME) + + CLIENT_CERTIFICATE: ClientCertificate | None = { + "certificate": certificate, + "key": key, + } +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 +99,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..dba20b9e --- /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 to will be automatically cleaned up with + # the stack. + cert_file = tempfile.NamedTemporaryFile( # noqa: SIM115 + dir="/tmp", delete=True + ) + stack.enter_context(cert_file) + + # File added to Exit stack to will be automatically cleaned up with + # the stack. + key_file = tempfile.NamedTemporaryFile( # noqa: SIM115 + dir="/tmp", 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_handler.py b/pathology-api/src/pathology_api/test_handler.py index d649d4a4..298bf7e3 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 Mock, 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,7 +21,24 @@ 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, +): + apim_authenticator_mock.return_value.auth = mock_auth + from pathology_api.handler import handle_request class TestHandleRequest: @@ -49,6 +79,30 @@ 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"]) + + 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", diff --git a/pathology-api/test_lambda_handler.py b/pathology-api/test_lambda_handler.py index 7f867aea..ab4a41cf 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,13 @@ 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: + get_secret_mock.side_effect = lambda secret_name: { + os.environ["APIM_PRIVATE_KEY_NAME"]: "private_key", + os.environ["APIM_API_KEY_NAME"]: "api_key", + }[secret_name] + bundle = Bundle.create( type="document", entry=[ @@ -54,6 +72,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 +97,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(