From 0e0b964a55a22308165f804e9cba22e9313bd2da 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 01/17] [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. --- pathology-api/poetry.lock | 131 +++++++++++------------- pathology-api/pyproject.toml | 3 +- pathology-api/src/pathology_api/apim.py | 115 +++++++++++++++++++++ pathology-api/src/pathology_api/http.py | 53 ++++++++++ 4 files changed, 230 insertions(+), 72 deletions(-) create mode 100644 pathology-api/src/pathology_api/apim.py create mode 100644 pathology-api/src/pathology_api/http.py diff --git a/pathology-api/poetry.lock b/pathology-api/poetry.lock index 126a5204..b01f33a9 100644 --- a/pathology-api/poetry.lock +++ b/pathology-api/poetry.lock @@ -137,7 +137,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 +224,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\""} @@ -485,61 +486,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"] 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] @@ -1666,12 +1667,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,33 +1926,21 @@ version = "2.11.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] 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"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] -[[package]] -name = "pyotp" -version = "2.9.0" -description = "Python One Time Password Library" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, - {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, -] - -[package.extras] -test = ["coverage", "mypy", "ruff", "wheel"] - [[package]] name = "pyrate-limiter" version = "3.9.0" @@ -2875,4 +2864,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "01a9d2938cce5c8af1bc4e03e7b03b21e69dd52554692ad5c032f344edeec0f3" +content-hash = "bdec312aa620287731b8f0900fea37b3fe8b8f83e58075105de6bc73e8144d1f" diff --git a/pathology-api/pyproject.toml b/pathology-api/pyproject.toml index 0ef04856..e1e589bc 100644 --- a/pathology-api/pyproject.toml +++ b/pathology-api/pyproject.toml @@ -9,7 +9,8 @@ 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)" ] [tool.poetry] diff --git a/pathology-api/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py new file mode 100644 index 00000000..63d29189 --- /dev/null +++ b/pathology-api/src/pathology_api/apim.py @@ -0,0 +1,115 @@ +import uuid +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from typing import Any, Concatenate, TypedDict + +import jwt +import requests + +from pathology_api.http import SessionManager + + +class ApimAuthenticationException(Exception): + pass + + +# Type alias describing the expected signature for use with the `Authenticator.auth` +# decorator. +# 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 AuthenticatedMethod[**P] = Callable[Concatenate[requests.Session, P], Any] + + +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]( + self, func: AuthenticatedMethod[P] + ) -> Callable[[AuthenticatedMethod[P]], AuthenticatedMethod[P]]: + """ + Decorate a given function with APIM authentication. This authentication will be + provided via a `requests.Session` object. + """ + + def wrapper(*args: Any, **kwargs: Any) -> Any: + # 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 + ): + self.__access_token = self._authenticate() + + with self._session_manager.open_session() as session: + session.headers.update( + {"Authorization": f"Bearer {self.__access_token['value']}"} + ) + return func(session, *args, **kwargs) + + return wrapper + + def _create_client_assertion(self) -> str: + 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() + ), + } + + return jwt.encode( + claims, + self._private_key, + algorithm="RS512", + headers={"kid": self._key_id}, + ) + + def _authenticate(self) -> __AccessToken: + with self._session_manager.open_session() as 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": self._create_client_assertion(), + }, + ) + + 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() + + return { + "value": response_data["access_token"], + "expiry": datetime.now(tz=timezone.utc) + + timedelta(seconds=response_data["expires_in"]), + } diff --git a/pathology-api/src/pathology_api/http.py b/pathology-api/src/pathology_api/http.py new file mode 100644 index 00000000..8eb2c7a5 --- /dev/null +++ b/pathology-api/src/pathology_api/http.py @@ -0,0 +1,53 @@ +from datetime import timedelta +from typing import Any, TypedDict + +import requests +from requests.adapters import HTTPAdapter + + +class ClientCertificate(TypedDict): + certificate_path: str + key_path: 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: + 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 open_session(self) -> requests.Session: + session = requests.Session() + + if self._client_certificate is not None: + session.cert = ( + self._client_certificate["certificate_path"], + self._client_certificate["key_path"], + ) + + session.mount("https://", self._client_adapter) + session.mount("http://", self._client_adapter) + + return session From 7705ddb91f60e3606a8d41159fbdc62ce55573da Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:08:31 +0000 Subject: [PATCH 02/17] [CDAPI-85]: Added initial plumbing for lambda to utilise provided environment variables --- .github/workflows/preview-env.yaml | 12 ++-- pathology-api/lambda_handler.py | 1 - pathology-api/src/pathology_api/apim.py | 48 ++++++++++------ pathology-api/src/pathology_api/config.py | 67 ++++++++++++++++++++++ pathology-api/src/pathology_api/handler.py | 63 ++++++++++++++++++++ pathology-api/src/pathology_api/http.py | 56 +++++++++++++----- 6 files changed, 211 insertions(+), 36 deletions(-) create mode 100644 pathology-api/src/pathology_api/config.py diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index e9ac4ded..0ace3aab 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -159,9 +159,11 @@ jobs: 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=5s, \ JWKS_SECRET_NAME=$JWKS_SECRET}" || true wait_for_lambda_ready aws lambda update-function-code --function-name "$FN" \ @@ -176,11 +178,13 @@ jobs: --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=5s, \ JWKS_SECRET_NAME=$JWKS_SECRET}" \ --publish wait_for_lambda_ready 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/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py index 63d29189..9ab4dcab 100644 --- a/pathology-api/src/pathology_api/apim.py +++ b/pathology-api/src/pathology_api/apim.py @@ -1,25 +1,22 @@ +import functools import uuid from collections.abc import Callable from datetime import datetime, timedelta, timezone -from typing import Any, Concatenate, TypedDict +from typing import Any, TypedDict import jwt import requests -from pathology_api.http import SessionManager +from pathology_api.http import RequestMethod, SessionManager +from pathology_api.logging import get_logger + +_logger = get_logger(__name__) class ApimAuthenticationException(Exception): pass -# Type alias describing the expected signature for use with the `Authenticator.auth` -# decorator. -# 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 AuthenticatedMethod[**P] = Callable[Concatenate[requests.Session, P], Any] - - class ApimAuthenticator: class __AccessToken(TypedDict): value: str @@ -43,15 +40,23 @@ def __init__( self.__access_token: ApimAuthenticator.__AccessToken | None = None - def auth[**P]( - self, func: AuthenticatedMethod[P] - ) -> Callable[[AuthenticatedMethod[P]], AuthenticatedMethod[P]]: + 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 ( @@ -59,13 +64,10 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: or self.__access_token["expiry"] - datetime.now(tz=timezone.utc) < self._token_validity_threshold ): + _logger.debug("Authenticating with APIM...") self.__access_token = self._authenticate() - with self._session_manager.open_session() as session: - session.headers.update( - {"Authorization": f"Bearer {self.__access_token['value']}"} - ) - return func(session, *args, **kwargs) + return with_session(self.__access_token) return wrapper @@ -88,7 +90,8 @@ def _create_client_assertion(self) -> str: ) def _authenticate(self) -> __AccessToken: - with self._session_manager.open_session() as session: + @self._session_manager.with_session + def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken: response = session.post( self._token_endpoint, data={ @@ -98,6 +101,9 @@ def _authenticate(self) -> __AccessToken: "client_assertion": self._create_client_assertion(), }, ) + _logger.debug( + "Sending authentication request to APIM: %s", self._token_endpoint + ) if response.status_code != 200: raise ApimAuthenticationException( @@ -107,9 +113,15 @@ def _authenticate(self) -> __AccessToken: ) 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=response_data["expires_in"]), } + + 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..2c20e3de --- /dev/null +++ b/pathology-api/src/pathology_api/config.py @@ -0,0 +1,67 @@ +import os +import re +from dataclasses import dataclass +from datetime import timedelta +from enum import StrEnum + + +class ConfigError(Exception): + pass + + +class DurationUnit(StrEnum): + SECONDS = "s" + MINUTES = "m" + + +@dataclass(frozen=True) +class Duration: + def __init__(self, unit: DurationUnit, value: int): + self.unit = unit + self.value = value + + @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) + + match _type: + case Duration() if 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 Duration( + unit=DurationUnit(raw_unit), + value=int(raw_value), + ) + + case _ if value is not None: + if not isinstance(value, _type): + raise ConfigError( + f"Environment variable {name!r} is not of type {_type!r}" + ) + + return value + case _: + 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..10415555 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 auth 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 index 8eb2c7a5..8ecc56d6 100644 --- a/pathology-api/src/pathology_api/http.py +++ b/pathology-api/src/pathology_api/http.py @@ -1,13 +1,22 @@ +import functools +import tempfile +from collections.abc import Callable +from contextlib import ExitStack from datetime import timedelta -from typing import Any, TypedDict +from typing import Any, Concatenate, TypedDict import requests from requests.adapters import HTTPAdapter +# 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_path: str - key_path: str + certificate: str + key: str class SessionManager: @@ -38,16 +47,37 @@ def __init__( self._client_adapter = self._Adapter(timeout=client_timeout.total_seconds()) self._client_certificate = client_certificate - def open_session(self) -> requests.Session: - session = requests.Session() + 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: + session = requests.Session() + stack.enter_context(session) + + session.mount("https://", self._client_adapter) + + if self._client_certificate is not None: + # File added to Exit stack to will be automatically cleaned up with + # the stack. + cert_file = tempfile.NamedTemporaryFile( # noqa: SIM115 + dir="/tmp/pathology", 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/pathology", 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() - if self._client_certificate is not None: - session.cert = ( - self._client_certificate["certificate_path"], - self._client_certificate["key_path"], - ) + session.cert = (cert_file.name, key_file.name) - session.mount("https://", self._client_adapter) - session.mount("http://", self._client_adapter) + return func(session, *args, **kwargs) - return session + return wrapper From 7500fb68435fcb8ff7ac0f9a5579f8d90c569209 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:51:25 +0000 Subject: [PATCH 03/17] [CDAPI-85]: Moved requests to be a main dependency --- pathology-api/poetry.lock | 12 ++++++------ pathology-api/pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pathology-api/poetry.lock b/pathology-api/poetry.lock index b01f33a9..c173510a 100644 --- a/pathology-api/poetry.lock +++ b/pathology-api/poetry.lock @@ -125,7 +125,7 @@ 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"}, @@ -235,7 +235,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"}, @@ -745,7 +745,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"}, @@ -2239,7 +2239,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"}, @@ -2656,7 +2656,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"}, @@ -2864,4 +2864,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "bdec312aa620287731b8f0900fea37b3fe8b8f83e58075105de6bc73e8144d1f" +content-hash = "87821fa9f43a94360bc0295ea299fd6af58d85ca536e443b8914a700522d56ba" diff --git a/pathology-api/pyproject.toml b/pathology-api/pyproject.toml index e1e589bc..21191ce7 100644 --- a/pathology-api/pyproject.toml +++ b/pathology-api/pyproject.toml @@ -10,7 +10,8 @@ requires-python = ">3.13,<4.0.0" dependencies = [ "aws-lambda-powertools (>=3.24.0,<4.0.0)", "pydantic (>=2.12.5,<3.0.0)", - "pyjwt[crypto] (>=2.11.0,<3.0.0)" + "pyjwt[crypto] (>=2.11.0,<3.0.0)", + "requests>=2.31.0" ] [tool.poetry] @@ -48,7 +49,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)", From c3654ffd9b5c1eb6deb48c560b440cf824328bec Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:51:39 +0000 Subject: [PATCH 04/17] [CDAPI-85]: Minor fix to type conditions within config.py --- pathology-api/src/pathology_api/config.py | 42 +++++++++++------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/pathology-api/src/pathology_api/config.py b/pathology-api/src/pathology_api/config.py index 2c20e3de..a2711259 100644 --- a/pathology-api/src/pathology_api/config.py +++ b/pathology-api/src/pathology_api/config.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import timedelta from enum import StrEnum +from typing import cast class ConfigError(Exception): @@ -32,32 +33,31 @@ def timedelta(self) -> timedelta: def get_optional_environment_variable[T](name: str, _type: type[T]) -> T | None: value = os.getenv(name) - match _type: - case Duration() if 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}") + 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") + 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}") + if not raw_value or not raw_value.isdigit(): + raise ConfigError(f"Invalid duration value: {value!r}") - return Duration( + return cast( + "T", + Duration( unit=DurationUnit(raw_unit), value=int(raw_value), - ) - - case _ if value is not None: - if not isinstance(value, _type): - raise ConfigError( - f"Environment variable {name!r} is not of type {_type!r}" - ) - - return value - case _: - return None + ), + ) + 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: From 3bbade821c8f08137bdf12b2c6994409ebe61cb2 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:45:26 +0000 Subject: [PATCH 05/17] [CDAPI-85]: Minor Duration class fix --- pathology-api/src/pathology_api/config.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pathology-api/src/pathology_api/config.py b/pathology-api/src/pathology_api/config.py index a2711259..117b90a5 100644 --- a/pathology-api/src/pathology_api/config.py +++ b/pathology-api/src/pathology_api/config.py @@ -17,9 +17,8 @@ class DurationUnit(StrEnum): @dataclass(frozen=True) class Duration: - def __init__(self, unit: DurationUnit, value: int): - self.unit = unit - self.value = value + unit: DurationUnit + value: int @property def timedelta(self) -> timedelta: From 74936e5c9f257e670450b020c7ac10dca4ebac05 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:46:32 +0000 Subject: [PATCH 06/17] [CDAPI-85]: Minor lambda environment variable fix --- .github/workflows/preview-env.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 0ace3aab..8975d50c 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -159,7 +159,7 @@ jobs: APIM_API_KEY_NAME=$API_KEY, \ APIM_MTLS_CERT_NAME=$MTLS_CERT, \ APIM_MTLS_KEY_NAME=$MTLS_KEY, \ - APIM_KEY_ID=DEV-1 \ + 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, \ @@ -178,7 +178,7 @@ jobs: --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_KEY_ID=DEV-1, \ APIM_MTLS_CERT_NAME=$MTLS_CERT, \ APIM_MTLS_KEY_NAME=$MTLS_KEY, \ APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \ From a910fc675bb09c97891e5d235684c21ce8a61d2e Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:29:55 +0000 Subject: [PATCH 07/17] [CDAPI-85]: Initial unit test fixes WIP --- .vscode/settings.json | 4 ++ pathology-api/poetry.lock | 64 ++++++++++++++++++- pathology-api/pyproject.toml | 3 +- .../src/pathology_api/test_handler.py | 9 +++ pathology-api/test_lambda_handler.py | 41 +++++++++--- 5 files changed, 108 insertions(+), 13 deletions(-) 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/poetry.lock b/pathology-api/poetry.lock index c173510a..eec50023 100644 --- a/pathology-api/poetry.lock +++ b/pathology-api/poetry.lock @@ -119,6 +119,46 @@ 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" @@ -2110,7 +2150,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"}, @@ -2426,6 +2466,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" @@ -2493,7 +2551,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"}, @@ -2864,4 +2922,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "87821fa9f43a94360bc0295ea299fd6af58d85ca536e443b8914a700522d56ba" +content-hash = "ce827a8ca274710da8c8e6289e8fe8efaa3383381102f7f1124304243912cfb8" diff --git a/pathology-api/pyproject.toml b/pathology-api/pyproject.toml index 21191ce7..66a7799a 100644 --- a/pathology-api/pyproject.toml +++ b/pathology-api/pyproject.toml @@ -11,7 +11,8 @@ dependencies = [ "aws-lambda-powertools (>=3.24.0,<4.0.0)", "pydantic (>=2.12.5,<3.0.0)", "pyjwt[crypto] (>=2.11.0,<3.0.0)", - "requests>=2.31.0" + "requests>=2.31.0", + "boto3 (>=1.42.64,<2.0.0)" ] [tool.poetry] diff --git a/pathology-api/src/pathology_api/test_handler.py b/pathology-api/src/pathology_api/test_handler.py index d649d4a4..4b402dcf 100644 --- a/pathology-api/src/pathology_api/test_handler.py +++ b/pathology-api/src/pathology_api/test_handler.py @@ -1,7 +1,16 @@ import datetime +import os 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 pathology_api.exception import ValidationError from pathology_api.fhir.r4.elements import ( LogicalReference, 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( From 995efbaebd5f402b0740cb02c7572c1cd85d1b8e Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:36:18 +0000 Subject: [PATCH 08/17] [CDAPI-85]: Moved SessionManager directory to use top level /tmp --- pathology-api/src/pathology_api/http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pathology-api/src/pathology_api/http.py b/pathology-api/src/pathology_api/http.py index 8ecc56d6..4b493f1f 100644 --- a/pathology-api/src/pathology_api/http.py +++ b/pathology-api/src/pathology_api/http.py @@ -60,14 +60,14 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # File added to Exit stack to will be automatically cleaned up with # the stack. cert_file = tempfile.NamedTemporaryFile( # noqa: SIM115 - dir="/tmp/pathology", delete=True + 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/pathology", delete=True + dir="/tmp", delete=True ) stack.enter_context(key_file) From 78f1eb7d944a1007f68328a2e41bf0dc2a1e455a Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:54:32 +0000 Subject: [PATCH 09/17] [CDAPI-85]: Added additional logging to APIM authentication --- pathology-api/src/pathology_api/apim.py | 6 +++--- pathology-api/src/pathology_api/http.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pathology-api/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py index 9ab4dcab..85db96b9 100644 --- a/pathology-api/src/pathology_api/apim.py +++ b/pathology-api/src/pathology_api/apim.py @@ -101,9 +101,6 @@ def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken: "client_assertion": self._create_client_assertion(), }, ) - _logger.debug( - "Sending authentication request to APIM: %s", self._token_endpoint - ) if response.status_code != 200: raise ApimAuthenticationException( @@ -124,4 +121,7 @@ def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken: + timedelta(seconds=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/http.py b/pathology-api/src/pathology_api/http.py index 4b493f1f..f3086caf 100644 --- a/pathology-api/src/pathology_api/http.py +++ b/pathology-api/src/pathology_api/http.py @@ -8,6 +8,10 @@ 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. @@ -51,12 +55,16 @@ 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 @@ -77,6 +85,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: key_file.flush() session.cert = (cert_file.name, key_file.name) + _logger.debug("Client certificate added.") return func(session, *args, **kwargs) From d1a3474da81f1e57066f64cd41b2420402c5d2aa Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:10:17 +0000 Subject: [PATCH 10/17] [CDAPI-85]: Minor poetry.lock fix post rebase --- pathology-api/poetry.lock | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pathology-api/poetry.lock b/pathology-api/poetry.lock index eec50023..42b9aae6 100644 --- a/pathology-api/poetry.lock +++ b/pathology-api/poetry.lock @@ -530,7 +530,7 @@ 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 = ["main"] +groups = ["main", "dev"] files = [ {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"}, @@ -593,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]] @@ -1966,7 +1966,7 @@ version = "2.11.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["main"] +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"}, @@ -1981,6 +1981,21 @@ dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pyt docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] +[[package]] +name = "pyotp" +version = "2.9.0" +description = "Python One Time Password Library" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, + {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, +] + +[package.extras] +test = ["coverage", "mypy", "ruff", "wheel"] + [[package]] name = "pyrate-limiter" version = "3.9.0" @@ -2922,4 +2937,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">3.13,<4.0.0" -content-hash = "ce827a8ca274710da8c8e6289e8fe8efaa3383381102f7f1124304243912cfb8" +content-hash = "be09d7dd0dfdbbd7e96fb53c81834218363383494569278f2e651c9eb6761c25" From 70b7cf40c459da8c212ec8749552c743e2462bb2 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:29:51 +0000 Subject: [PATCH 11/17] [CDAPI-85]: Increased client timeout Also included additional logging to support debugging. --- .github/workflows/preview-env.yaml | 4 ++-- pathology-api/src/pathology_api/apim.py | 8 ++++++++ pathology-api/src/pathology_api/http.py | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 8975d50c..ab29dc12 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -163,7 +163,7 @@ jobs: APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \ PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \ MNS_EVENT_URL=$MOCK_URL/mns, \ - CLIENT_TIMEOUT=5s, \ + CLIENT_TIMEOUT=1m, \ JWKS_SECRET_NAME=$JWKS_SECRET}" || true wait_for_lambda_ready aws lambda update-function-code --function-name "$FN" \ @@ -184,7 +184,7 @@ jobs: APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \ PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \ MNS_EVENT_URL=$MOCK_URL/mns, \ - CLIENT_TIMEOUT=5s, \ + CLIENT_TIMEOUT=1m, \ JWKS_SECRET_NAME=$JWKS_SECRET}" \ --publish wait_for_lambda_ready diff --git a/pathology-api/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py index 85db96b9..e7a43726 100644 --- a/pathology-api/src/pathology_api/apim.py +++ b/pathology-api/src/pathology_api/apim.py @@ -72,6 +72,7 @@ def with_session( 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, @@ -81,6 +82,12 @@ def _create_client_assertion(self) -> str: (datetime.now(tz=timezone.utc) + timedelta(seconds=30)).timestamp() ), } + _logger.debug( + "Created client assertion. jti: %s, exp: %s, aud: %s", + claims["jti"], + claims["exp"], + claims["aud"], + ) return jwt.encode( claims, @@ -92,6 +99,7 @@ def _create_client_assertion(self) -> str: def _authenticate(self) -> __AccessToken: @self._session_manager.with_session def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken: + _logger.debug("Sending token request with created session.") response = session.post( self._token_endpoint, data={ diff --git a/pathology-api/src/pathology_api/http.py b/pathology-api/src/pathology_api/http.py index f3086caf..dba20b9e 100644 --- a/pathology-api/src/pathology_api/http.py +++ b/pathology-api/src/pathology_api/http.py @@ -40,6 +40,9 @@ def send( *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) From cd646a4ee2b551e20da422c8773a1296e4a1ed27 Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:46:49 +0000 Subject: [PATCH 12/17] [CDAPI-85]: Added additional logging around APIM authentication --- pathology-api/src/pathology_api/apim.py | 48 +++++++++++++++++-------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/pathology-api/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py index e7a43726..92a870fc 100644 --- a/pathology-api/src/pathology_api/apim.py +++ b/pathology-api/src/pathology_api/apim.py @@ -89,25 +89,45 @@ def _create_client_assertion(self) -> str: claims["aud"], ) - return jwt.encode( - claims, - self._private_key, - algorithm="RS512", - headers={"kid": self._key_id}, - ) + 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.") - 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": self._create_client_assertion(), - }, + + 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: From ed838fb18589319fdea55d1b5468634f7b8336bb Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:52:46 +0000 Subject: [PATCH 13/17] [CDAPI-85]: Fixed test_handler unit tests --- .../src/pathology_api/test_handler.py | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/pathology-api/src/pathology_api/test_handler.py b/pathology-api/src/pathology_api/test_handler.py index 4b402dcf..298bf7e3 100644 --- a/pathology-api/src/pathology_api/test_handler.py +++ b/pathology-api/src/pathology_api/test_handler.py @@ -1,7 +1,11 @@ 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 @@ -17,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: @@ -58,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", From f37947fb0649985961f9f7fd8e0b9f5ba9a91e2e Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:56:22 +0000 Subject: [PATCH 14/17] [CDAPI-85]: Bumped pathology-api lambda timeout to 30 seconds --- .github/workflows/preview-env.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index ab29dc12..23471957 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -154,6 +154,7 @@ jobs: wait_for_lambda_ready aws lambda update-function-configuration --function-name "$FN" \ --handler "${{ env.LAMBDA_HANDLER }}" \ + --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ APIM_API_KEY_NAME=$API_KEY, \ @@ -175,6 +176,7 @@ jobs: --handler "${{ env.LAMBDA_HANDLER }}" \ --zip-file "fileb://artifact.zip" \ --role "${{ steps.role-select.outputs.lambda_role }}" \ + --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ APIM_API_KEY_NAME=$API_KEY, \ From a3677757f4bc84e08db7cf5529c9345155bfe66e Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:41:19 +0000 Subject: [PATCH 15/17] [CDAPI-85]: Added type conversion for expiry time within ApimAuthenticator --- pathology-api/src/pathology_api/apim.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pathology-api/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py index 92a870fc..acaa6f85 100644 --- a/pathology-api/src/pathology_api/apim.py +++ b/pathology-api/src/pathology_api/apim.py @@ -146,7 +146,7 @@ def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken: return { "value": response_data["access_token"], "expiry": datetime.now(tz=timezone.utc) - + timedelta(seconds=response_data["expires_in"]), + + timedelta(seconds=int(response_data["expires_in"])), } _logger.debug( From e0eeb7ff15def55aded9a8e7d0be459499fc16fb Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:57:12 +0000 Subject: [PATCH 16/17] [CDAPI-85]: Increase memory for pathology-api lambda --- .github/workflows/preview-env.yaml | 2 ++ pathology-api/src/pathology_api/apim.py | 2 +- pathology-api/src/pathology_api/handler.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 23471957..162e3994 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -154,6 +154,7 @@ jobs: wait_for_lambda_ready aws lambda update-function-configuration --function-name "$FN" \ --handler "${{ env.LAMBDA_HANDLER }}" \ + --memory-size 256 \ --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ @@ -176,6 +177,7 @@ jobs: --handler "${{ env.LAMBDA_HANDLER }}" \ --zip-file "fileb://artifact.zip" \ --role "${{ steps.role-select.outputs.lambda_role }}" \ + --memory-size 256 \ --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ diff --git a/pathology-api/src/pathology_api/apim.py b/pathology-api/src/pathology_api/apim.py index acaa6f85..2a1dde30 100644 --- a/pathology-api/src/pathology_api/apim.py +++ b/pathology-api/src/pathology_api/apim.py @@ -83,7 +83,7 @@ def _create_client_assertion(self) -> str: ), } _logger.debug( - "Created client assertion. jti: %s, exp: %s, aud: %s", + "Created client claims. jti: %s, exp: %s, aud: %s", claims["jti"], claims["exp"], claims["aud"], diff --git a/pathology-api/src/pathology_api/handler.py b/pathology-api/src/pathology_api/handler.py index 10415555..48951469 100644 --- a/pathology-api/src/pathology_api/handler.py +++ b/pathology-api/src/pathology_api/handler.py @@ -101,7 +101,7 @@ def handle_request(bundle: Bundle) -> Bundle: auth_response = _send_request(PDM_URL) _logger.debug( - "Result of auth request. status_code=%s data=%s", + "Result of authenticated request. status_code=%s data=%s", auth_response.status_code, auth_response.text, ) From 3a5e0b7e7f00de9728e9de8e3784349413a6bd2e Mon Sep 17 00:00:00 2001 From: Jack Wainwright <79214177+nhsd-jack-wainwright@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:21:40 +0000 Subject: [PATCH 17/17] [CDAPI-85]: Increased pathology-api lambda memory to 512MB --- .github/workflows/preview-env.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 162e3994..f9ce144a 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -154,7 +154,7 @@ jobs: wait_for_lambda_ready aws lambda update-function-configuration --function-name "$FN" \ --handler "${{ env.LAMBDA_HANDLER }}" \ - --memory-size 256 \ + --memory-size 512 \ --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ @@ -177,7 +177,7 @@ jobs: --handler "${{ env.LAMBDA_HANDLER }}" \ --zip-file "fileb://artifact.zip" \ --role "${{ steps.role-select.outputs.lambda_role }}" \ - --memory-size 256 \ + --memory-size 512 \ --timeout 30 \ --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \