├── .gitignore ├── Pipfile ├── Pipfile.lock ├── app ├── __init__.py ├── containers.py ├── domain.py ├── dto.py ├── exceptions.py ├── main.py ├── repository.py └── routes.py ├── readme.md └── test ├── __init__.py ├── behave ├── environment.py ├── features │ └── do-thing.feature ├── mocks.py └── steps │ ├── given.py │ ├── then.py │ └── when.py └── unit ├── __init__.py ├── test_domain.py └── test_repository.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | fastapi = "*" 8 | httpx = "*" 9 | dependency-injector = "*" 10 | uvicorn = "*" 11 | 12 | [dev-packages] 13 | ruff = "*" 14 | black = "*" 15 | mypy = "*" 16 | behave = "*" 17 | pytest = "*" 18 | 19 | [requires] 20 | python_version = "3.8" 21 | 22 | [scripts] 23 | lint = """sh -c " 24 | black . \ 25 | && ruff check . --fix \ 26 | && mypy . --ignore-missing-imports 27 | " 28 | """ 29 | test-static = """sh -c " 30 | black . --check \ 31 | && ruff check . \ 32 | && mypy . --ignore-missing-imports 33 | " 34 | """ 35 | test-unit = "pytest test/unit" 36 | test-behave = "behave test/behave/features" 37 | serve = "uvicorn app.main:app --host 0.0.0.0 --port 80" 38 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "52bc5537c5ebaf26fef3ffb38aeee595208fdb8dd13cbe7bddef175ca3ef9d4d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "annotated-types": { 20 | "hashes": [ 21 | "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802", 22 | "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd" 23 | ], 24 | "markers": "python_version >= '3.7'", 25 | "version": "==0.5.0" 26 | }, 27 | "anyio": { 28 | "hashes": [ 29 | "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", 30 | "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5" 31 | ], 32 | "markers": "python_version >= '3.7'", 33 | "version": "==3.7.1" 34 | }, 35 | "certifi": { 36 | "hashes": [ 37 | "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", 38 | "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" 39 | ], 40 | "markers": "python_version >= '3.6'", 41 | "version": "==2023.7.22" 42 | }, 43 | "click": { 44 | "hashes": [ 45 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 46 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 47 | ], 48 | "markers": "python_version >= '3.7'", 49 | "version": "==8.1.7" 50 | }, 51 | "dependency-injector": { 52 | "hashes": [ 53 | "sha256:02620454ee8101f77a317f3229935ce687480883d72a40858ff4b0c87c935cce", 54 | "sha256:059fbb48333148143e8667a5323d162628dfe27c386bd0ed3deeecfc390338bf", 55 | "sha256:05e15ea0f2b14c1127e8b0d1597fef13f98845679f63bf670ba12dbfc12a16ef", 56 | "sha256:12e91ac0333e7e589421943ff6c6bf9cf0d9ac9703301cec37ccff3723406332", 57 | "sha256:1662e2ef60ac6e681b9e11b5d8b7c17a0f733688916cf695f9540f8f50a61b1e", 58 | "sha256:168334cba3f1cbf55299ef38f0f2e31879115cc767b780c859f7814a52d80abb", 59 | "sha256:16de2797dcfcc2263b8672bf0751166f7c7b369ca2ff9246ceb67b65f8e1d802", 60 | "sha256:1baee908f21190bdc46a65ce4c417a5175e9397ca62354928694fce218f84487", 61 | "sha256:22b11dbf696e184f0b3d5ac4e5418aeac3c379ba4ea758c04a83869b7e5d1cbf", 62 | "sha256:300838e9d4f3fbf539892a5a4072851728e23b37a1f467afcf393edd994d88f0", 63 | "sha256:3055b3fc47a0d6e5f27defb4166c0d37543a4967c279549b154afaf506ce6efc", 64 | "sha256:33a724e0a737baadb4378f5dc1b079867cc3a88552fcca719b3dba84716828b2", 65 | "sha256:3535d06416251715b45f8412482b58ec1c6196a4a3baa207f947f0b03a7c4b44", 66 | "sha256:3588bd887b051d16b8bcabaae1127eb14059a0719a8fe34c8a75ba59321b352c", 67 | "sha256:3744c327d18408e74781bd6d8b7738745ee80ef89f2c8daecf9ebd098cb84972", 68 | "sha256:37d5954026e3831663518d78bdf4be9c2dbfea691edcb73c813aa3093aa4363a", 69 | "sha256:40936d9384363331910abd59dd244158ec3572abf9d37322f15095315ac99893", 70 | "sha256:409441122f40e1b4b8582845fdd76deb9dc5c9d6eb74a057b85736ef9e9c671f", 71 | "sha256:48b6886a87b4ceb9b9f78550f77b2a5c7d2ce33bc83efd886556ad468cc9c85a", 72 | "sha256:4a31d9d60be4b585585081109480cfb2ef564d3b851cb32a139bf8408411a93a", 73 | "sha256:4a44ca3ce5867513a70b31855b218be3d251f5068ce1c480cc3a4ad24ffd3280", 74 | "sha256:51217cb384b468d7cc355544cec20774859f00812f9a1a71ed7fa701c957b2a7", 75 | "sha256:5168dc59808317dc4cdd235aa5d7d556d33e5600156acaf224cead236b48a3e8", 76 | "sha256:54032d62610cf2f4421c9d92cef52957215aaa0bca403cda580c58eb3f726eda", 77 | "sha256:56d37b9d2f50a18f059d9abdbea7669a7518bd42b81603c21a27910a2b3f1657", 78 | "sha256:586a0821720b15932addbefb00f7370fbcd5831d6ebbd6494d774b44ff96d23a", 79 | "sha256:5fa3ed8f0700e47a0e7363f949b4525ffa8277aa1c5b10ca5b41fce4dea61bb9", 80 | "sha256:63bfba21f8bff654a80e9b9d06dd6c43a442990b73bf89cd471314c11c541ec2", 81 | "sha256:67b369592c57549ccdcad0d5fef1ddb9d39af7fed8083d76e789ab0111fc6389", 82 | "sha256:6b29abac56ce347d2eb58a560723e1663ee2125cf5cc38866ed92b84319927ec", 83 | "sha256:6b98945edae88e777091bf0848f869fb94bd76dfa4066d7c870a5caa933391d0", 84 | "sha256:6ee9810841c6e0599356cb884d16453bfca6ab739d0e4f0248724ed8f9ee0d79", 85 | "sha256:740a8e8106a04d3f44b52b25b80570fdac96a8a3934423de7c9202c5623e7936", 86 | "sha256:75280dfa23f7c88e1bf56c3920d58a43516816de6f6ab2a6650bb8a0f27d5c2c", 87 | "sha256:75e7a733b372db3144a34020c4233f6b94db2c6342d6d16bc5245b1b941ee2bd", 88 | "sha256:76b94c8310929e54136f3cb3de3adc86d1a657b3984299f40bf1cd2ba0bae548", 89 | "sha256:786f7aac592e191c9caafc47732161d807bad65c62f260cd84cd73c7e2d67d6d", 90 | "sha256:7a92680bea1c260e5c0d2d6cd60b0c913cba76a456a147db5ac047ecfcfcc758", 91 | "sha256:7dcba8665cafec825b7095d5dd80afb5cf14404450eca3fe8b66e1edbf4dbc10", 92 | "sha256:7fa4970f12a3fc95d8796938b11c41276ad1ff4c447b0e589212eab3fc527a90", 93 | "sha256:87be84084a1b922c4ba15e2e5aa900ee24b78a5467997cb7aec0a1d6cdb4a00b", 94 | "sha256:89c67edffe7007cf33cee79ecbca38f48efcc2add5c280717af434db6c789377", 95 | "sha256:8b51efeaebacaf79ef68edfc65e9687699ccffb3538c4a3ab30d0d77e2db7189", 96 | "sha256:8b8cf1c6c56f5c18bdbd9f5e93b52ca29cb4d99606d4056e91f0c761eef496dc", 97 | "sha256:8d670a844268dcd758195e58e9a5b39fc74bb8648aba99a13135a4a10ec9cfac", 98 | "sha256:8f0090ff14038f17a026ca408a3a0b0e7affb6aa7498b2b59d670f40ac970fbe", 99 | "sha256:939dfc657104bc3e66b67afd3fb2ebb0850c9a1e73d0d26066f2bbdd8735ff9c", 100 | "sha256:953bfac819d32dc72b963767589e0ed372e5e9e78b03fb6b89419d0500d34bbe", 101 | "sha256:99ed73b1521bf249e2823a08a730c9f9413a58f4b4290da022e0ad4fb333ba3d", 102 | "sha256:9e3b9d41e0eff4c8e16fea1e33de66ff0030fe51137ca530f3c52ce110447914", 103 | "sha256:a2381a251b04244125148298212550750e6e1403e9b2850cc62e0e829d050ad3", 104 | "sha256:a2dee5d4abdd21f1a30a51d46645c095be9dcc404c7c6e9f81d0a01415a49e64", 105 | "sha256:a4f113e5d4c3070973ad76e5bda7317e500abae6083d78689f0b6e37cf403abf", 106 | "sha256:a8686fa330c83251c75c8238697686f7a0e0f6d40658538089165dc72df9bcff", 107 | "sha256:ac79f3c05747f9724bd56c06985e78331fc6c85eb50f3e3f1a35e0c60f9977e9", 108 | "sha256:b0c9c966ff66c77364a2d43d08de9968aff7e3903938fe912ba49796b2133344", 109 | "sha256:b2440b32474d4e747209528ca3ae48f42563b2fbe3d74dbfe949c11dfbfef7c4", 110 | "sha256:b365a8548e9a49049fa6acb24d3cd939f619eeb8e300ca3e156e44402dcc07ec", 111 | "sha256:b37f36ecb0c1227f697e1d4a029644e3eda8dd0f0716aa63ad04d96dbb15bbbb", 112 | "sha256:b3890a12423ae3a9eade035093beba487f8d092ee6c6cb8706f4e7080a56e819", 113 | "sha256:b8b61a15bc46a3aa7b29bd8a7384b650aa3a7ef943491e93c49a0540a0b3dda4", 114 | "sha256:bc852da612c7e347f2fcf921df2eca2718697a49f648a28a63db3ab504fd9510", 115 | "sha256:c71d30b6708438050675f338edb9a25bea6c258478dbe5ec8405286756a2d347", 116 | "sha256:d03f5fa0fa98a18bd0dfce846db80e2798607f0b861f1f99c97f441f7669d7a2", 117 | "sha256:d09c08c944a25dabfb454238c1a889acd85102b93ae497de523bf9ab7947b28a", 118 | "sha256:d283aee588a72072439e6721cb64aa6cba5bc18c576ef0ab28285a6ec7a9d655", 119 | "sha256:d557e40673de984f78dab13ebd68d27fbb2f16d7c4e3b663ea2fa2f9fae6765b", 120 | "sha256:e3229d83e99e255451605d5276604386e06ad948e3d60f31ddd796781c77f76f", 121 | "sha256:f2842e15bae664a9f69932e922b02afa055c91efec959cb1896f6c499bf68180", 122 | "sha256:f89a507e389b7e4d4892dd9a6f5f4da25849e24f73275478634ac594d621ab3f" 123 | ], 124 | "index": "pypi", 125 | "version": "==4.41.0" 126 | }, 127 | "exceptiongroup": { 128 | "hashes": [ 129 | "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", 130 | "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" 131 | ], 132 | "markers": "python_version < '3.11'", 133 | "version": "==1.1.3" 134 | }, 135 | "fastapi": { 136 | "hashes": [ 137 | "sha256:345844e6a82062f06a096684196aaf96c1198b25c06b72c1311b882aa2d8a35d", 138 | "sha256:5e5f17e826dbd9e9b5a5145976c5cd90bcaa61f2bf9a69aca423f2bcebe44d83" 139 | ], 140 | "index": "pypi", 141 | "markers": "python_version >= '3.7'", 142 | "version": "==0.103.1" 143 | }, 144 | "h11": { 145 | "hashes": [ 146 | "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", 147 | "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" 148 | ], 149 | "markers": "python_version >= '3.7'", 150 | "version": "==0.14.0" 151 | }, 152 | "httpcore": { 153 | "hashes": [ 154 | "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9", 155 | "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced" 156 | ], 157 | "markers": "python_version >= '3.8'", 158 | "version": "==0.18.0" 159 | }, 160 | "httpx": { 161 | "hashes": [ 162 | "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100", 163 | "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875" 164 | ], 165 | "index": "pypi", 166 | "markers": "python_version >= '3.8'", 167 | "version": "==0.25.0" 168 | }, 169 | "idna": { 170 | "hashes": [ 171 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 172 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 173 | ], 174 | "markers": "python_version >= '3.5'", 175 | "version": "==3.4" 176 | }, 177 | "pydantic": { 178 | "hashes": [ 179 | "sha256:1607cc106602284cd4a00882986570472f193fde9cb1259bceeaedb26aa79a6d", 180 | "sha256:45b5e446c6dfaad9444819a293b921a40e1db1aa61ea08aede0522529ce90e81" 181 | ], 182 | "markers": "python_version >= '3.7'", 183 | "version": "==2.3.0" 184 | }, 185 | "pydantic-core": { 186 | "hashes": [ 187 | "sha256:002d0ea50e17ed982c2d65b480bd975fc41086a5a2f9c924ef8fc54419d1dea3", 188 | "sha256:02e1c385095efbd997311d85c6021d32369675c09bcbfff3b69d84e59dc103f6", 189 | "sha256:046af9cfb5384f3684eeb3f58a48698ddab8dd870b4b3f67f825353a14441418", 190 | "sha256:04fe5c0a43dec39aedba0ec9579001061d4653a9b53a1366b113aca4a3c05ca7", 191 | "sha256:07a1aec07333bf5adebd8264047d3dc518563d92aca6f2f5b36f505132399efc", 192 | "sha256:1480fa4682e8202b560dcdc9eeec1005f62a15742b813c88cdc01d44e85308e5", 193 | "sha256:1508f37ba9e3ddc0189e6ff4e2228bd2d3c3a4641cbe8c07177162f76ed696c7", 194 | "sha256:171a4718860790f66d6c2eda1d95dd1edf64f864d2e9f9115840840cf5b5713f", 195 | "sha256:19e20f8baedd7d987bd3f8005c146e6bcbda7cdeefc36fad50c66adb2dd2da48", 196 | "sha256:1a0ddaa723c48af27d19f27f1c73bdc615c73686d763388c8683fe34ae777bad", 197 | "sha256:1aa712ba150d5105814e53cb141412217146fedc22621e9acff9236d77d2a5ef", 198 | "sha256:1ac1750df1b4339b543531ce793b8fd5c16660a95d13aecaab26b44ce11775e9", 199 | "sha256:1c721bfc575d57305dd922e6a40a8fe3f762905851d694245807a351ad255c58", 200 | "sha256:1ce8c84051fa292a5dc54018a40e2a1926fd17980a9422c973e3ebea017aa8da", 201 | "sha256:1fa1f6312fb84e8c281f32b39affe81984ccd484da6e9d65b3d18c202c666149", 202 | "sha256:22134a4453bd59b7d1e895c455fe277af9d9d9fbbcb9dc3f4a97b8693e7e2c9b", 203 | "sha256:23470a23614c701b37252618e7851e595060a96a23016f9a084f3f92f5ed5881", 204 | "sha256:240a015102a0c0cc8114f1cba6444499a8a4d0333e178bc504a5c2196defd456", 205 | "sha256:252851b38bad3bfda47b104ffd077d4f9604a10cb06fe09d020016a25107bf98", 206 | "sha256:2a20c533cb80466c1d42a43a4521669ccad7cf2967830ac62c2c2f9cece63e7e", 207 | "sha256:2dd50d6a1aef0426a1d0199190c6c43ec89812b1f409e7fe44cb0fbf6dfa733c", 208 | "sha256:340e96c08de1069f3d022a85c2a8c63529fd88709468373b418f4cf2c949fb0e", 209 | "sha256:3796a6152c545339d3b1652183e786df648ecdf7c4f9347e1d30e6750907f5bb", 210 | "sha256:37a822f630712817b6ecc09ccc378192ef5ff12e2c9bae97eb5968a6cdf3b862", 211 | "sha256:3a750a83b2728299ca12e003d73d1264ad0440f60f4fc9cee54acc489249b728", 212 | "sha256:3c8945a105f1589ce8a693753b908815e0748f6279959a4530f6742e1994dcb6", 213 | "sha256:3ccc13afee44b9006a73d2046068d4df96dc5b333bf3509d9a06d1b42db6d8bf", 214 | "sha256:3f90e5e3afb11268628c89f378f7a1ea3f2fe502a28af4192e30a6cdea1e7d5e", 215 | "sha256:4292ca56751aebbe63a84bbfc3b5717abb09b14d4b4442cc43fd7c49a1529efd", 216 | "sha256:430ddd965ffd068dd70ef4e4d74f2c489c3a313adc28e829dd7262cc0d2dd1e8", 217 | "sha256:439a0de139556745ae53f9cc9668c6c2053444af940d3ef3ecad95b079bc9987", 218 | "sha256:44b4f937b992394a2e81a5c5ce716f3dcc1237281e81b80c748b2da6dd5cf29a", 219 | "sha256:48c1ed8b02ffea4d5c9c220eda27af02b8149fe58526359b3c07eb391cb353a2", 220 | "sha256:4ef724a059396751aef71e847178d66ad7fc3fc969a1a40c29f5aac1aa5f8784", 221 | "sha256:50555ba3cb58f9861b7a48c493636b996a617db1a72c18da4d7f16d7b1b9952b", 222 | "sha256:522a9c4a4d1924facce7270c84b5134c5cabcb01513213662a2e89cf28c1d309", 223 | "sha256:5493a7027bfc6b108e17c3383959485087d5942e87eb62bbac69829eae9bc1f7", 224 | "sha256:56ea80269077003eaa59723bac1d8bacd2cd15ae30456f2890811efc1e3d4413", 225 | "sha256:5a2a3c9ef904dcdadb550eedf3291ec3f229431b0084666e2c2aa8ff99a103a2", 226 | "sha256:5cfde4fab34dd1e3a3f7f3db38182ab6c95e4ea91cf322242ee0be5c2f7e3d2f", 227 | "sha256:5e4a2cf8c4543f37f5dc881de6c190de08096c53986381daebb56a355be5dfe6", 228 | "sha256:5e9c068f36b9f396399d43bfb6defd4cc99c36215f6ff33ac8b9c14ba15bdf6b", 229 | "sha256:5ed7ceca6aba5331ece96c0e328cd52f0dcf942b8895a1ed2642de50800b79d3", 230 | "sha256:5fa159b902d22b283b680ef52b532b29554ea2a7fc39bf354064751369e9dbd7", 231 | "sha256:615a31b1629e12445c0e9fc8339b41aaa6cc60bd53bf802d5fe3d2c0cda2ae8d", 232 | "sha256:621afe25cc2b3c4ba05fff53525156d5100eb35c6e5a7cf31d66cc9e1963e378", 233 | "sha256:6656a0ae383d8cd7cc94e91de4e526407b3726049ce8d7939049cbfa426518c8", 234 | "sha256:672174480a85386dd2e681cadd7d951471ad0bb028ed744c895f11f9d51b9ebe", 235 | "sha256:692b4ff5c4e828a38716cfa92667661a39886e71136c97b7dac26edef18767f7", 236 | "sha256:6bcc1ad776fffe25ea5c187a028991c031a00ff92d012ca1cc4714087e575973", 237 | "sha256:6bf7d610ac8f0065a286002a23bcce241ea8248c71988bda538edcc90e0c39ad", 238 | "sha256:75c0ebbebae71ed1e385f7dfd9b74c1cff09fed24a6df43d326dd7f12339ec34", 239 | "sha256:788be9844a6e5c4612b74512a76b2153f1877cd845410d756841f6c3420230eb", 240 | "sha256:7dc2ce039c7290b4ef64334ec7e6ca6494de6eecc81e21cb4f73b9b39991408c", 241 | "sha256:813aab5bfb19c98ae370952b6f7190f1e28e565909bfc219a0909db168783465", 242 | "sha256:8421cf496e746cf8d6b677502ed9a0d1e4e956586cd8b221e1312e0841c002d5", 243 | "sha256:84e87c16f582f5c753b7f39a71bd6647255512191be2d2dbf49458c4ef024588", 244 | "sha256:84f8bb34fe76c68c9d96b77c60cef093f5e660ef8e43a6cbfcd991017d375950", 245 | "sha256:85cc4d105747d2aa3c5cf3e37dac50141bff779545ba59a095f4a96b0a460e70", 246 | "sha256:883daa467865e5766931e07eb20f3e8152324f0adf52658f4d302242c12e2c32", 247 | "sha256:8b2b1bfed698fa410ab81982f681f5b1996d3d994ae8073286515ac4d165c2e7", 248 | "sha256:8ecbac050856eb6c3046dea655b39216597e373aa8e50e134c0e202f9c47efec", 249 | "sha256:930bfe73e665ebce3f0da2c6d64455098aaa67e1a00323c74dc752627879fc67", 250 | "sha256:9616567800bdc83ce136e5847d41008a1d602213d024207b0ff6cab6753fe645", 251 | "sha256:9680dd23055dd874173a3a63a44e7f5a13885a4cfd7e84814be71be24fba83db", 252 | "sha256:99faba727727b2e59129c59542284efebbddade4f0ae6a29c8b8d3e1f437beb7", 253 | "sha256:9a718d56c4d55efcfc63f680f207c9f19c8376e5a8a67773535e6f7e80e93170", 254 | "sha256:9b33bf9658cb29ac1a517c11e865112316d09687d767d7a0e4a63d5c640d1b17", 255 | "sha256:9e8b374ef41ad5c461efb7a140ce4730661aadf85958b5c6a3e9cf4e040ff4bb", 256 | "sha256:9e9b65a55bbabda7fccd3500192a79f6e474d8d36e78d1685496aad5f9dbd92c", 257 | "sha256:a0b7486d85293f7f0bbc39b34e1d8aa26210b450bbd3d245ec3d732864009819", 258 | "sha256:a53e3195f134bde03620d87a7e2b2f2046e0e5a8195e66d0f244d6d5b2f6d31b", 259 | "sha256:a87c54e72aa2ef30189dc74427421e074ab4561cf2bf314589f6af5b37f45e6d", 260 | "sha256:a892b5b1871b301ce20d40b037ffbe33d1407a39639c2b05356acfef5536d26a", 261 | "sha256:a8acc9dedd304da161eb071cc7ff1326aa5b66aadec9622b2574ad3ffe225525", 262 | "sha256:aaafc776e5edc72b3cad1ccedb5fd869cc5c9a591f1213aa9eba31a781be9ac1", 263 | "sha256:acafc4368b289a9f291e204d2c4c75908557d4f36bd3ae937914d4529bf62a76", 264 | "sha256:b0a5d7edb76c1c57b95df719af703e796fc8e796447a1da939f97bfa8a918d60", 265 | "sha256:b25afe9d5c4f60dcbbe2b277a79be114e2e65a16598db8abee2a2dcde24f162b", 266 | "sha256:b44c42edc07a50a081672e25dfe6022554b47f91e793066a7b601ca290f71e42", 267 | "sha256:b594b64e8568cf09ee5c9501ede37066b9fc41d83d58f55b9952e32141256acd", 268 | "sha256:b962700962f6e7a6bd77e5f37320cabac24b4c0f76afeac05e9f93cf0c620014", 269 | "sha256:bb128c30cf1df0ab78166ded1ecf876620fb9aac84d2413e8ea1594b588c735d", 270 | "sha256:bf9d42a71a4d7a7c1f14f629e5c30eac451a6fc81827d2beefd57d014c006c4a", 271 | "sha256:c6595b0d8c8711e8e1dc389d52648b923b809f68ac1c6f0baa525c6440aa0daa", 272 | "sha256:c8c6660089a25d45333cb9db56bb9e347241a6d7509838dbbd1931d0e19dbc7f", 273 | "sha256:c9d469204abcca28926cbc28ce98f28e50e488767b084fb3fbdf21af11d3de26", 274 | "sha256:d38bbcef58220f9c81e42c255ef0bf99735d8f11edef69ab0b499da77105158a", 275 | "sha256:d4eb77df2964b64ba190eee00b2312a1fd7a862af8918ec70fc2d6308f76ac64", 276 | "sha256:d63b7545d489422d417a0cae6f9898618669608750fc5e62156957e609e728a5", 277 | "sha256:d7050899026e708fb185e174c63ebc2c4ee7a0c17b0a96ebc50e1f76a231c057", 278 | "sha256:d79f1f2f7ebdb9b741296b69049ff44aedd95976bfee38eb4848820628a99b50", 279 | "sha256:d85463560c67fc65cd86153a4975d0b720b6d7725cf7ee0b2d291288433fc21b", 280 | "sha256:d9140ded382a5b04a1c030b593ed9bf3088243a0a8b7fa9f071a5736498c5483", 281 | "sha256:d9b4916b21931b08096efed090327f8fe78e09ae8f5ad44e07f5c72a7eedb51b", 282 | "sha256:df14f6332834444b4a37685810216cc8fe1fe91f447332cd56294c984ecbff1c", 283 | "sha256:e49ce7dc9f925e1fb010fc3d555250139df61fa6e5a0a95ce356329602c11ea9", 284 | "sha256:e61eae9b31799c32c5f9b7be906be3380e699e74b2db26c227c50a5fc7988698", 285 | "sha256:ea053cefa008fda40f92aab937fb9f183cf8752e41dbc7bc68917884454c6362", 286 | "sha256:f06e21ad0b504658a3a9edd3d8530e8cea5723f6ea5d280e8db8efc625b47e49", 287 | "sha256:f14546403c2a1d11a130b537dda28f07eb6c1805a43dae4617448074fd49c282", 288 | "sha256:f1a5d8f18877474c80b7711d870db0eeef9442691fcdb00adabfc97e183ee0b0", 289 | "sha256:f2969e8f72c6236c51f91fbb79c33821d12a811e2a94b7aa59c65f8dbdfad34a", 290 | "sha256:f468d520f47807d1eb5d27648393519655eadc578d5dd862d06873cce04c4d1b", 291 | "sha256:f70dc00a91311a1aea124e5f64569ea44c011b58433981313202c46bccbec0e1", 292 | "sha256:f93255b3e4d64785554e544c1c76cd32f4a354fa79e2eeca5d16ac2e7fdd57aa" 293 | ], 294 | "markers": "python_version >= '3.7'", 295 | "version": "==2.6.3" 296 | }, 297 | "six": { 298 | "hashes": [ 299 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 300 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 301 | ], 302 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 303 | "version": "==1.16.0" 304 | }, 305 | "sniffio": { 306 | "hashes": [ 307 | "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", 308 | "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" 309 | ], 310 | "markers": "python_version >= '3.7'", 311 | "version": "==1.3.0" 312 | }, 313 | "starlette": { 314 | "hashes": [ 315 | "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75", 316 | "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91" 317 | ], 318 | "markers": "python_version >= '3.7'", 319 | "version": "==0.27.0" 320 | }, 321 | "typing-extensions": { 322 | "hashes": [ 323 | "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", 324 | "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" 325 | ], 326 | "markers": "python_version >= '3.8'", 327 | "version": "==4.8.0" 328 | }, 329 | "uvicorn": { 330 | "hashes": [ 331 | "sha256:1f9be6558f01239d4fdf22ef8126c39cb1ad0addf76c40e760549d2c2f43ab53", 332 | "sha256:4d3cc12d7727ba72b64d12d3cc7743124074c0a69f7b201512fc50c3e3f1569a" 333 | ], 334 | "index": "pypi", 335 | "markers": "python_version >= '3.8'", 336 | "version": "==0.23.2" 337 | } 338 | }, 339 | "develop": { 340 | "behave": { 341 | "hashes": [ 342 | "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", 343 | "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c" 344 | ], 345 | "index": "pypi", 346 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 347 | "version": "==1.2.6" 348 | }, 349 | "black": { 350 | "hashes": [ 351 | "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f", 352 | "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7", 353 | "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100", 354 | "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573", 355 | "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d", 356 | "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f", 357 | "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9", 358 | "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300", 359 | "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948", 360 | "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325", 361 | "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9", 362 | "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71", 363 | "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186", 364 | "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f", 365 | "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe", 366 | "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855", 367 | "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80", 368 | "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393", 369 | "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c", 370 | "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204", 371 | "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377", 372 | "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301" 373 | ], 374 | "index": "pypi", 375 | "markers": "python_version >= '3.8'", 376 | "version": "==23.9.1" 377 | }, 378 | "click": { 379 | "hashes": [ 380 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 381 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 382 | ], 383 | "markers": "python_version >= '3.7'", 384 | "version": "==8.1.7" 385 | }, 386 | "exceptiongroup": { 387 | "hashes": [ 388 | "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", 389 | "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" 390 | ], 391 | "markers": "python_version < '3.11'", 392 | "version": "==1.1.3" 393 | }, 394 | "iniconfig": { 395 | "hashes": [ 396 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 397 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 398 | ], 399 | "markers": "python_version >= '3.7'", 400 | "version": "==2.0.0" 401 | }, 402 | "mypy": { 403 | "hashes": [ 404 | "sha256:159aa9acb16086b79bbb0016145034a1a05360626046a929f84579ce1666b315", 405 | "sha256:258b22210a4a258ccd077426c7a181d789d1121aca6db73a83f79372f5569ae0", 406 | "sha256:26f71b535dfc158a71264e6dc805a9f8d2e60b67215ca0bfa26e2e1aa4d4d373", 407 | "sha256:26fb32e4d4afa205b24bf645eddfbb36a1e17e995c5c99d6d00edb24b693406a", 408 | "sha256:2fc3a600f749b1008cc75e02b6fb3d4db8dbcca2d733030fe7a3b3502902f161", 409 | "sha256:32cb59609b0534f0bd67faebb6e022fe534bdb0e2ecab4290d683d248be1b275", 410 | "sha256:330857f9507c24de5c5724235e66858f8364a0693894342485e543f5b07c8693", 411 | "sha256:361da43c4f5a96173220eb53340ace68cda81845cd88218f8862dfb0adc8cddb", 412 | "sha256:4a465ea2ca12804d5b34bb056be3a29dc47aea5973b892d0417c6a10a40b2d65", 413 | "sha256:51cb1323064b1099e177098cb939eab2da42fea5d818d40113957ec954fc85f4", 414 | "sha256:57b10c56016adce71fba6bc6e9fd45d8083f74361f629390c556738565af8eeb", 415 | "sha256:596fae69f2bfcb7305808c75c00f81fe2829b6236eadda536f00610ac5ec2243", 416 | "sha256:5d627124700b92b6bbaa99f27cbe615c8ea7b3402960f6372ea7d65faf376c14", 417 | "sha256:6ac9c21bfe7bc9f7f1b6fae441746e6a106e48fc9de530dea29e8cd37a2c0cc4", 418 | "sha256:82cb6193de9bbb3844bab4c7cf80e6227d5225cc7625b068a06d005d861ad5f1", 419 | "sha256:8f772942d372c8cbac575be99f9cc9d9fb3bd95c8bc2de6c01411e2c84ebca8a", 420 | "sha256:9fece120dbb041771a63eb95e4896791386fe287fefb2837258925b8326d6160", 421 | "sha256:a156e6390944c265eb56afa67c74c0636f10283429171018446b732f1a05af25", 422 | "sha256:a9ec1f695f0c25986e6f7f8778e5ce61659063268836a38c951200c57479cc12", 423 | "sha256:abed92d9c8f08643c7d831300b739562b0a6c9fcb028d211134fc9ab20ccad5d", 424 | "sha256:b031b9601f1060bf1281feab89697324726ba0c0bae9d7cd7ab4b690940f0b92", 425 | "sha256:c543214ffdd422623e9fedd0869166c2f16affe4ba37463975043ef7d2ea8770", 426 | "sha256:d28ddc3e3dfeab553e743e532fb95b4e6afad51d4706dd22f28e1e5e664828d2", 427 | "sha256:f33592ddf9655a4894aef22d134de7393e95fcbdc2d15c1ab65828eee5c66c70", 428 | "sha256:f6b0e77db9ff4fda74de7df13f30016a0a663928d669c9f2c057048ba44f09bb", 429 | "sha256:f757063a83970d67c444f6e01d9550a7402322af3557ce7630d3c957386fa8f5", 430 | "sha256:ff0cedc84184115202475bbb46dd99f8dcb87fe24d5d0ddfc0fe6b8575c88d2f" 431 | ], 432 | "index": "pypi", 433 | "markers": "python_version >= '3.8'", 434 | "version": "==1.5.1" 435 | }, 436 | "mypy-extensions": { 437 | "hashes": [ 438 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 439 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 440 | ], 441 | "markers": "python_version >= '3.5'", 442 | "version": "==1.0.0" 443 | }, 444 | "packaging": { 445 | "hashes": [ 446 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 447 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 448 | ], 449 | "markers": "python_version >= '3.7'", 450 | "version": "==23.1" 451 | }, 452 | "parse": { 453 | "hashes": [ 454 | "sha256:371ed3800dc63983832159cc9373156613947707bc448b5215473a219dbd4362", 455 | "sha256:cc3a47236ff05da377617ddefa867b7ba983819c664e1afe46249e5b469be464" 456 | ], 457 | "version": "==1.19.1" 458 | }, 459 | "parse-type": { 460 | "hashes": [ 461 | "sha256:06d39a8b70fde873eb2a131141a0e79bb34a432941fb3d66fad247abafc9766c", 462 | "sha256:79b1f2497060d0928bc46016793f1fca1057c4aacdf15ef876aa48d75a73a355" 463 | ], 464 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 465 | "version": "==0.6.2" 466 | }, 467 | "pathspec": { 468 | "hashes": [ 469 | "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", 470 | "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" 471 | ], 472 | "markers": "python_version >= '3.7'", 473 | "version": "==0.11.2" 474 | }, 475 | "platformdirs": { 476 | "hashes": [ 477 | "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", 478 | "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d" 479 | ], 480 | "markers": "python_version >= '3.7'", 481 | "version": "==3.10.0" 482 | }, 483 | "pluggy": { 484 | "hashes": [ 485 | "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12", 486 | "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7" 487 | ], 488 | "markers": "python_version >= '3.8'", 489 | "version": "==1.3.0" 490 | }, 491 | "pytest": { 492 | "hashes": [ 493 | "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002", 494 | "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069" 495 | ], 496 | "index": "pypi", 497 | "markers": "python_version >= '3.7'", 498 | "version": "==7.4.2" 499 | }, 500 | "ruff": { 501 | "hashes": [ 502 | "sha256:0e2b09ac4213b11a3520221083866a5816616f3ae9da123037b8ab275066fbac", 503 | "sha256:150bf8050214cea5b990945b66433bf9a5e0cef395c9bc0f50569e7de7540c86", 504 | "sha256:1d9be6351b7889462912e0b8185a260c0219c35dfd920fb490c7f256f1d8313e", 505 | "sha256:2ab41bc0ba359d3f715fc7b705bdeef19c0461351306b70a4e247f836b9350ed", 506 | "sha256:35e3550d1d9f2157b0fcc77670f7bb59154f223bff281766e61bdd1dd854e0c5", 507 | "sha256:461fbd1fb9ca806d4e3d5c745a30e185f7cf3ca77293cdc17abb2f2a990ad3f7", 508 | "sha256:4ca6285aa77b3d966be32c9a3cd531655b3d4a0171e1f9bf26d66d0372186767", 509 | "sha256:75386ebc15fe5467248c039f5bf6a0cfe7bfc619ffbb8cd62406cd8811815fca", 510 | "sha256:75cdc7fe32dcf33b7cec306707552dda54632ac29402775b9e212a3c16aad5e6", 511 | "sha256:949fecbc5467bb11b8db810a7fa53c7e02633856ee6bd1302b2f43adcd71b88d", 512 | "sha256:982af5ec67cecd099e2ef5e238650407fb40d56304910102d054c109f390bf3c", 513 | "sha256:ac93eadf07bc4ab4c48d8bb4e427bf0f58f3a9c578862eb85d99d704669f5da0", 514 | "sha256:ae5a92dfbdf1f0c689433c223f8dac0782c2b2584bd502dfdbc76475669f1ba1", 515 | "sha256:bbd37352cea4ee007c48a44c9bc45a21f7ba70a57edfe46842e346651e2b995a", 516 | "sha256:d748c8bd97874f5751aed73e8dde379ce32d16338123d07c18b25c9a2796574a", 517 | "sha256:eb07f37f7aecdbbc91d759c0c09870ce0fb3eed4025eebedf9c4b98c69abd527", 518 | "sha256:f1f49f5ec967fd5778813780b12a5650ab0ebcb9ddcca28d642c689b36920796" 519 | ], 520 | "index": "pypi", 521 | "markers": "python_version >= '3.7'", 522 | "version": "==0.0.290" 523 | }, 524 | "six": { 525 | "hashes": [ 526 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 527 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 528 | ], 529 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 530 | "version": "==1.16.0" 531 | }, 532 | "tomli": { 533 | "hashes": [ 534 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 535 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 536 | ], 537 | "markers": "python_version < '3.11'", 538 | "version": "==2.0.1" 539 | }, 540 | "typing-extensions": { 541 | "hashes": [ 542 | "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", 543 | "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" 544 | ], 545 | "markers": "python_version >= '3.8'", 546 | "version": "==4.8.0" 547 | } 548 | } 549 | } 550 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhilWhittingham/python-http-thing-doer/1ae1127b5132d8c5101af08afdb8d1909cf22626/app/__init__.py -------------------------------------------------------------------------------- /app/containers.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from app.repository import CharacterCountRepository 4 | 5 | 6 | class Container(containers.DeclarativeContainer): 7 | wiring_config = containers.WiringConfiguration(modules=["app.routes"]) 8 | 9 | database_client = providers.Factory() # type: ignore 10 | 11 | count_repository = providers.Factory( 12 | CharacterCountRepository, client=database_client 13 | ) 14 | -------------------------------------------------------------------------------- /app/domain.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class CharCount(BaseModel): 5 | char: str = Field(max_length=1) 6 | count: int 7 | 8 | 9 | class CharCounter(BaseModel): 10 | input: str 11 | 12 | def count_characters(self) -> list[CharCount]: 13 | character_counts = [] 14 | unique_characters = set(self.input) 15 | for character in unique_characters: 16 | character_count = CharCount( 17 | char=character, count=self.input.count(character) 18 | ) 19 | character_counts.append(character_count) 20 | 21 | return character_counts 22 | -------------------------------------------------------------------------------- /app/dto.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class CommandDto(BaseModel): 5 | command: str 6 | 7 | 8 | class ResponseDto(BaseModel): 9 | input: CommandDto 10 | count: int 11 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | class CountNotFoundException(Exception): 2 | # Raised when a count cannot be found in the repository 3 | ... 4 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from app import routes 3 | 4 | from app.containers import Container 5 | 6 | 7 | def create_app() -> FastAPI: 8 | container = Container() 9 | 10 | app = FastAPI() 11 | app.container = container # type: ignore 12 | app.include_router(routes.router) 13 | return app 14 | 15 | 16 | app = create_app() 17 | -------------------------------------------------------------------------------- /app/repository.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.domain import CharCount 6 | from app.exceptions import CountNotFoundException 7 | 8 | 9 | class CharacterCountRepository(BaseModel): 10 | # Ignore typing for this example, but imagine this could be 11 | # your typical pymongo collection or some sqlalchemy construct 12 | # which allows for saving and retrieving 13 | client: Any 14 | 15 | def get_count(self, character: str, request_input: str) -> int: 16 | database_counts = self.client.find({"request_input": request_input}) 17 | 18 | for database_count in database_counts: 19 | if database_count["count"]["char"] != character: 20 | continue 21 | 22 | return database_count["count"]["count"] 23 | 24 | raise CountNotFoundException() 25 | 26 | def save_count(self, character_count: CharCount, request_input: str): 27 | database_character_count = { 28 | "count": character_count, 29 | "request_input": request_input, 30 | } 31 | self.client.insert_one(database_character_count) 32 | -------------------------------------------------------------------------------- /app/routes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from fastapi import APIRouter, Depends 3 | from dependency_injector.wiring import inject, Provide 4 | from app.containers import Container 5 | from app.domain import CharCounter 6 | 7 | from app.dto import ResponseDto, CommandDto 8 | from app.exceptions import CountNotFoundException 9 | from app.repository import CharacterCountRepository 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.post("/do-thing", response_model=ResponseDto) 17 | @inject 18 | def do_a_thing_url_command( 19 | command: CommandDto, 20 | repository: CharacterCountRepository = Depends(Provide[Container.count_repository]), 21 | ): 22 | command_string = command.command 23 | first_character = command_string[0] 24 | logger.info(f"Request received to count command: {command_string}") 25 | 26 | logger.debug("Attempting to avoid calculation by checking database") 27 | try: 28 | count = repository.get_count(first_character, command_string) 29 | logger.info(f"Returning retrieved value: {count}") 30 | return ResponseDto(input=command, count=count) 31 | except CountNotFoundException: 32 | logger.debug("Unable to find result in database, proceeding to calculation") 33 | 34 | character_counter = CharCounter(input=command_string) 35 | char_counts = character_counter.count_characters() 36 | 37 | first_character_counts = [ 38 | char_count for char_count in char_counts if char_count.char == first_character 39 | ] 40 | 41 | first_character_count = first_character_counts[0] 42 | 43 | logger.debug(f"Successfully calculated count of {first_character_count.count}") 44 | repository.save_count(first_character_count, command_string) 45 | logger.debug("Saved count in database") 46 | 47 | logger.info(f"Returning calculated value: {first_character_count.count}") 48 | return ResponseDto(input=command, count=first_character_counts[0].count) 49 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Generic Thing-Doer in Python 2 | 3 | A tongue-in-cheek demonstration of a handful of technologies working together to achieve something completely arbitrary. 4 | 5 | ## Description 6 | 7 | An elaborate, over-engineered backend solution for doing a single generic "thing" using an HTTP endpoint. 8 | 9 | This project utilises tools and architectures that I have become comfortable with during my time as an engineer. It can be used as a minimal-example for the packages and concepts used. It can be used as a template to build projects that _actually_ do things. 10 | 11 | 12 | ## Pre-requisites 13 | 14 | `Python 3.10` and `Pipenv` are required to build, run and test. 15 | 16 | ## Running Instructions 17 | 18 | Build the environment using 19 | 20 | ```pipenv install```
21 | or
22 | ```pipenv install --dev```
23 | if running the tests. 24 | 25 | Run the service using `uvicorn`:
26 | ```pipenv run serve```
27 | (note that hitting the endpoint will result in errors because, well, it's all pretend). 28 | 29 | Run the tests by running one of
30 | ```pipenv run test-static```
31 | ```pipenv run test-unit```
32 | ```pipenv run test-behave```
33 | for running the static (`ruff`, `black`, `mypy` for styles and types), unit and behave tests respectively. 34 | 35 | 36 | ## Built With 37 | 38 | * [Python 3.10](https://www.python.org/) 39 | * [Pipenv](https://pipenv.pypa.io/en/latest/) 40 | * [FastAPI](https://fastapi.tiangolo.com/) 41 | * [Pydantic](https://docs.pydantic.dev/latest/) 42 | * [Dependency Injector](https://python-dependency-injector.ets-labs.org/) 43 | * [Uvicorn](https://www.uvicorn.org/) 44 | * [Pytest](https://docs.pytest.org/en/7.4.x/) 45 | * [Behave](https://github.com/behave/behave) 46 | * [Ruff](https://github.com/astral-sh/ruff) 47 | * [Black](https://github.com/psf/black) 48 | * [mypy](https://mypy-lang.org/) 49 | 50 | ## Inspired By 51 | [Enterprise FizzBuzz](https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpriseEdition) for its humour, but imagine less silliness and more learning. 52 | # Code Walkthrough 53 | 54 | Here, I'll explain the code in order to describe the concepts demonstrated. Each section will contain a header, a link to any file I'm referring to and an explanation including links to any external references (these links are available in a section at the bottom too). Often, there will be points of uncertainty or discussion as this design is not intended to be a series of dogmatic rules, but rather choices between many valid options. 55 | 56 | ## Entry point 57 | 58 | [app/main.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/main.py) 59 | 60 | This entry point of the code includes a pattern called an Application Factory (which is a concept I've borrowed from my use of it in [Flask](https://flask.palletsprojects.com/en/2.3.x/patterns/appfactories/)). 61 | 62 | In the `create_app` function we _could_ define a bunch of app-level settings, but this is mostly used for its synergy with the Dependency Injector pattern (inspiration from the [Python Dependency Injector pattern itself](https://python-dependency-injector.ets-labs.org/examples/fastapi.html)). Simply, we define our `app` and our `container` (containing our dependencies). The initialisation of the `container` '_wires_' itself through config inside the `Container` class (although, arguably this could be moved to here to keep the config in one place). 63 | 64 | Finally, we make the `app` globally available which allows us to run the program using some ASGI service ([uvicorn](https://www.uvicorn.org/), etc). 65 | 66 | [app/containers.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/containers.py) 67 | 68 | This file is purely there to define our dependencies using Dependency Injector. They include default instantiations which are the ones which will be used in the live service. Everything defined in this file can be overridden in tests, as will be seen later. 69 | 70 | The following sections focus on the conventional [layers](https://en.wikipedia.org/wiki/Multitier_architecture) which I've used here to support appropriate separation of concerns and testibility. 71 | 72 | ## API 73 | 74 | [app/routes.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/routes.py) 75 | 76 | In a more expansive project, this file (like most of the files here) could become it's own folder. I've intentionally chosen to not clog the project structure here with superfluous files/folders (keeping [the YAGNI principle](https://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it) in mind while still demonstrating complexity). 77 | 78 | Routes is intended to be a few things: 79 | - Functionally, this is where our FastAPI routes are defined. 80 | - Logically, this is Domain Driven Design "Service" (Services come in many shapes, some good articles on this [here](http://gorodinski.com/blog/2012/04/14/services-in-domain-driven-design-ddd/) and [here](https://enterprisecraftsmanship.com/posts/domain-vs-application-services/)). 81 | 82 | While I call this a Service, it's only because there is _some_ conditionality controlling the flow of behaviour (we either compute the result or not based on the data in the database). You could split this logic out into a dedicated "Domain Service" and have this file exist simply to define routes. 83 | 84 | We use dependency injection here to allow a dependency to be defined dynamically, and not _inside_ the running code. Leveraging dependency injection allows us to write highly testable code (there's a good article on it [here](https://safjan.com/python-dependency-injection-for-the-testability/)). By defining our dependencies in this way, they are easily overrideable (more on this later). 85 | 86 | Ideally, this route function would do as little as possible, but there's a few things I always deem acceptable in moderation: 87 | - Logging: logging here usually allows for clear and simple logging which is directly related to the flow of behaviour in the system. Digging through logs is one thing, but digging for where a log message is defined is another. 88 | - Safe exception handling: here we handle a missing entry in a database. Having this handled here ensures that the flow of the route's logic is in 1 place, not spread between multiple places. 89 | 90 | Whenever possible, I try to keep the route functions as simple as possible. Really, all we're doing here is: 91 | 1. (Optionally) retrieve some data from a repository. 92 | 2. (Optionally) perform some business logic by using domain level functions. 93 | 3. (Optionally) save some data to a repository. 94 | 4. Return data in an agreed format. 95 | 96 | Note three of those are optional, they can be omitted but rarely should they be rearranged. Ideally they shouldn't be chained either (avoid doing `this` then `that` then `the other` - the function would be definitely doing too much ([Clean Code](https://www.oreilly.com/library/view/clean-code-a/9780136083238/)) and become a maintenance nightmare). 97 | 98 | 99 | ## Domain (Business logic) 100 | 101 | [app/domain.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/domain.py) 102 | 103 | This is where the business logic for our application lives. Ours counts characters present in a string - we define two classes, one to perform the behaviour and one as a return type. We use some nice [Pydantic validation](https://docs.pydantic.dev/latest/usage/validators/) on `CharCount` to ensure that we always create valid objects. 104 | 105 | 106 | ## Repository (Interfacing with an external dependency) 107 | 108 | [app/repository.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/respository.py) 109 | 110 | Our repository class is designed as a way for our service to save the things we want to save. The only thing interesting we do here is to allow for the `client` dependency to be set. 111 | 112 | 113 | ## DTOs (The "display" layer) 114 | 115 | [app/dto.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/dto.py) 116 | 117 | The goal here is to define, in one place, all of the models which our are used to bring data in or send data out from the service. I commonly use the suffix DTO (Data Transfer Object) as a way to visually differentiate them from other models. 118 | 119 | Having all of the models defined in one place also allows us to have all of our input validation in one place. Pydantic provides type validation upon object instantiation, and combined with FastAPI (like we do in `routes.py` [here](https://github.com/PhilWhittingham/python-http-thing-doer/blob/60eb71c6141619f35ec56e48e8cadc4f2cc76c0d/app/routes.py#L16C14-L16C14) and [here](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/routes.py#L19)) gives us automatic [422 status code](https://www.abstractapi.com/http-status-codes/422) responses when our inputs aren't valid. As we did in `domains.py`, we can extend the validation on these DTO classes to be more complex as the need arises. 120 | 121 | A final thing to note is that FastAPI can use these models and routes to generate an [OpenAPI specification](https://spec.openapis.org/oas/v3.1.0) (example tutorial [here](https://www.doctave.com/blog/python-export-fastapi-openapi-spec)) which can be used by a number of platforms to run, mock, describe etc our service. This could be done even if the DTO-style models weren't in one place, but it's nicer that they are. 122 | 123 | ## Other 124 | 125 | [app/exceptions.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/main/app/exceptions.py) 126 | 127 | It's my personal preference to 1, minimise the use of custom exceptions where [Python built-ins](https://docs.python.org/3/library/exceptions.html) could be used instead, and 2, keep all custom exceptions in one place - usually with a small note about their intended usage. 128 | 129 | 130 | ## Testing 131 | 132 | ### Unit testing 133 | I use Pytest for unit testing and attempt to keep the hierarchy as flat as possible by only writing fully isolated function tests (no test classes) with highly descriptive names in the format 134 | 135 | ``` 136 | def test_function_under_test_input_description_expected_behaviour(): 137 | ... 138 | ``` 139 | 140 | There are a bunch of [variations](https://medium.com/@stefanovskyi/unit-test-naming-conventions-dd9208eadbea) on this pattern, but it mostly contains the same information 141 | 142 | I avoid test classes to encourage better isolation (also I find that the terminal output is clearer to read). 143 | 144 | ### BDD testing 145 | 146 | The behave testing is incredibly powerful for testing system-wide behaviour _and_ allowing you to make certain assertions about data-at-rest afterwards (through mocking the database). Behave allows us to write canned expressions (steps) to populate "Given, When, Then" Scenarios in [Gherkin syntax](https://cucumber.io/docs/gherkin/reference/). 147 | 148 | Where behave excels in this example is its synergy with FastAPI's test client (direct access to the HTTP endpoints) and Dependency Injectors containers (overriding allows us direct access to the dependencies). 149 | 150 | 151 | [test/behave/environment.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/test/behave/environment.py) 152 | 153 | The environment file for Behave allows us to set behaviour that we want to happen when the tests run. In this we set mocks, and define that they are refreshed between every Scenario. We also define the app's test client. We attach all of these to Behave's `context` object which manages data between steps (this way, it's all accessible in the steps themselves). 154 | 155 | [test/behave/mocks.py](https://github.com/PhilWhittingham/python-http-thing-doer/blob/test/behave/mocks.py) 156 | 157 | Here, we define a minimal mock Client to ensure that we can pretend that we're saving data. Only the functions used by the application are covered, and we only do enough to ensure that we can verify that the right things have been saved. This could easily be replaced by [mongomock](https://github.com/mongomock/mongomock) if we're fancy, just a standard [Mock/MagicMock](https://docs.python.org/3/library/unittest.mock.html) if we're not. 158 | 159 | We use the `datastore` property later to enable verification that our application submits things to the database correctly (`Given` we have a request, `When` we hit an endpoint, `Then` data is written to the database). Arguably it's enough to say a 2XX HTTP status code is returned (we do this too), but its nice to be sure. 160 | 161 | 162 | ## Further reading (links to all sources) 163 | 164 | TODO -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhilWhittingham/python-http-thing-doer/1ae1127b5132d8c5101af08afdb8d1909cf22626/test/__init__.py -------------------------------------------------------------------------------- /test/behave/environment.py: -------------------------------------------------------------------------------- 1 | from behave import fixture, use_fixture 2 | from fastapi.testclient import TestClient 3 | from app.main import create_app 4 | from test.behave.mocks import MockDatabaseClient 5 | 6 | 7 | @fixture 8 | def mock_database_client(context): 9 | client = MockDatabaseClient() 10 | 11 | context.mock_database_client = client 12 | with context.app.container.database_client.override(client): 13 | yield client 14 | 15 | 16 | @fixture 17 | def app_client(context): 18 | app = create_app() 19 | 20 | # Used to overwrite dependencies 21 | context.app = app 22 | use_fixture(mock_database_client, context) 23 | 24 | # Used in tests to execute calls 25 | context.client = TestClient(app, raise_server_exceptions=False) 26 | yield context.client 27 | app.container.unwire() 28 | 29 | 30 | def before_scenario(context, scenario): 31 | context.mock_database_client.datastore = [] 32 | 33 | 34 | def before_feature(context, feature): 35 | use_fixture(app_client, context) 36 | -------------------------------------------------------------------------------- /test/behave/features/do-thing.feature: -------------------------------------------------------------------------------- 1 | Feature: Do things using the thing doer 2 | 3 | Scenario: The user does a thing 4 | Given a request in the correct format 5 | When we do a thing 6 | Then the status code returned is 200 7 | 8 | Scenario: The user does a thing with the wrong input 9 | Given a request in the incorrect format 10 | When we do a thing 11 | Then the status code returned is 422 12 | 13 | Scenario: The user does a specific thing 14 | Given a request where the input is: this string 15 | When we do a thing 16 | Then the status code returned is 200 17 | And the response has successfully counted our first letter 2 times 18 | And the database has 1 document in it 19 | 20 | Scenario: The user does a thing which has already been done 21 | Given a request where the input is: this string 22 | And a database entry which has character t with a count of 100 for that request 23 | When we do a thing 24 | Then the status code returned is 200 25 | And the response has successfully counted our first letter 100 times 26 | And the database has 1 document in it -------------------------------------------------------------------------------- /test/behave/mocks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pydantic import BaseModel, Field 3 | 4 | 5 | class MockDatabaseClient(BaseModel): 6 | datastore: list[str] = Field(default_factory=list) 7 | 8 | def insert_one(self, inserted_one: dict): 9 | # We cheat here to do the serialisation which may 10 | # be handled by the database engine 11 | serialised_inserted_one = json.dumps(inserted_one, default=str) 12 | 13 | self.datastore.append(serialised_inserted_one) 14 | 15 | def find(self, query_dict: dict): 16 | # Only support minimum query here for the test 17 | request_input = query_dict["request_input"] 18 | 19 | matched_data: list[dict] = [] 20 | for data in self.datastore: 21 | json_data = json.loads(data) 22 | if json_data["request_input"] != request_input: 23 | continue 24 | 25 | matched_data.append(json_data) 26 | 27 | return matched_data 28 | -------------------------------------------------------------------------------- /test/behave/steps/given.py: -------------------------------------------------------------------------------- 1 | from behave import given 2 | 3 | 4 | @given("a request in the correct format") 5 | def add_data_to_request(context): 6 | request = {"command": "this is a command"} 7 | 8 | context.request = request 9 | 10 | 11 | @given("a request where the input is: {request_input}") 12 | def add_specific_data_to_request(context, request_input: str): 13 | request = {"command": request_input} 14 | 15 | context.request = request 16 | 17 | 18 | @given("a request in the incorrect format") 19 | def add_bad_data_to_request(context): 20 | request = {"not_the_right_format": 100} 21 | 22 | context.request = request 23 | 24 | 25 | @given( 26 | "a database entry which has character {character} with a " 27 | "count of {count:d} for that request" 28 | ) 29 | def create_database_entry(context, character: str, count: int): 30 | entry = { 31 | "count": {"char": character, "count": count}, 32 | "request_input": context.request["command"], 33 | } 34 | 35 | context.mock_database_client.insert_one(entry) 36 | -------------------------------------------------------------------------------- /test/behave/steps/then.py: -------------------------------------------------------------------------------- 1 | from behave import then 2 | 3 | 4 | @then("the status code returned is {code:d}") 5 | def assert_status_code_is_200(context, code: int): 6 | response = context.response 7 | 8 | assert response.status_code == code 9 | 10 | 11 | @then("the response has successfully counted our first letter {count:d} times") 12 | def assert_character_count(context, count: int): 13 | response_body = context.response.json() 14 | 15 | assert response_body["count"] == count 16 | 17 | 18 | @then("the database has {count:d} document in it") 19 | def assert_database_document_count(context, count: int): 20 | datastore = context.mock_database_client.datastore 21 | 22 | assert len(datastore) == count, len(datastore) 23 | -------------------------------------------------------------------------------- /test/behave/steps/when.py: -------------------------------------------------------------------------------- 1 | from behave import when 2 | 3 | 4 | @when("we do a thing") 5 | def post_do_thing(context): 6 | response = context.client.post("/do-thing", json=context.request) 7 | 8 | context.response = response 9 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhilWhittingham/python-http-thing-doer/1ae1127b5132d8c5101af08afdb8d1909cf22626/test/unit/__init__.py -------------------------------------------------------------------------------- /test/unit/test_domain.py: -------------------------------------------------------------------------------- 1 | from pydantic import ValidationError 2 | import pytest 3 | from app.domain import CharCount, CharCounter 4 | 5 | 6 | def test_char_counter_given_input_string_counts_characters(): 7 | input_string = "this is our string" 8 | 9 | character_counter = CharCounter(input=input_string) 10 | charater_counts = character_counter.count_characters() 11 | 12 | t_counts = [ 13 | character_count 14 | for character_count in charater_counts 15 | if character_count.char == "t" 16 | ] 17 | assert len(t_counts) == 1 18 | assert t_counts[0].count == 2 19 | 20 | 21 | @pytest.mark.parametrize("character_string", ["ab", "abc", "abcd"]) 22 | def test_char_count_with_char_length_greater_than_1_fails_validation(character_string): 23 | with pytest.raises(ValidationError): 24 | CharCount(char=character_string, count=0) 25 | -------------------------------------------------------------------------------- /test/unit/test_repository.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | from app.domain import CharCount 5 | from app.exceptions import CountNotFoundException 6 | from app.repository import CharacterCountRepository 7 | 8 | 9 | def test_character_count_repository_save_with_inputs_returns_none(): 10 | char_count = CharCount(char="t", count=1) 11 | request_input = "this" 12 | 13 | mocked_client = MagicMock() 14 | mocked_client.insert_one.return_value = None 15 | repository = CharacterCountRepository(client=mocked_client) 16 | 17 | # We assert the function completed successfully 18 | assert repository.save_count(char_count, request_input) is None 19 | 20 | 21 | def test_character_count_repository_get_with_count_present_returns_count_value(): 22 | mocked_client = MagicMock() 23 | mocked_client.find.return_value = [ 24 | {"request_input": "this", "count": {"char": "t", "count": 1}} 25 | ] 26 | repository = CharacterCountRepository(client=mocked_client) 27 | 28 | count = repository.get_count("t", "this") 29 | 30 | assert count == 1 31 | 32 | 33 | def test_character_count_repository_get_with_count_missing_raises_exception(): 34 | mocked_client = MagicMock() 35 | mocked_client.find.return_value = [] 36 | repository = CharacterCountRepository(client=mocked_client) 37 | 38 | with pytest.raises(CountNotFoundException): 39 | _ = repository.get_count("t", "this") 40 | --------------------------------------------------------------------------------