├── .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 |
--------------------------------------------------------------------------------