diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml new file mode 100644 index 0000000..d20dec1 --- /dev/null +++ b/.github/workflows/lint-and-test.yml @@ -0,0 +1,24 @@ +name: "lint and test" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + types: [ opened, synchronize, reopened ] + +permissions: + contents: read + pull-requests: write + +jobs: + lint-and-test: + uses: esclient/tools/.github/workflows/lint-and-test-python.yml@v1.0.1 + with: + python-version: "3.13.7" + source: "apigateway" + sonar-inclusions: "src/**,Dockerfile" + sonar-exclusions: "**/stubs/**" + sonar-coverage-exclusions: "src/apigateway/server.py,src/apigateway/settings.py" + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/lint-api-gateway.yml b/.github/workflows/lint-api-gateway.yml deleted file mode 100644 index 5b9513c..0000000 --- a/.github/workflows/lint-api-gateway.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: "lint api-gateway" - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - types: [ opened, synchronize, reopened ] - -permissions: - contents: read - pull-requests: write - -jobs: - lint-api-gateway: - uses: esclient/tools/.github/workflows/lint-api-gateway-python.yml@v1.0.0 - with: - python-version: '3.13.7' - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/pdm.lock b/pdm.lock index 619490a..0355f02 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "lint"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:e8bdf5753e1d3060fb5d2c8ec3a586824fa145f163a99da4ddc6aaeb9457f6e1" +content_hash = "sha256:546bbd64d27d434bbf436005487df139f71f865e451d3ae3e0335aec4a708f66" [[metadata.targets]] requires_python = "~=3.13" @@ -102,12 +102,157 @@ version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." groups = ["default", "dev"] -marker = "platform_system == \"Windows\"" +marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.11.0" +requires_python = ">=3.10" +summary = "Code coverage measurement for Python" +groups = ["dev"] +files = [ + {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, + {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, + {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, + {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, + {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, + {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, + {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, + {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, + {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, + {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, + {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, + {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, + {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, + {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, + {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, + {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, +] + +[[package]] +name = "coverage" +version = "7.11.0" +extras = ["toml"] +requires_python = ">=3.10" +summary = "Code coverage measurement for Python" +groups = ["dev"] +dependencies = [ + "coverage==7.11.0", + "tomli; python_full_version <= \"3.11.0a6\"", +] +files = [ + {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, + {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, + {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, + {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, + {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, + {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, + {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, + {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, + {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, + {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, + {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, + {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, + {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, + {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, + {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, + {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, +] + +[[package]] +name = "faker" +version = "37.11.0" +requires_python = ">=3.9" +summary = "Faker is a Python package that generates fake data for you." +groups = ["dev"] +dependencies = [ + "tzdata", +] +files = [ + {file = "faker-37.11.0-py3-none-any.whl", hash = "sha256:1508d2da94dfd1e0087b36f386126d84f8583b3de19ac18e392a2831a6676c57"}, + {file = "faker-37.11.0.tar.gz", hash = "sha256:22969803849ba0618be8eee2dd01d0d9e2cd3b75e6ff1a291fa9abcdb34da5e6"}, +] + [[package]] name = "flake8" version = "7.3.0" @@ -243,6 +388,17 @@ files = [ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +requires_python = ">=3.10" +summary = "brain-dead simple config-ini parsing" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "isort" version = "6.1.0" @@ -341,6 +497,17 @@ files = [ {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, ] +[[package]] +name = "pluggy" +version = "1.6.0" +requires_python = ">=3.9" +summary = "plugin and hook calling mechanisms for python" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + [[package]] name = "protobuf" version = "6.32.1" @@ -459,6 +626,80 @@ files = [ {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, ] +[[package]] +name = "pygments" +version = "2.19.2" +requires_python = ">=3.8" +summary = "Pygments is a syntax highlighting package written in Python." +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[[package]] +name = "pytest" +version = "8.4.2" +requires_python = ">=3.9" +summary = "pytest: simple powerful testing with Python" +groups = ["dev"] +dependencies = [ + "colorama>=0.4; sys_platform == \"win32\"", + "exceptiongroup>=1; python_version < \"3.11\"", + "iniconfig>=1", + "packaging>=20", + "pluggy<2,>=1.5", + "pygments>=2.7.2", + "tomli>=1; python_version < \"3.11\"", +] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +requires_python = ">=3.9" +summary = "Pytest support for asyncio" +groups = ["dev"] +dependencies = [ + "backports-asyncio-runner<2,>=1.1; python_version < \"3.11\"", + "pytest<9,>=8.2", + "typing-extensions>=4.12; python_version < \"3.13\"", +] +files = [ + {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, + {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, +] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +requires_python = ">=3.8" +summary = "Pytest plugin for measuring coverage." +groups = ["dev"] +dependencies = [ + "coverage[toml]>=5.2.1", + "pytest>=4.6", +] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[[package]] +name = "pytest-faker" +version = "2.0.0" +summary = "Faker integration with the pytest framework." +groups = ["dev"] +dependencies = [ + "Faker>=0.7.3", +] +files = [ + {file = "pytest-faker-2.0.0.tar.gz", hash = "sha256:6b37bb89d94f96552bfa51f8e8b89d32addded8ddb58a331488299ef0137d9b6"}, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -604,6 +845,17 @@ files = [ {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] +[[package]] +name = "tzdata" +version = "2025.2" +requires_python = ">=2" +summary = "Provider of IANA time zone data" +groups = ["dev"] +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + [[package]] name = "uvicorn" version = "0.37.0" diff --git a/pyproject.toml b/pyproject.toml index 0cb2e29..f724086 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.pdm] -distribution = false +distribution = true [tool.black] line-length = 120 @@ -100,6 +100,25 @@ plugins = ["pydantic.mypy"] module = ["apigateway.stubs.*"] ignore_errors = true +[[tool.mypy.overrides]] +module = ["tests.*"] +ignore_errors = true + +[tool.pytest.ini_options] +addopts = "--import-mode=importlib" +asyncio_mode = "strict" +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.coverage.run] +source = ["src/apigateway"] +relative_files = true + +[tool.coverage.report] +include = [ + "src/apigateway/*", +] + [project] name = "api-gateway" version = "0.0.1" @@ -124,11 +143,16 @@ lint = [ "flake8>=7.3.0", ] dev = [ + "black>=25.9.0", + "faker>=25.0.0", "flake8>=7.3.0", "flake8-pyproject>=1.2.3", - "black>=25.9.0", "isort>=6.0.1", "mypy>=1.18.2", + "pytest>=8.3.0", + "pytest-cov==5.0.0", + "pytest-asyncio>=0.25.0", + "pytest-faker>=2.0.0", "ruff>=0.13.1", "types-grpcio>=1.0.0.20250914", "types-protobuf>=6.32.1.20250918", diff --git a/src/apigateway/clients/base_client.py b/src/apigateway/clients/base_client.py index d410c10..6d94968 100644 --- a/src/apigateway/clients/base_client.py +++ b/src/apigateway/clients/base_client.py @@ -1,6 +1,7 @@ import logging from collections.abc import Awaitable, Callable -from typing import Self +from types import TracebackType +from typing import Self, TypeVar import grpc import grpc.aio @@ -10,46 +11,51 @@ logger = logging.getLogger(__name__) +ResponseT = TypeVar("ResponseT", bound=_message.Message) + class GrpcError(Exception): pass -class GrpcClient: +class GrpcClient[StubT]: def __init__(self, channel: grpc.aio.Channel): self._channel = channel - self._stub = None - self._initialize_stub() + self._stub: StubT = self._initialize_stub() - def _initialize_stub(self) -> None: - raise NotImplementedError() + def _initialize_stub(self) -> StubT: + raise NotImplementedError - @grpc_retry() # type: ignore + @grpc_retry() async def call( self, - rpc_method: Callable[["_message.Message"], Awaitable["_message.Message"]], - request: "_message.Message", + rpc_method: Callable[..., Awaitable[ResponseT]], + request: _message.Message, *, timeout: int = 30, - ) -> "_message.Message": + ) -> ResponseT: + method_name = getattr(rpc_method, "__name__", repr(rpc_method)) + try: - response: _message.Message = await rpc_method(request, timeout=timeout) # type: ignore[operator] + response = await rpc_method(request, timeout=timeout) return response - except grpc.RpcError as e: - logger.error(f"gRPC ошибка {rpc_method.__name__ if hasattr(rpc_method, '__name__') else rpc_method}: {e}") - raise GrpcError(f"gRPC Ошибка вызова: {e}") from e - except Exception as e: - logger.error( - f"Неизвестная ошибка при вызове {rpc_method.__name__ if hasattr(rpc_method, '__name__') else rpc_method}: {e}" - ) + except grpc.RpcError as exc: + logger.error("gRPC error while calling %s: %s", method_name, exc) + raise GrpcError(f"gRPC call failed: {exc}") from exc + except Exception as exc: + logger.error("Unknown error while calling %s: %s", method_name, exc) raise async def close(self) -> None: - if self._channel: - await self._channel.close() + await self._channel.close() async def __aenter__(self) -> Self: return self - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: await self.close() diff --git a/src/apigateway/clients/comment.py b/src/apigateway/clients/comment.py index da95e41..7ec125b 100644 --- a/src/apigateway/clients/comment.py +++ b/src/apigateway/clients/comment.py @@ -2,9 +2,9 @@ from apigateway.stubs.comment import comment_pb2, comment_pb2_grpc -class CommentServiceClient(GrpcClient): - def _initialize_stub(self) -> None: - self._stub = comment_pb2_grpc.CommentServiceStub(self._channel) # type: ignore +class CommentServiceClient(GrpcClient[comment_pb2_grpc.CommentServiceStub]): + def _initialize_stub(self) -> comment_pb2_grpc.CommentServiceStub: + return comment_pb2_grpc.CommentServiceStub(self._channel) # type: ignore[no-untyped-call] async def create_comment(self, mod_id: int, author_id: int, text: str) -> comment_pb2.CreateCommentResponse: request = comment_pb2.CreateCommentRequest(mod_id=mod_id, author_id=author_id, text=text) diff --git a/src/apigateway/clients/mod.py b/src/apigateway/clients/mod.py index 464ec26..f112f41 100644 --- a/src/apigateway/clients/mod.py +++ b/src/apigateway/clients/mod.py @@ -2,12 +2,16 @@ from apigateway.stubs.mod import mod_pb2, mod_pb2_grpc -class ModServiceClient(GrpcClient): - def _initialize_stub(self) -> None: - self._stub = mod_pb2_grpc.ModServiceStub(self._channel) # type: ignore +class ModServiceClient(GrpcClient[mod_pb2_grpc.ModServiceStub]): + def _initialize_stub(self) -> mod_pb2_grpc.ModServiceStub: + return mod_pb2_grpc.ModServiceStub(self._channel) # type: ignore[no-untyped-call] async def create_mod( - self, title: str, author_id: int, filename: str, description: str + self, + title: str, + author_id: int, + filename: str, + description: str, ) -> mod_pb2.CreateModResponse: request = mod_pb2.CreateModRequest(title=title, author_id=author_id, filename=filename, description=description) return await self.call(self._stub.CreateMod, request) diff --git a/src/apigateway/clients/rating.py b/src/apigateway/clients/rating.py index 423a627..f1ab99f 100644 --- a/src/apigateway/clients/rating.py +++ b/src/apigateway/clients/rating.py @@ -2,9 +2,9 @@ from apigateway.stubs.rating import rating_pb2, rating_pb2_grpc -class RatingServiceClient(GrpcClient): - def _initialize_stub(self) -> None: - self._stub = rating_pb2_grpc.RatingServiceStub(self._channel) # type: ignore +class RatingServiceClient(GrpcClient[rating_pb2_grpc.RatingServiceStub]): + def _initialize_stub(self) -> rating_pb2_grpc.RatingServiceStub: + return rating_pb2_grpc.RatingServiceStub(self._channel) # type: ignore[no-untyped-call] async def rate_mod(self, mod_id: int, author_id: int, rate: str) -> rating_pb2.RateModResponse: request = rating_pb2.RateModRequest(mod_id=mod_id, author_id=author_id, rate=rate) diff --git a/src/apigateway/esclient_graphql.py b/src/apigateway/esclient_graphql.py index 9368514..1047bef 100644 --- a/src/apigateway/esclient_graphql.py +++ b/src/apigateway/esclient_graphql.py @@ -5,7 +5,7 @@ class GQLContextViewer: def __init__(self) -> None: - self.clients: dict[str, GrpcClient] = {} + self.clients: dict[str, GrpcClient[Any]] = {} def get_current(self, request: Any) -> dict[str, Any]: return {"request": request, "clients": self.clients} diff --git a/src/apigateway/helpers/retry.py b/src/apigateway/helpers/retry.py index 4eb8140..4b6d832 100644 --- a/src/apigateway/helpers/retry.py +++ b/src/apigateway/helpers/retry.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Callable +from typing import TypeVar import grpc from tenacity import RetryCallState, retry, retry_if_exception, stop_after_attempt, wait_exponential @@ -12,6 +14,8 @@ RETRY_DELAY_MIN = 1 RETRY_DELAY_MAX = 10 +F = TypeVar("F", bound=Callable[..., object]) + def is_retryable_grpc_exception(exc: BaseException) -> bool: if isinstance(exc, grpc.RpcError): @@ -27,27 +31,31 @@ def log_retry_attempt(retry_state: RetryCallState) -> None: if retry_state.outcome is None or not retry_state.outcome.failed or not hasattr(retry_state.outcome, "exception"): return - try: - exception = retry_state.outcome.exception() - if exception is None: - return + exception = retry_state.outcome.exception() if retry_state.outcome else None + if exception is None: + return - sleep_time = getattr(retry_state.next_action, "sleep", 0) if retry_state.next_action else 0 + sleep_time = getattr(retry_state.next_action, "sleep", 0.0) if retry_state.next_action else 0.0 - logger.warning( - f"Повторная попытка {retry_state.attempt_number}/{RETRY_ATTEMPTS} " - f"для gRPC вызова. Ошибка: {exception} " - f"(следующая попытка через {sleep_time:.1f} сек)" - ) - except Exception as e: - logger.debug(f"Ошибка при логировании повторной попытки: {e}") + logger.warning( + "Retry attempt %s/%s for gRPC call failed. Exception: %s (next sleep %.1fs)", + retry_state.attempt_number, + RETRY_ATTEMPTS, + exception, + sleep_time, + ) -def grpc_retry(): # type: ignore - return retry( +def grpc_retry() -> Callable[[F], F]: + decorator = retry( stop=stop_after_attempt(RETRY_ATTEMPTS), wait=wait_exponential(multiplier=RETRY_DELAY_MULTIPLIER, min=RETRY_DELAY_MIN, max=RETRY_DELAY_MAX), retry=retry_if_exception(is_retryable_grpc_exception), reraise=True, before_sleep=log_retry_attempt, ) + + def _wrapper(func: F) -> F: + return decorator(func) + + return _wrapper diff --git a/tests/clients/test_base_client.py b/tests/clients/test_base_client.py new file mode 100644 index 0000000..1cdc63e --- /dev/null +++ b/tests/clients/test_base_client.py @@ -0,0 +1,100 @@ +import asyncio +from types import SimpleNamespace +from typing import Any, cast + +import grpc +import grpc.aio +import pytest +from faker import Faker +from google.protobuf import message as _message + +from apigateway.clients.base_client import GrpcClient, GrpcError + + +class _FakeChannel: + def __init__(self) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + +class _FakeRpcError(grpc.RpcError): + def __init__(self, status_code: grpc.StatusCode) -> None: + super().__init__() + self._status_code = status_code + + def code(self) -> grpc.StatusCode: # type: ignore[override] + return self._status_code + + +class _ConcreteClient(GrpcClient[SimpleNamespace]): + def __init__(self, channel: _FakeChannel) -> None: + self.stub_initialized = False + super().__init__(cast(grpc.aio.Channel, channel)) + + def _initialize_stub(self) -> SimpleNamespace: + self.stub_initialized = True + return SimpleNamespace() + + +@pytest.mark.asyncio +async def test_call_invokes_rpc_method_with_timeout(faker: Faker) -> None: + channel = _FakeChannel() + client = _ConcreteClient(channel) + captured: dict[str, Any] = {} + request_payload = cast(_message.Message, SimpleNamespace(payload=faker.pystr())) + expected_response = faker.pystr() + timeout_value = faker.random_int(min=1, max=60) + + async def rpc_method(request: _message.Message, timeout: int = 30) -> str: + await asyncio.sleep(0) + captured["request"] = request + captured["timeout"] = timeout + return expected_response + + result = await client.call(rpc_method, request=request_payload, timeout=timeout_value) + + assert result == expected_response + assert captured == {"request": request_payload, "timeout": timeout_value} + + +@pytest.mark.asyncio +async def test_call_wraps_grpc_errors() -> None: + channel = _FakeChannel() + client = _ConcreteClient(channel) + + async def failing_rpc(request: _message.Message, timeout: int = 30) -> str: + await asyncio.sleep(0) + raise _FakeRpcError(grpc.StatusCode.INVALID_ARGUMENT) + + with pytest.raises(GrpcError) as exc: + await client.call(failing_rpc, request=cast(_message.Message, SimpleNamespace())) + + assert "gRPC" in str(exc.value) + + +@pytest.mark.asyncio +async def test_call_propagates_other_exceptions(faker: Faker) -> None: + channel = _FakeChannel() + client = _ConcreteClient(channel) + error_message = faker.sentence(nb_words=3) + + async def failing_rpc(request: _message.Message, timeout: int = 30) -> str: + await asyncio.sleep(0) + raise RuntimeError(error_message) + + with pytest.raises(RuntimeError) as exc: + await client.call(failing_rpc, request=cast(_message.Message, SimpleNamespace())) + + assert str(exc.value) == error_message + + +@pytest.mark.asyncio +async def test_close_closes_channel() -> None: + channel = _FakeChannel() + client = _ConcreteClient(channel) + + await client.close() + + assert channel.closed is True diff --git a/tests/clients/test_client_factory.py b/tests/clients/test_client_factory.py new file mode 100644 index 0000000..5f9ef49 --- /dev/null +++ b/tests/clients/test_client_factory.py @@ -0,0 +1,68 @@ +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from faker import Faker + +from apigateway.clients.client_factory import GrpcClientFactory + + +class _StubChannel: + def __init__(self) -> None: + self.closed = False + + def unary_unary(self, *_: Any, **__: Any) -> Callable[..., Any]: + async_mock = AsyncMock() + async_mock.__name__ = "unary_unary_mock" + return async_mock + + async def close(self) -> None: + self.closed = True + + +@pytest.fixture +def stub_channel_factory(monkeypatch: pytest.MonkeyPatch): + created_channels: list[_StubChannel] = [] + + def _factory(_: str) -> _StubChannel: + channel = _StubChannel() + created_channels.append(channel) + return channel + + monkeypatch.setattr( + "apigateway.clients.client_factory.grpc.aio.insecure_channel", + _factory, + ) + return created_channels + + +def test_factory_reuses_channel_instances(stub_channel_factory: list[_StubChannel], faker: Faker) -> None: + comment_endpoint = f"{faker.hostname()}:{faker.port_number()}" + mod_endpoint = f"{faker.hostname()}:{faker.port_number()}" + rating_endpoint = f"{faker.hostname()}:{faker.port_number()}" + factory = GrpcClientFactory(comment_endpoint, mod_endpoint, rating_endpoint) + + comment_client_first = factory.get_comment_client() + comment_client_second = factory.get_comment_client() + + assert comment_client_first is not comment_client_second + assert comment_client_first._channel is comment_client_second._channel # type: ignore[attr-defined] + assert len(stub_channel_factory) == 1 + + +@pytest.mark.asyncio +async def test_close_all_closes_each_channel(stub_channel_factory: list[_StubChannel], faker: Faker) -> None: + comment_endpoint = f"{faker.hostname()}:{faker.port_number()}" + mod_endpoint = f"{faker.hostname()}:{faker.port_number()}" + rating_endpoint = f"{faker.hostname()}:{faker.port_number()}" + factory = GrpcClientFactory(comment_endpoint, mod_endpoint, rating_endpoint) + + factory.get_comment_client() + factory.get_mod_client() + factory.get_rating_client() + + await factory.close_all() + + assert len(stub_channel_factory) == 3 + assert all(channel.closed for channel in stub_channel_factory) diff --git a/tests/converters/test_mod_status_converter.py b/tests/converters/test_mod_status_converter.py new file mode 100644 index 0000000..9664335 --- /dev/null +++ b/tests/converters/test_mod_status_converter.py @@ -0,0 +1,28 @@ +import pytest + +from apigateway.converters.mod_status_converter import ( + DEFAULT_GRAPHQL, + DEFAULT_PROTO, + GRAPHQL_TO_PROTO, + PROTO_TO_GRAPHQL, + graphql_to_proto_mod_status, + proto_to_graphql_mod_status, +) + + +@pytest.mark.parametrize("graphql_value", list(GRAPHQL_TO_PROTO)) +def test_graphql_to_proto_known_values(graphql_value: str) -> None: + assert graphql_to_proto_mod_status(graphql_value) == GRAPHQL_TO_PROTO[graphql_value] + + +def test_graphql_to_proto_defaults_to_unspecified() -> None: + assert graphql_to_proto_mod_status("UNKNOWN") == DEFAULT_PROTO + + +@pytest.mark.parametrize("proto_value", list(PROTO_TO_GRAPHQL)) +def test_proto_to_graphql_known_values(proto_value: int) -> None: + assert proto_to_graphql_mod_status(proto_value) == PROTO_TO_GRAPHQL[proto_value] + + +def test_proto_to_graphql_defaults_to_unspecified() -> None: + assert proto_to_graphql_mod_status(-1) == DEFAULT_GRAPHQL diff --git a/tests/helpers/test_id_helper.py b/tests/helpers/test_id_helper.py new file mode 100644 index 0000000..e1da61b --- /dev/null +++ b/tests/helpers/test_id_helper.py @@ -0,0 +1,42 @@ +import pytest +from faker import Faker +from graphql import GraphQLError + +from apigateway.helpers.id_helper import validate_and_convert_id + + +def test_validate_and_convert_id_returns_int_for_valid_string(faker: Faker) -> None: + numeric_value = faker.random_int(min=1) + raw_id = str(numeric_value) + + assert validate_and_convert_id(raw_id) == numeric_value + + +def test_validate_and_convert_id_trims_whitespace(faker: Faker) -> None: + numeric_value = faker.random_int(min=1) + raw_id = f"\t{numeric_value}\n" + + assert validate_and_convert_id(raw_id) == numeric_value + + +def test_validate_and_convert_id_raises_for_empty_input(faker: Faker) -> None: + field_name = faker.word() + + with pytest.raises(GraphQLError) as exc: + validate_and_convert_id(" ", field_name=field_name) + + assert exc.value.extensions == {"code": "MISSING_ID", "field": field_name} + + +def test_validate_and_convert_id_raises_for_non_numeric(faker: Faker) -> None: + field_name = faker.word() + invalid_value = faker.lexify(text="??????") + + with pytest.raises(GraphQLError) as exc: + validate_and_convert_id(invalid_value, field_name=field_name) + + assert exc.value.extensions == { + "code": "INVALID_ID_FORMAT", + "field": field_name, + "value": invalid_value, + } diff --git a/tests/helpers/test_retry.py b/tests/helpers/test_retry.py new file mode 100644 index 0000000..2d48d0d --- /dev/null +++ b/tests/helpers/test_retry.py @@ -0,0 +1,64 @@ +import logging +from types import SimpleNamespace + +import grpc +import pytest +from faker import Faker + +from apigateway.helpers.retry import ( + NON_RETRYABLE, + RETRY_ATTEMPTS, + grpc_retry, + is_retryable_grpc_exception, + log_retry_attempt, +) + + +class _FakeRpcError(grpc.RpcError): + def __init__(self, status_code: grpc.StatusCode) -> None: + super().__init__() + self._status_code = status_code + + def code(self) -> grpc.StatusCode: # type: ignore[override] + return self._status_code + + +def test_is_retryable_grpc_exception_respects_non_retryable_codes() -> None: + for code in NON_RETRYABLE: + assert not is_retryable_grpc_exception(_FakeRpcError(code)) + + assert is_retryable_grpc_exception(_FakeRpcError(grpc.StatusCode.UNAVAILABLE)) + + +def test_log_retry_attempt_emits_warning_for_failed_retry(caplog: pytest.LogCaptureFixture, faker: Faker) -> None: + error_message = faker.sentence(nb_words=3) + exc = RuntimeError(error_message) + attempt_number = faker.random_int(min=1, max=RETRY_ATTEMPTS) + outcome = SimpleNamespace(failed=True, exception=lambda: exc) + retry_state = SimpleNamespace( + attempt_number=attempt_number, + outcome=outcome, + next_action=SimpleNamespace(sleep=faker.pyfloat(min_value=0.1, max_value=2.0)), + ) + + caplog.set_level(logging.WARNING) + log_retry_attempt(retry_state) + + assert caplog.records + assert f"{attempt_number}/{RETRY_ATTEMPTS}" in caplog.records[0].message + assert error_message in caplog.records[0].message + + +def test_grpc_retry_retries_retryable_errors() -> None: + attempts = 0 + + @grpc_retry() + def flaky_call() -> None: + nonlocal attempts + attempts += 1 + raise _FakeRpcError(grpc.StatusCode.ABORTED) + + with pytest.raises(_FakeRpcError): + flaky_call() + + assert attempts == RETRY_ATTEMPTS diff --git a/tests/resolvers/test_comment_mutation.py b/tests/resolvers/test_comment_mutation.py new file mode 100644 index 0000000..4014756 --- /dev/null +++ b/tests/resolvers/test_comment_mutation.py @@ -0,0 +1,84 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from graphql import GraphQLError + +from apigateway.clients.base_client import GrpcError +from apigateway.resolvers.mutation.comment import ( + resolve_create_comment, + resolve_delete_comment, + resolve_edit_comment, +) + + +def build_info(**clients: object) -> SimpleNamespace: + return SimpleNamespace(context={"clients": clients}) + + +@pytest.mark.asyncio +async def test_resolve_create_comment_returns_new_id(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + author_id = faker.random_int(min=1) + comment_id = faker.random_int(min=1) + comment_text = faker.sentence(nb_words=5) + client = SimpleNamespace(create_comment=AsyncMock(return_value=SimpleNamespace(comment_id=comment_id))) + + result = await resolve_create_comment( + parent=None, + info=build_info(comment_service=client), + input={"mod_id": str(mod_id), "author_id": str(author_id), "text": comment_text}, + ) + + assert result == str(comment_id) + client.create_comment.assert_awaited_once_with(mod_id, author_id, comment_text) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_resolve_edit_comment_returns_success_flag(faker: Faker) -> None: + comment_id = faker.random_int(min=1) + new_text = faker.sentence(nb_words=4) + client = SimpleNamespace(edit_comment=AsyncMock(return_value=SimpleNamespace(success=True))) + + result = await resolve_edit_comment( + parent=None, + info=build_info(comment_service=client), + input={"comment_id": str(comment_id), "text": new_text}, + ) + + assert result is True + client.edit_comment.assert_awaited_once_with(comment_id, new_text) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_resolve_delete_comment_returns_success_flag(faker: Faker) -> None: + comment_id = faker.random_int(min=1) + client = SimpleNamespace(delete_comment=AsyncMock(return_value=SimpleNamespace(success=True))) + + result = await resolve_delete_comment( + parent=None, + info=build_info(comment_service=client), + input={"comment_id": str(comment_id)}, + ) + + assert result is True + client.delete_comment.assert_awaited_once_with(comment_id) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_comment_mutations_wrap_grpc_errors(faker: Faker) -> None: + comment_id = faker.random_int(min=1) + error_message = faker.sentence(nb_words=6) + client = SimpleNamespace( + delete_comment=AsyncMock(side_effect=GrpcError(error_message)), + ) + + with pytest.raises(GraphQLError) as exc: + await resolve_delete_comment( + parent=None, + info=build_info(comment_service=client), + input={"comment_id": str(comment_id)}, + ) + + assert "gRPC" in str(exc.value) diff --git a/tests/resolvers/test_grpc_error_wrapper.py b/tests/resolvers/test_grpc_error_wrapper.py new file mode 100644 index 0000000..7c409f6 --- /dev/null +++ b/tests/resolvers/test_grpc_error_wrapper.py @@ -0,0 +1,51 @@ +import asyncio + +import pytest +from faker import Faker +from graphql import GraphQLError + +from apigateway.clients.base_client import GrpcError +from apigateway.resolvers.grpc_error_wrapper import handle_grpc_errors + + +@pytest.mark.asyncio +async def test_async_wrapper_transforms_grpc_error(faker: Faker) -> None: + error_message = faker.sentence(nb_words=3) + + @handle_grpc_errors + async def failing() -> None: + await asyncio.sleep(0) + raise GrpcError(error_message) + + with pytest.raises(GraphQLError) as exc: + await failing() + + assert "gRPC" in str(exc.value) + + +@pytest.mark.asyncio +async def test_async_wrapper_transforms_unknown_error(faker: Faker) -> None: + error_message = faker.sentence(nb_words=3) + + @handle_grpc_errors + async def failing() -> None: + await asyncio.sleep(0) + raise RuntimeError(error_message) + + with pytest.raises(GraphQLError) as exc: + await failing() + + assert error_message in str(exc.value) + + +def test_sync_wrapper_transforms_grpc_error(faker: Faker) -> None: + error_message = faker.sentence(nb_words=3) + + @handle_grpc_errors + def failing() -> None: + raise GrpcError(error_message) + + with pytest.raises(GraphQLError) as exc: + failing() + + assert "gRPC" in str(exc.value) diff --git a/tests/resolvers/test_mod_mutation.py b/tests/resolvers/test_mod_mutation.py new file mode 100644 index 0000000..9c10d05 --- /dev/null +++ b/tests/resolvers/test_mod_mutation.py @@ -0,0 +1,76 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from graphql import GraphQLError + +from apigateway.clients.base_client import GrpcError +from apigateway.resolvers.mutation.mod import ( + ModStatus, + resolve_create_mod, + resolve_set_status_mod, +) + + +def build_info(**clients: object) -> SimpleNamespace: + return SimpleNamespace(context={"clients": clients}) + + +@pytest.mark.asyncio +async def test_resolve_create_mod_returns_payload_dict(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + s3_key = f"{mod_id}/{faker.file_name()}" + upload_url = faker.url() + title = faker.sentence(nb_words=3) + author_id = faker.random_int(min=1) + filename = faker.file_name(extension="zip") + description = faker.sentence(nb_words=6) + client = SimpleNamespace( + create_mod=AsyncMock(return_value=SimpleNamespace(mod_id=mod_id, s3_key=s3_key, upload_url=upload_url)) + ) + + result = await resolve_create_mod( + parent=None, + info=build_info(mod_service=client), + input={ + "title": title, + "author_id": str(author_id), + "filename": filename, + "description": description, + }, + ) + + assert result == {"mod_id": mod_id, "s3_key": s3_key, "upload_url": upload_url} + client.create_mod.assert_awaited_once_with(title, author_id, filename, description) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_resolve_set_status_mod_returns_success(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + client = SimpleNamespace(set_status_mod=AsyncMock(return_value=SimpleNamespace(success=True))) + + result = await resolve_set_status_mod( + parent=None, + info=build_info(mod_service=client), + input={"mod_id": str(mod_id), "status": ModStatus.MOD_STATUS_BANNED}, + ) + + assert result is True + client.set_status_mod.assert_awaited_once_with(mod_id, ModStatus.MOD_STATUS_BANNED.value) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_mod_mutations_wrap_grpc_errors(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + error_message = faker.sentence(nb_words=5) + client = SimpleNamespace(set_status_mod=AsyncMock(side_effect=GrpcError(error_message))) + + with pytest.raises(GraphQLError) as exc: + await resolve_set_status_mod( + parent=None, + info=build_info(mod_service=client), + input={"mod_id": str(mod_id), "status": ModStatus.MOD_STATUS_HIDDEN}, + ) + + assert "gRPC" in str(exc.value) diff --git a/tests/resolvers/test_query_resolvers.py b/tests/resolvers/test_query_resolvers.py new file mode 100644 index 0000000..84374ed --- /dev/null +++ b/tests/resolvers/test_query_resolvers.py @@ -0,0 +1,138 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from graphql import GraphQLError + +from apigateway.clients.base_client import GrpcError +from apigateway.converters.mod_status_converter import PROTO_TO_GRAPHQL, proto_to_graphql_mod_status +from apigateway.resolvers.query.comment import resolve_get_comments +from apigateway.resolvers.query.mod import resolve_get_mod_download_link, resolve_get_mods + + +def build_info(**clients: object) -> SimpleNamespace: + return SimpleNamespace(context={"clients": clients}) + + +@pytest.mark.asyncio +async def test_resolve_get_mod_download_link_returns_url(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + link_url = faker.url() + mod_client = SimpleNamespace(get_mod_download_link=AsyncMock(return_value=SimpleNamespace(link_url=link_url))) + + result = await resolve_get_mod_download_link( + parent=None, + info=build_info(mod_service=mod_client), + input={"mod_id": str(mod_id)}, + ) + + assert result == link_url + mod_client.get_mod_download_link.assert_awaited_once_with(mod_id) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_resolve_get_mods_maps_proto_fields(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + author_id = faker.random_int(min=1) + title = faker.sentence(nb_words=3) + description = faker.sentence() + version = faker.random_int(min=1, max=10) + status = faker.random_element(list(PROTO_TO_GRAPHQL.keys())) + created_at_seconds = faker.random_number(digits=6) + mod_client = SimpleNamespace( + get_mods=AsyncMock( + return_value=SimpleNamespace( + mods=[ + SimpleNamespace( + id=mod_id, + author_id=author_id, + title=title, + description=description, + version=version, + status=status, + created_at=SimpleNamespace(seconds=created_at_seconds), + ) + ] + ) + ) + ) + + result = await resolve_get_mods( + parent=None, + info=build_info(mod_service=mod_client), + ) + + assert result == [ + { + "id": mod_id, + "author_id": author_id, + "title": title, + "description": description, + "version": version, + "status": proto_to_graphql_mod_status(status), + "created_at": created_at_seconds, + } + ] + + +@pytest.mark.asyncio +async def test_resolve_get_comments_returns_serialized_comments(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + first_comment = SimpleNamespace( + id=faker.random_int(min=1), + text=faker.sentence(), + author_id=faker.random_int(min=1), + created_at=faker.random_int(min=1_000, max=9_999_999), + edited_at=0, + ) + second_comment = SimpleNamespace( + id=faker.random_int(min=first_comment.id + 1, max=first_comment.id + 10_000), + text=faker.sentence(), + author_id=faker.random_int(min=1), + created_at=faker.random_int(min=1_000, max=9_999_999), + edited_at=faker.random_int(min=1, max=9_999_999), + ) + comment_client = SimpleNamespace( + get_comments=AsyncMock(return_value=SimpleNamespace(comments=[first_comment, second_comment])) + ) + + result = await resolve_get_comments( + parent=None, + info=build_info(comment_service=comment_client), + input={"mod_id": str(mod_id)}, + ) + + expected = [ + { + "id": first_comment.id, + "text": first_comment.text, + "author_id": first_comment.author_id, + "created_at": first_comment.created_at, + "edited_at": None, + }, + { + "id": second_comment.id, + "text": second_comment.text, + "author_id": second_comment.author_id, + "created_at": second_comment.created_at, + "edited_at": second_comment.edited_at, + }, + ] + + assert result == expected + comment_client.get_comments.assert_awaited_once_with(mod_id) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_query_resolvers_wrap_grpc_errors(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + error_message = faker.sentence(nb_words=5) + mod_client = SimpleNamespace(get_mod_download_link=AsyncMock(side_effect=GrpcError(error_message))) + + with pytest.raises(GraphQLError): + await resolve_get_mod_download_link( + parent=None, + info=build_info(mod_service=mod_client), + input={"mod_id": str(mod_id)}, + ) diff --git a/tests/resolvers/test_rating_mutation.py b/tests/resolvers/test_rating_mutation.py new file mode 100644 index 0000000..09ac5cf --- /dev/null +++ b/tests/resolvers/test_rating_mutation.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from graphql import GraphQLError + +from apigateway.clients.base_client import GrpcError +from apigateway.resolvers.mutation.rating import RateType, resolve_add_rate + + +def build_info(**clients: object) -> SimpleNamespace: + return SimpleNamespace(context={"clients": clients}) + + +@pytest.mark.asyncio +async def test_resolve_add_rate_returns_rate_id(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + author_id = faker.random_int(min=1) + rate_id = faker.random_int(min=1) + client = SimpleNamespace(rate_mod=AsyncMock(return_value=SimpleNamespace(rate_id=rate_id))) + + result = await resolve_add_rate( + parent=None, + info=build_info(rating_service=client), + input={"mod_id": str(mod_id), "author_id": str(author_id), "rate": RateType.RATE_5}, + ) + + assert result == str(rate_id) + client.rate_mod.assert_awaited_once_with(mod_id, author_id, RateType.RATE_5.value) # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_rating_mutation_wraps_grpc_errors(faker: Faker) -> None: + mod_id = faker.random_int(min=1) + author_id = faker.random_int(min=1) + error_message = faker.sentence(nb_words=4) + client = SimpleNamespace(rate_mod=AsyncMock(side_effect=GrpcError(error_message))) + + with pytest.raises(GraphQLError) as exc: + await resolve_add_rate( + parent=None, + info=build_info(rating_service=client), + input={"mod_id": str(mod_id), "author_id": str(author_id), "rate": RateType.RATE_1}, + ) + + assert "gRPC" in str(exc.value) diff --git a/tools/common.just b/tools/common.just index 83f286d..de39bc7 100644 --- a/tools/common.just +++ b/tools/common.just @@ -49,6 +49,8 @@ lint: mypy --strict . test: - pytest + pytest \ + --cov=src/apigateway \ + --cov-report=xml:coverage.xml prepare: format lint test