diff --git a/Dockerfile.dev b/Dockerfile.dev index 99f9c4c..1ed08c7 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -8,13 +8,16 @@ RUN echo Creating dev user with UID $DEV_USER_UID && \ USER dev ENV PATH="/home/dev/.local/bin:${PATH}" +# Don't write bytecode cache files +ENV PYTHONDONTWRITEBYTECODE=1 + # Install pipenv to generate requirements.txt from Pipfile RUN pip install pipenv # Generate requirements.txt and install dependencies with pip WORKDIR /app COPY Pipfile Pipfile.lock ./ -RUN pipenv lock --requirements --dev > tmp_requirements.txt && \ +RUN pipenv requirements --dev > tmp_requirements.txt && \ pip install -r tmp_requirements.txt # Set default command diff --git a/Pipfile b/Pipfile index d5f298d..78dd3a8 100644 --- a/Pipfile +++ b/Pipfile @@ -6,12 +6,13 @@ name = "pypi" [packages] gunicorn = "~=20.1" werkzeug = "~=2.0" -flask = "~=2.0" +flask = "~=2.2" pyyaml = "*" sqlalchemy = "~=1.4" pymysql = "*" alembic = "~=1.7" python-dateutil = "*" +validataclass = "~=0.6" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 88d65a4..3cef811 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f63a254c353bc5c8e6cbe2a920f8402ba0da5bec775bfff91f64fa485e9bf95c" + "sha256": "84613c292abfb9f596faadd525137db1053dfe39d650638455c11ac684a91487" }, "pipfile-spec": 6, "requires": { @@ -18,88 +18,87 @@ "default": { "alembic": { "hashes": [ - "sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b", - "sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58" + "sha256:0a024d7f2de88d738d7395ff866997314c837be6104e90c5724350313dee4da4", + "sha256:cd0b5e45b14b706426b833f06369b9a6d5ee03f826ec3238723ce8caaf6e5ffa" ], "index": "pypi", - "version": "==1.7.7" + "version": "==1.8.1" }, "click": { "hashes": [ - "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e", - "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72" + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" ], "markers": "python_version >= '3.7'", - "version": "==8.1.2" + "version": "==8.1.3" }, "flask": { "hashes": [ - "sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264", - "sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8" + "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b", + "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526" ], "index": "pypi", - "version": "==2.1.1" + "version": "==2.2.2" }, "greenlet": { "hashes": [ - "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3", - "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711", - "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd", - "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073", - "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708", - "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67", - "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23", - "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1", - "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08", - "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd", - "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2", - "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa", - "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8", - "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40", - "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab", - "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6", - "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc", - "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b", - "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e", - "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963", - "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3", - "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d", - "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d", - "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe", - "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28", - "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3", - "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e", - "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c", - "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d", - "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0", - "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497", - "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee", - "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713", - "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58", - "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a", - "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06", - "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88", - "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965", - "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f", - "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4", - "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5", - "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c", - "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a", - "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1", - "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43", - "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627", - "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b", - "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168", - "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d", - "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5", - "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478", - "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf", - "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce", - "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c", - "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b" + "sha256:0118817c9341ef2b0f75f5af79ac377e4da6ff637e5ee4ac91802c0e379dadb4", + "sha256:048d2bed76c2aa6de7af500ae0ea51dd2267aec0e0f2a436981159053d0bc7cc", + "sha256:07c58e169bbe1e87b8bbf15a5c1b779a7616df9fd3e61cadc9d691740015b4f8", + "sha256:095a980288fe05adf3d002fbb180c99bdcf0f930e220aa66fcd56e7914a38202", + "sha256:0b181e9aa6cb2f5ec0cacc8cee6e5a3093416c841ba32c185c30c160487f0380", + "sha256:1626185d938d7381631e48e6f7713e8d4b964be246073e1a1d15c2f061ac9f08", + "sha256:184416e481295832350a4bf731ba619a92f5689bf5d0fa4341e98b98b1265bd7", + "sha256:1dd51d2650e70c6c4af37f454737bf4a11e568945b27f74b471e8e2a9fd21268", + "sha256:1ec2779774d8e42ed0440cf8bc55540175187e8e934f2be25199bf4ed948cd9e", + "sha256:2cf45e339cabea16c07586306a31cfcc5a3b5e1626d365714d283732afed6809", + "sha256:2fb0aa7f6996879551fd67461d5d3ab0c3c0245da98be90c89fcb7a18d437403", + "sha256:44b4817c34c9272c65550b788913620f1fdc80362b209bc9d7dd2f40d8793080", + "sha256:466ce0928e33421ee84ae04c4ac6f253a3a3e6b8d600a79bd43fd4403e0a7a76", + "sha256:4f166b4aca8d7d489e82d74627a7069ab34211ef5ebb57c300ec4b9337b60fc0", + "sha256:510c3b15587afce9800198b4b142202b323bf4b4b5f9d6c79cb9a35e5e3c30d2", + "sha256:5b756e6730ea59b2745072e28ad27f4c837084688e6a6b3633c8b1e509e6ae0e", + "sha256:5fbe1ab72b998ca77ceabbae63a9b2e2dc2d963f4299b9b278252ddba142d3f1", + "sha256:6200a11f003ec26815f7e3d2ded01b43a3810be3528dd760d2f1fa777490c3cd", + "sha256:65ad1a7a463a2a6f863661329a944a5802c7129f7ad33583dcc11069c17e622c", + "sha256:694ffa7144fa5cc526c8f4512665003a39fa09ef00d19bbca5c8d3406db72fbe", + "sha256:6f5d4b2280ceea76c55c893827961ed0a6eadd5a584a7c4e6e6dd7bc10dfdd96", + "sha256:7532a46505470be30cbf1dbadb20379fb481244f1ca54207d7df3bf0bbab6a20", + "sha256:76a53bfa10b367ee734b95988bd82a9a5f0038a25030f9f23bbbc005010ca600", + "sha256:77e41db75f9958f2083e03e9dd39da12247b3430c92267df3af77c83d8ff9eed", + "sha256:7a43bbfa9b6cfdfaeefbd91038dde65ea2c421dc387ed171613df340650874f2", + "sha256:7b41d19c0cfe5c259fe6c539fd75051cd39a5d33d05482f885faf43f7f5e7d26", + "sha256:7c5227963409551ae4a6938beb70d56bf1918c554a287d3da6853526212fbe0a", + "sha256:870a48007872d12e95a996fca3c03a64290d3ea2e61076aa35d3b253cf34cd32", + "sha256:88b04e12c9b041a1e0bcb886fec709c488192638a9a7a3677513ac6ba81d8e79", + "sha256:8c287ae7ac921dfde88b1c125bd9590b7ec3c900c2d3db5197f1286e144e712b", + "sha256:903fa5716b8fbb21019268b44f73f3748c41d1a30d71b4a49c84b642c2fed5fa", + "sha256:9537e4baf0db67f382eb29255a03154fcd4984638303ff9baaa738b10371fa57", + "sha256:9951dcbd37850da32b2cb6e391f621c1ee456191c6ae5528af4a34afe357c30e", + "sha256:9b2f7d0408ddeb8ea1fd43d3db79a8cefaccadd2a812f021333b338ed6b10aba", + "sha256:9c88e134d51d5e82315a7c32b914a58751b7353eb5268dbd02eabf020b4c4700", + "sha256:9fae214f6c43cd47f7bef98c56919b9222481e833be2915f6857a1e9e8a15318", + "sha256:a3a669f11289a8995d24fbfc0e63f8289dd03c9aaa0cc8f1eab31d18ca61a382", + "sha256:aa741c1a8a8cc25eb3a3a01a62bdb5095a773d8c6a86470bde7f607a447e7905", + "sha256:b0877a9a2129a2c56a2eae2da016743db7d9d6a05d5e1c198f1b7808c602a30e", + "sha256:bcb6c6dd1d6be6d38d6db283747d07fda089ff8c559a835236560a4410340455", + "sha256:caff52cb5cd7626872d9696aee5b794abe172804beb7db52eed1fd5824b63910", + "sha256:cbc1eb55342cbac8f7ec159088d54e2cfdd5ddf61c87b8bbe682d113789331b2", + "sha256:cd16a89efe3a003029c87ff19e9fba635864e064da646bc749fc1908a4af18f3", + "sha256:ce5b64dfe8d0cca407d88b0ee619d80d4215a2612c1af8c98a92180e7109f4b5", + "sha256:d58a5a71c4c37354f9e0c24c9c8321f0185f6945ef027460b809f4bb474bfe41", + "sha256:db41f3845eb579b544c962864cce2c2a0257fe30f0f1e18e51b1e8cbb4e0ac6d", + "sha256:db5b25265010a1b3dca6a174a443a0ed4c4ab12d5e2883a11c97d6e6d59b12f9", + "sha256:dd0404d154084a371e6d2bafc787201612a1359c2dee688ae334f9118aa0bf47", + "sha256:de431765bd5fe62119e0bc6bc6e7b17ac53017ae1782acf88fcf6b7eae475a49", + "sha256:df02fdec0c533301497acb0bc0f27f479a3a63dcdc3a099ae33a902857f07477", + "sha256:e8533f5111704d75de3139bf0b8136d3a6c1642c55c067866fa0a51c2155ee33", + "sha256:f2f908239b7098799b8845e5936c2ccb91d8c2323be02e82f8dcb4a80dcf4a25", + "sha256:f8bfd36f368efe0ab2a6aa3db7f14598aac454b06849fb633b762ddbede1db90", + "sha256:ffe73f9e7aea404722058405ff24041e59d31ca23d1da0895af48050a07b6932" ], "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", - "version": "==1.1.2" + "version": "==1.1.3" }, "gunicorn": { "hashes": [ @@ -119,19 +118,19 @@ }, "jinja2": { "hashes": [ - "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119", - "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9" + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" ], "markers": "python_version >= '3.7'", - "version": "==3.1.1" + "version": "==3.1.2" }, "mako": { "hashes": [ - "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba", - "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39" + "sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f", + "sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534" ], "markers": "python_version >= '3.7'", - "version": "==1.2.0" + "version": "==1.2.2" }, "markupsafe": { "hashes": [ @@ -197,6 +196,7 @@ }, "pyyaml": { "hashes": [ + "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", @@ -208,26 +208,32 @@ "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" ], @@ -236,11 +242,11 @@ }, "setuptools": { "hashes": [ - "sha256:26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8", - "sha256:47c7b0c0f8fc10eec4cf1e71c6fdadf8decaa74ffa087e68cd1c20db7ad6a592" + "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82", + "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57" ], "markers": "python_version >= '3.7'", - "version": "==62.1.0" + "version": "==65.3.0" }, "six": { "hashes": [ @@ -252,53 +258,66 @@ }, "sqlalchemy": { "hashes": [ - "sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6", - "sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b", - "sha256:1b4eac3933c335d7f375639885765722534bb4e52e51cdc01a667eea822af9b6", - "sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e", - "sha256:28aa2ef06c904729620cc735262192e622db9136c26d8587f71f29ec7715628a", - "sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b", - "sha256:2c6c411d8c59afba95abccd2b418f30ade674186660a2d310d364843049fb2c1", - "sha256:2ffc813b01dc6473990f5e575f210ca5ac2f5465ace3908b78ffd6d20058aab5", - "sha256:48036698f20080462e981b18d77d574631a3d1fc2c33b416c6df299ec1d10b99", - "sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4", - "sha256:4ba2c1f368bcf8551cdaa27eac525022471015633d5bdafbc4297e0511f62f51", - "sha256:53c7469b86a60fe2babca4f70111357e6e3d5150373bc85eb3b914356983e89a", - "sha256:6204d06bfa85f87625e1831ca663f9dba91ac8aec24b8c65d02fb25cbaf4b4d7", - "sha256:63c82c9e8ccc2fb4bfd87c24ffbac320f70b7c93b78f206c1f9c441fa3013a5f", - "sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157", - "sha256:95411abc0e36d18f54fa5e24d42960ea3f144fb16caaa5a8c2e492b5424cc82c", - "sha256:9837133b89ad017e50a02a3b46419869cf4e9aa02743e911b2a9e25fa6b05403", - "sha256:9bec63b1e20ef69484f530fb4b4837e050450637ff9acd6dccc7003c5013abf8", - "sha256:9d8edfb09ed2b865485530c13e269833dab62ab2d582fde21026c9039d4d0e62", - "sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d", - "sha256:9e1a72197529ea00357640f21d92ffc7024e156ef9ac36edf271c8335facbc1a", - "sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd", - "sha256:a4efb70a62cbbbc052c67dc66b5448b0053b509732184af3e7859d05fdf6223c", - "sha256:a5dbdbb39c1b100df4d182c78949158073ca46ba2850c64fe02ffb1eb5b70903", - "sha256:aeea6ace30603ca9a8869853bb4a04c7446856d7789e36694cd887967b7621f6", - "sha256:b2489e70bfa2356f2d421106794507daccf6cc8711753c442fc97272437fc606", - "sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f", - "sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041", - "sha256:cfd8e4c64c30a5219032e64404d468c425bdbc13b397da906fc9bee6591fc0dd", - "sha256:d17316100fcd0b6371ac9211351cb976fd0c2e12a859c1a57965e3ef7f3ed2bc", - "sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70", - "sha256:da25e75ba9f3fabc271673b6b413ca234994e6d3453424bea36bb5549c5bbaec", - "sha256:e255a8dd5572b0c66d6ee53597d36157ad6cf3bc1114f61c54a65189f996ab03", - "sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044", - "sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228", - "sha256:effadcda9a129cc56408dd5b2ea20ee9edcea24bd58e6a1489fa27672d733182" + "sha256:0002e829142b2af00b4eaa26c51728f3ea68235f232a2e72a9508a3116bd6ed0", + "sha256:0005bd73026cd239fc1e8ccdf54db58b6193be9a02b3f0c5983808f84862c767", + "sha256:0292f70d1797e3c54e862e6f30ae474014648bc9c723e14a2fda730adb0a9791", + "sha256:036d8472356e1d5f096c5e0e1a7e0f9182140ada3602f8fff6b7329e9e7cfbcd", + "sha256:05f0de3a1dc3810a776275763764bb0015a02ae0f698a794646ebc5fb06fad33", + "sha256:0990932f7cca97fece8017414f57fdd80db506a045869d7ddf2dda1d7cf69ecc", + "sha256:13e397a9371ecd25573a7b90bd037db604331cf403f5318038c46ee44908c44d", + "sha256:14576238a5f89bcf504c5f0a388d0ca78df61fb42cb2af0efe239dc965d4f5c9", + "sha256:199a73c31ac8ea59937cc0bf3dfc04392e81afe2ec8a74f26f489d268867846c", + "sha256:2082a2d2fca363a3ce21cfa3d068c5a1ce4bf720cf6497fb3a9fc643a8ee4ddd", + "sha256:22ff16cedab5b16a0db79f1bc99e46a6ddececb60c396562e50aab58ddb2871c", + "sha256:2307495d9e0ea00d0c726be97a5b96615035854972cc538f6e7eaed23a35886c", + "sha256:2ad2b727fc41c7f8757098903f85fafb4bf587ca6605f82d9bf5604bd9c7cded", + "sha256:2d6495f84c4fd11584f34e62f9feec81bf373787b3942270487074e35cbe5330", + "sha256:361f6b5e3f659e3c56ea3518cf85fbdae1b9e788ade0219a67eeaaea8a4e4d2a", + "sha256:3e2ef592ac3693c65210f8b53d0edcf9f4405925adcfc031ff495e8d18169682", + "sha256:4676d51c9f6f6226ae8f26dc83ec291c088fe7633269757d333978df78d931ab", + "sha256:4ba7e122510bbc07258dc42be6ed45997efdf38129bde3e3f12649be70683546", + "sha256:5102fb9ee2c258a2218281adcb3e1918b793c51d6c2b4666ce38c35101bb940e", + "sha256:5323252be2bd261e0aa3f33cb3a64c45d76829989fa3ce90652838397d84197d", + "sha256:58bb65b3274b0c8a02cea9f91d6f44d0da79abc993b33bdedbfec98c8440175a", + "sha256:59bdc291165b6119fc6cdbc287c36f7f2859e6051dd923bdf47b4c55fd2f8bd0", + "sha256:5facb7fd6fa8a7353bbe88b95695e555338fb038ad19ceb29c82d94f62775a05", + "sha256:639e1ae8d48b3c86ffe59c0daa9a02e2bfe17ca3d2b41611b30a0073937d4497", + "sha256:8eb8897367a21b578b26f5713833836f886817ee2ffba1177d446fa3f77e67c8", + "sha256:90484a2b00baedad361402c257895b13faa3f01780f18f4a104a2f5c413e4536", + "sha256:9c56e19780cd1344fcd362fd6265a15f48aa8d365996a37fab1495cae8fcd97d", + "sha256:b67fc780cfe2b306180e56daaa411dd3186bf979d50a6a7c2a5b5036575cbdbb", + "sha256:c0dcf127bb99458a9d211e6e1f0f3edb96c874dd12f2503d4d8e4f1fd103790b", + "sha256:c23d64a0b28fc78c96289ffbd0d9d1abd48d267269b27f2d34e430ea73ce4b26", + "sha256:ccfd238f766a5bb5ee5545a62dd03f316ac67966a6a658efb63eeff8158a4bbf", + "sha256:cd767cf5d7252b1c88fcfb58426a32d7bd14a7e4942497e15b68ff5d822b41ad", + "sha256:ce8feaa52c1640de9541eeaaa8b5fb632d9d66249c947bb0d89dd01f87c7c288", + "sha256:d2e054aed4645f9b755db85bc69fc4ed2c9020c19c8027976f66576b906a74f1", + "sha256:e16c2be5cb19e2c08da7bd3a87fed2a0d4e90065ee553a940c4fc1a0fb1ab72b", + "sha256:e4b12e3d88a8fffd0b4ca559f6d4957ed91bd4c0613a4e13846ab8729dc5c251", + "sha256:e570cfc40a29d6ad46c9aeaddbdcee687880940a3a327f2c668dd0e4ef0a441d", + "sha256:eb30cf008850c0a26b72bd1b9be6730830165ce049d239cfdccd906f2685f892", + "sha256:f37fa70d95658763254941ddd30ecb23fc4ec0c5a788a7c21034fc2305dab7cc", + "sha256:f5ebeeec5c14533221eb30bad716bc1fd32f509196318fb9caa7002c4a364e4c", + "sha256:f5fa526d027d804b1f85cdda1eb091f70bde6fb7d87892f6dd5a48925bc88898" ], "index": "pypi", - "version": "==1.4.35" + "version": "==1.4.41" + }, + "validataclass": { + "hashes": [ + "sha256:154021e42be7168aa6465e3c8d05b7add3ac5a78a5c1b02f17efdf886c345d9e", + "sha256:db198754479aac855a62a4195f2ba6d0d0893cac10a949946566169ca92d4895" + ], + "index": "pypi", + "version": "==0.6.2" }, "werkzeug": { "hashes": [ - "sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6", - "sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74" + "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f", + "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5" ], "index": "pypi", - "version": "==2.1.1" + "version": "==2.2.2" } }, "develop": {} diff --git a/api_tests/http-client.env.json b/api_tests/http-client.env.json new file mode 100644 index 0000000..6e4bf5e --- /dev/null +++ b/api_tests/http-client.env.json @@ -0,0 +1,5 @@ +{ + "dev": { + "api_host": "http://localhost:5000" + } +} diff --git a/api_tests/tasks.http b/api_tests/tasks.http new file mode 100644 index 0000000..7e6699e --- /dev/null +++ b/api_tests/tasks.http @@ -0,0 +1,28 @@ +### Fetch all tasks +GET {{api_host}}/api/tasks + + +### Fetch one task +GET {{api_host}}/api/tasks/1 + + +### Create new task +POST {{api_host}}/api/tasks +Content-Type: application/json + +{ + "title": "Some test task" +} + + +### Update task +PATCH {{api_host}}/api/tasks/1 +Content-Type: application/json + +{ + "description": "Update!" +} + + +### Delete task +DELETE {{api_host}}/api/tasks/10 diff --git a/development.env b/development.env index 3cd6d84..8a5b126 100644 --- a/development.env +++ b/development.env @@ -1,5 +1,5 @@ FLASK_APP=tofu_api.app -FLASK_ENV=development +FLASK_DEBUG=true FLASK_CONFIG_FILE=config.dev.yml # Show SQLAlchemy 2.0 deprecation warnings diff --git a/tofu_api/api/rest_api.py b/tofu_api/api/rest_api.py index fea8814..f708aae 100644 --- a/tofu_api/api/rest_api.py +++ b/tofu_api/api/rest_api.py @@ -1,5 +1,5 @@ from tofu_api.common.rest import BaseBlueprint -from .tasks import TaskApiBlueprint +from .tasks import TaskBlueprint class TofuApiBlueprint(BaseBlueprint): @@ -12,4 +12,4 @@ class TofuApiBlueprint(BaseBlueprint): url_prefix = '/api' def init_blueprint(self) -> None: - self.register_blueprint(TaskApiBlueprint(self.app)) + self.register_blueprint(TaskBlueprint(self.app)) diff --git a/tofu_api/api/tasks/__init__.py b/tofu_api/api/tasks/__init__.py index 021af83..ce3b464 100644 --- a/tofu_api/api/tasks/__init__.py +++ b/tofu_api/api/tasks/__init__.py @@ -1 +1,2 @@ -from .task_api import TaskApiBlueprint +from .task_blueprint import TaskBlueprint +from .task_handler import TaskHandler diff --git a/tofu_api/api/tasks/task_api.py b/tofu_api/api/tasks/task_api.py deleted file mode 100644 index a4fff4a..0000000 --- a/tofu_api/api/tasks/task_api.py +++ /dev/null @@ -1,104 +0,0 @@ -from flask import jsonify -from flask.views import MethodView -from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound - -from tofu_api.common.rest import BaseBlueprint -from tofu_api.models import Task - - -class TaskApiBlueprint(BaseBlueprint): - """ - Blueprint for the tasks REST API. - """ - - # Blueprint settings - name = 'rest_api_tasks' - import_name = __name__ - url_prefix = '/tasks' - - def init_blueprint(self) -> None: - """ - Register URL rules. - """ - db_session = self.app.dependencies.get_db_session() - - self.add_url_rule( - '', - view_func=self.create_view_func(TaskCollectionView, db_session=db_session), - methods=['GET', 'POST'], - ) - self.add_url_rule( - '/', - view_func=self.create_view_func(TaskItemView, db_session=db_session), - methods=['GET', 'PATCH', 'DELETE'], - ) - - -class TaskBaseView(MethodView): - """ - Base class for view classes for the `/tasks` endpoint. - """ - - # TODO: Use a handler class instead of accessing the database session directly - db_session: Session - - def __init__(self, *, db_session: Session): - self.db_session = db_session - - -class TaskCollectionView(TaskBaseView): - """ - View class for `/tasks` endpoint. - """ - - def get(self): - """ - Get list of all tasks. - """ - task_list = self.db_session.query(Task).all() - return jsonify({ - 'count': len(task_list), - 'items': [task.to_dict() for task in task_list], - }), 200 - - def post(self): - """ - Create a new task. - """ - # TODO: Parse request data and create real data - new_task = Task( - title='Do stuff' - ) - self.db_session.add(new_task) - self.db_session.commit() - return jsonify(new_task.to_dict()), 201 - - -class TaskItemView(TaskBaseView): - """ - View class for `/tasks/` endpoint. - """ - - def get(self, task_id: int): - """ - Get a single task by ID. - """ - task = self.db_session.query(Task).get(task_id) - if task is None: - raise NotFound(f'Task with ID {task_id} not found!') - return jsonify(task.to_dict()), 200 - - def patch(self, task_id: int): - """ - Update a single task by ID. - """ - # TODO: Implement - raise NotImplementedError - - def delete(self, task_id: int): - """ - Delete a single task by ID. - """ - # TODO: Implement - raise NotImplementedError diff --git a/tofu_api/api/tasks/task_blueprint.py b/tofu_api/api/tasks/task_blueprint.py new file mode 100644 index 0000000..d73ee15 --- /dev/null +++ b/tofu_api/api/tasks/task_blueprint.py @@ -0,0 +1,30 @@ +from tofu_api.common.rest import BaseBlueprint +from .task_views import TaskCollectionView, TaskItemView + + +class TaskBlueprint(BaseBlueprint): + """ + Blueprint for the tasks REST API. + """ + + # Blueprint settings + name = 'rest_api_tasks' + import_name = __name__ + url_prefix = '/tasks' + + def init_blueprint(self) -> None: + """ + Register URL rules. + """ + task_handler = self.app.dependencies.get_task_handler() + + self.add_url_rule( + '', + view_func=TaskCollectionView.as_view(task_handler=task_handler), + methods=['GET', 'POST'], + ) + self.add_url_rule( + '/', + view_func=TaskItemView.as_view(task_handler=task_handler), + methods=['GET', 'PATCH', 'DELETE'], + ) diff --git a/tofu_api/api/tasks/task_handler.py b/tofu_api/api/tasks/task_handler.py new file mode 100644 index 0000000..7cb5fed --- /dev/null +++ b/tofu_api/api/tasks/task_handler.py @@ -0,0 +1,50 @@ +from tofu_api.models import Task +from tofu_api.repositories import TaskRepository +from .validators import TaskCreateData, TaskUpdateData + + +class TaskHandler: + """ + Handles operations on tasks. + """ + + task_repository: TaskRepository + + def __init__(self, *, task_repository: TaskRepository): + self.task_repository = task_repository + + def fetch_task(self, task_id: int) -> Task: + """ + Fetches a single task by its ID from the database. + Raises an ObjectNotFoundError if the task was not found. + """ + return self.task_repository.fetch_by_id(task_id) + + def fetch_all_tasks(self) -> list[Task]: + """ + Fetches a list of all tasks. + """ + return self.task_repository.fetch_all() + + def create_task(self, create_data: TaskCreateData) -> Task: + """ + Creates a new task, saves it to the database and returns it. + """ + task = Task() + task.update_from(create_data) + self.task_repository.save_resource(task) + return task + + def update_task(self, task: Task, update_data: TaskUpdateData) -> Task: + """ + Updates a Task object with new data. + """ + task.update_from(update_data) + self.task_repository.save_resource(task) + return task + + def delete_task(self, task: Task) -> None: + """ + Deletes a task from the database. + """ + self.task_repository.delete_resource(task) diff --git a/tofu_api/api/tasks/task_views.py b/tofu_api/api/tasks/task_views.py new file mode 100644 index 0000000..3f9a2c3 --- /dev/null +++ b/tofu_api/api/tasks/task_views.py @@ -0,0 +1,89 @@ +from flask import jsonify +from validataclass.validators import DataclassValidator + +from tofu_api.common.rest import BaseMethodView +from .task_handler import TaskHandler +from .validators import TaskCreateData, TaskUpdateData + + +class TaskBaseView(BaseMethodView): + """ + Base class for view classes for the `/tasks` endpoint. + """ + + task_handler: TaskHandler + + def __init__(self, *, task_handler: TaskHandler): + self.task_handler = task_handler + + +class TaskCollectionView(TaskBaseView): + """ + View class for `/api/tasks` endpoint. + """ + + # Validators + task_create_validator = DataclassValidator(TaskCreateData) + + def get(self): + """ + Get list of all tasks. + """ + task_list = self.task_handler.fetch_all_tasks() + return jsonify({ + 'items': [task.to_dict() for task in task_list], + 'total_count': len(task_list), + }), 200 + + def post(self): + """ + Create a new task. + """ + # Parse request data + create_data: TaskCreateData = self.validate_request_data(self.task_create_validator) + + # Create new task + new_task = self.task_handler.create_task(create_data) + + # Return new task as JSON + return jsonify(new_task.to_dict()), 201 + + +class TaskItemView(TaskBaseView): + """ + View class for `/api/tasks/` endpoint. + """ + + # Validators + task_update_validator = DataclassValidator(TaskUpdateData) + + def get(self, task_id: int): + """ + Get a single task by ID. + """ + task = self.task_handler.fetch_task(task_id) + return jsonify(task.to_dict()), 200 + + def patch(self, task_id: int): + """ + Update a single task by ID. + """ + # Parse request data + update_data: TaskUpdateData = self.validate_request_data(self.task_update_validator) + + # Fetch task and update + task = self.task_handler.fetch_task(task_id) + task = self.task_handler.update_task(task, update_data) + + # Return updated task as JSON + return jsonify(task.to_dict()), 200 + + def delete(self, task_id: int): + """ + Delete a single task by ID. + """ + # Fetch task and delete + task = self.task_handler.fetch_task(task_id) + self.task_handler.delete_task(task) + + return self.empty_response() diff --git a/tofu_api/api/tasks/validators.py b/tofu_api/api/tasks/validators.py new file mode 100644 index 0000000..ca8f3c4 --- /dev/null +++ b/tofu_api/api/tasks/validators.py @@ -0,0 +1,23 @@ +from validataclass.dataclasses import Default, DefaultUnset, ValidataclassMixin, validataclass +from validataclass.helpers import OptionalUnset +from validataclass.validators import StringValidator + + +@validataclass +class TaskCreateData(ValidataclassMixin): + """ + Dataclass for "create task" request data. + """ + + title: str = StringValidator(min_length=1, max_length=200) + description: str = StringValidator(max_length=2000), Default('') + + +@validataclass +class TaskUpdateData(TaskCreateData): + """ + Dataclass for "update task" request data. + """ + + title: OptionalUnset[str] = DefaultUnset + description: OptionalUnset[str] = DefaultUnset diff --git a/tofu_api/app.py b/tofu_api/app.py index 86987e8..d8d8606 100644 --- a/tofu_api/app.py +++ b/tofu_api/app.py @@ -1,3 +1,4 @@ +import logging import os import sys import warnings @@ -6,11 +7,12 @@ from flask import Flask from tofu_api.api import TofuApiBlueprint from tofu_api.common.config import Config -from tofu_api.common.json import JSONEncoder +from tofu_api.common.json import JSONProvider +from tofu_api.common.rest import RestApiErrorHandler from tofu_api.dependencies import Dependencies # Enable deprecation warnings in dev environment -if not sys.warnoptions and os.getenv('FLASK_ENV') == 'development': +if not sys.warnoptions and os.getenv('FLASK_DEBUG'): warnings.filterwarnings('default', module='tofu_api.*') @@ -20,7 +22,7 @@ class App(Flask): """ # Override Flask classes config_class = Config - json_encoder = JSONEncoder + json_provider_class = JSONProvider # Set type hint for config config: Config @@ -42,6 +44,10 @@ class App(Flask): # Load app configuration from YAML file self.config.from_yaml(os.getenv('FLASK_CONFIG_FILE', default='config.yml')) + # Configure logging and error handling + self.configure_logging() + self.configure_error_handling() + # Initialize DI container self.dependencies = Dependencies() @@ -51,6 +57,24 @@ class App(Flask): # Register blueprints self.register_blueprint(TofuApiBlueprint(self)) + def configure_logging(self) -> None: + """ + Configures the logging system. + """ + logging.basicConfig( + level=logging.DEBUG if self.debug else logging.INFO, + format='{asctime}.{msecs:03.0f} {levelname:>8} [{name}] {message}', + datefmt='%Y-%m-%d %H:%M:%S', + style='{', + ) + + def configure_error_handling(self) -> None: + """ + Registers error handlers to the app. + """ + error_handler = RestApiErrorHandler(debug_mode=self.debug) + error_handler.register_error_handlers(self) + def init_database(self) -> None: """ Initialize database connection and models. diff --git a/tofu_api/common/exceptions/__init__.py b/tofu_api/common/exceptions/__init__.py new file mode 100644 index 0000000..96105e0 --- /dev/null +++ b/tofu_api/common/exceptions/__init__.py @@ -0,0 +1 @@ +from .base import AppException diff --git a/tofu_api/common/exceptions/base.py b/tofu_api/common/exceptions/base.py new file mode 100644 index 0000000..ee77f61 --- /dev/null +++ b/tofu_api/common/exceptions/base.py @@ -0,0 +1,23 @@ +from typing import Optional + + +class AppException(Exception): + """ + Base class for application specific exceptions that can also be used as API error responses. + """ + code: str = 'unspecified_error' + status_code: int = 400 + message: str + + def __init__(self, message: str, *, code: Optional[str] = None, status_code: Optional[int] = None): + if code is not None: + self.code = code + if status_code is not None: + self.status_code = status_code + self.message = message + + def to_dict(self) -> dict: + return { + 'code': self.code, + 'message': self.message, + } diff --git a/tofu_api/common/json/__init__.py b/tofu_api/common/json/__init__.py index b8f935f..89cb0d8 100644 --- a/tofu_api/common/json/__init__.py +++ b/tofu_api/common/json/__init__.py @@ -1 +1 @@ -from .json_encoder import JSONEncoder +from .json_provider import JSONProvider diff --git a/tofu_api/common/json/json_encoder.py b/tofu_api/common/json/json_provider.py similarity index 71% rename from tofu_api/common/json/json_encoder.py rename to tofu_api/common/json/json_provider.py index 11820bb..c95c957 100644 --- a/tofu_api/common/json/json_encoder.py +++ b/tofu_api/common/json/json_provider.py @@ -1,16 +1,16 @@ from datetime import datetime from typing import Any -from flask.json import JSONEncoder as _FlaskJSONEncoder +from flask.json.provider import DefaultJSONProvider __all__ = [ - 'JSONEncoder', + 'JSONProvider', ] -class JSONEncoder(_FlaskJSONEncoder): +class JSONProvider(DefaultJSONProvider): """ - Custom JSON encoder built on top of the Flask JSONEncoder class. + Custom JSON provider. """ def default(self, obj: Any) -> Any: @@ -25,5 +25,5 @@ class JSONEncoder(_FlaskJSONEncoder): if hasattr(obj, 'to_dict'): return obj.to_dict() - # Fallback to the Flask JSONEncoder + # Fallback to the default JSON provider return super().default(obj) diff --git a/tofu_api/common/rest/__init__.py b/tofu_api/common/rest/__init__.py index 91d7bc8..e3cfe4c 100644 --- a/tofu_api/common/rest/__init__.py +++ b/tofu_api/common/rest/__init__.py @@ -1 +1,3 @@ from .base_blueprint import BaseBlueprint +from .base_method_view import BaseMethodView +from .error_handler import RestApiErrorHandler diff --git a/tofu_api/common/rest/base_blueprint.py b/tofu_api/common/rest/base_blueprint.py index 9d46569..21753e6 100644 --- a/tofu_api/common/rest/base_blueprint.py +++ b/tofu_api/common/rest/base_blueprint.py @@ -1,16 +1,11 @@ from abc import ABC, abstractmethod -from typing import Callable, Type, TYPE_CHECKING +from typing import TYPE_CHECKING from flask import Blueprint -from flask.views import View if TYPE_CHECKING: from tofu_api.app import App -__all__ = [ - 'BaseBlueprint', -] - class BaseBlueprint(Blueprint, ABC): """ @@ -62,11 +57,3 @@ class BaseBlueprint(Blueprint, ABC): Register child blueprints and URL rules. """ raise NotImplementedError - - @staticmethod - def create_view_func(view_cls: Type[View], *args, **kwargs) -> Callable: - """ - Helper function to create a view function from a `View` class using `view_cls.as_view()`. - All arguments are passed to the constructor of the view class. - """ - return view_cls.as_view(view_cls.__name__, *args, **kwargs) diff --git a/tofu_api/common/rest/base_method_view.py b/tofu_api/common/rest/base_method_view.py new file mode 100644 index 0000000..ba9c0e7 --- /dev/null +++ b/tofu_api/common/rest/base_method_view.py @@ -0,0 +1,32 @@ +from typing import Any + +import flask +from flask.typing import RouteCallable +from flask.views import MethodView +from validataclass.validators import Validator + + +class BaseMethodView(MethodView): + """ + Base class for REST API views. + """ + + @property + def request(self) -> flask.Request: + return flask.request + + @classmethod + def as_view(cls, *args, **kwargs) -> RouteCallable: + return super().as_view(cls.__name__, *args, **kwargs) + + @staticmethod + def empty_response(code: int = 204) -> tuple[str, int]: + return '', code + + def validate_request_data(self, validator: Validator) -> Any: + """ + Parses request data as JSON and validates it using a validataclass validator. + """ + # TODO error handling: wrong content type; empty body; invalid json; validation errors + parsed_json = self.request.json + return validator.validate(parsed_json) diff --git a/tofu_api/common/rest/error_handler.py b/tofu_api/common/rest/error_handler.py new file mode 100644 index 0000000..2b2350b --- /dev/null +++ b/tofu_api/common/rest/error_handler.py @@ -0,0 +1,73 @@ +import logging +from typing import Union + +from flask import Flask, Response, jsonify +from werkzeug.exceptions import HTTPException +from werkzeug.http import HTTP_STATUS_CODES + +from tofu_api.common import string_utils +from tofu_api.common.exceptions import AppException +from .exceptions import InternalServerError + +T_Response = Union[Response, tuple[Response, int]] + + +class RestApiErrorHandler: + """ + Error handler class for REST API errors. + """ + + # Dependencies + logger: logging.Logger + + # Options + debug_mode: bool = False + + # Lookup table for HTTP status codes to API error codes + _http_to_api_error_codes: dict[int, str] + + def __init__(self, *, debug_mode: bool = False): + self.logger = logging.getLogger(type(self).__name__) + self.debug_mode = debug_mode + + # Generate lookup table for HTTP status codes + self._http_to_api_error_codes = { + http_status: string_utils.str_to_snake_case(name) + for http_status, name in HTTP_STATUS_CODES.items() + } + + def register_error_handlers(self, app: Flask) -> None: + """ + Registers error handlers for different types of exceptions to the app. + """ + app.register_error_handler(AppException, self.handle_app_exception) + app.register_error_handler(HTTPException, self.handle_http_exception) + app.register_error_handler(Exception, self.handle_generic_exception) + + @staticmethod + def handle_app_exception(exception: AppException) -> T_Response: + """ + Handles exceptions of type `AppException` that were not handled by any more specific handler. + """ + return jsonify(exception.to_dict()), exception.status_code + + def handle_http_exception(self, exception: HTTPException) -> T_Response: + """ + Handles exceptions of type `HTTPException`, i.e. any werkzeug HTTP exceptions. + """ + if exception.code >= 500: + self.logger.exception('HTTP exception with status code %s: %s', exception.code, type(exception).__name__) + + return jsonify({ + 'code': self._http_to_api_error_codes.get(exception.code, 'unknown_http_error'), + 'message': exception.description, + }), exception.code + + def handle_generic_exception(self, exception: Exception) -> T_Response: + """ + Fallback handler for any exceptions not handled by any other handler. + """ + self.logger.exception('Uncaught exception: %s', type(exception).__name__) + + wrapped_exception = InternalServerError('There was an uncaught error on the server.', inner_exception=exception) + return jsonify(wrapped_exception.to_dict(debug=self.debug_mode)), wrapped_exception.status_code diff --git a/tofu_api/common/rest/exceptions.py b/tofu_api/common/rest/exceptions.py new file mode 100644 index 0000000..ae54727 --- /dev/null +++ b/tofu_api/common/rest/exceptions.py @@ -0,0 +1,30 @@ +__all__ = [ + 'InternalServerError' +] + +import traceback +from typing import Optional + +from tofu_api.common.exceptions import AppException + + +class InternalServerError(AppException): + """ + Wrapper exception for any uncaught exception. + """ + status_code = 500 + code = 'internal_server_error' + inner_exception: Optional[Exception] = None + + def __init__(self, message: str, *, inner_exception: Optional[Exception] = None): + super().__init__(message) + self.inner_exception = inner_exception + + def to_dict(self, *, debug: bool = False) -> dict: + data = super().to_dict() + if debug: + data['_debug'] = { + 'exception': str(self.inner_exception), + 'traceback': traceback.format_exception(self.inner_exception), + } + return data diff --git a/tofu_api/common/string_utils.py b/tofu_api/common/string_utils.py new file mode 100644 index 0000000..c15e95c --- /dev/null +++ b/tofu_api/common/string_utils.py @@ -0,0 +1,28 @@ +__all__ = [ + 'SNAKE_CASE_CHARACTERS', + 'is_snake_case', + 'str_to_snake_case', +] + +import string + +SNAKE_CASE_CHARACTERS = string.ascii_lowercase + string.digits + '_' + + +def is_snake_case(input_str: str) -> bool: + """ + Returns True if the input string only consists of snake case characters (lowercase letters, digits, underscore). + """ + return all(c in SNAKE_CASE_CHARACTERS for c in input_str) + + +def str_to_snake_case(input_str: str) -> str: + """ + Converts any string to a snake case string: Whitespaces are replaced with an underscore, uppercase letters are + converted to lowercase, and any non-alphanumeric character is removed. + """ + # First, lowercase string and replace any consecutive whitespaces with a single underscore + almost_snake_case = '_'.join(input_str.lower().split()) + + # Now, remove all characters that are neither letters, digits, nor underscores + return ''.join(filter(lambda c: c in SNAKE_CASE_CHARACTERS, almost_snake_case)) diff --git a/tofu_api/dependencies.py b/tofu_api/dependencies.py index b2698c6..59c11b5 100644 --- a/tofu_api/dependencies.py +++ b/tofu_api/dependencies.py @@ -1,6 +1,26 @@ +from typing import TypeVar + from sqlalchemy.orm import Session +from tofu_api.api.tasks import TaskHandler from tofu_api.common.database import SQLAlchemy +from tofu_api.repositories import TaskRepository + +T_Dep_Callable = TypeVar('T_Dep_Callable') + + +def cache_dependency(func: T_Dep_Callable) -> T_Dep_Callable: + """ + Decorator to be used in `Dependencies` to cache dependencies inside the Dependencies instance. + """ + dep_name = func.__name__ + + def wrapped_func(self: 'Dependencies'): + if dep_name not in self._dependency_cache: + self._dependency_cache[dep_name] = func(self) + return self._dependency_cache.get(dep_name) + + return wrapped_func class Dependencies: @@ -15,10 +35,22 @@ class Dependencies: # Database dependencies + @cache_dependency def get_sqlalchemy(self) -> SQLAlchemy: - if SQLAlchemy not in self._dependency_cache: - self._dependency_cache[SQLAlchemy] = SQLAlchemy() - return self._dependency_cache[SQLAlchemy] + return SQLAlchemy() + # No caching necessary here def get_db_session(self) -> Session: return self.get_sqlalchemy().session + + # Repository classes + + @cache_dependency + def get_task_repository(self) -> TaskRepository: + return TaskRepository(session=self.get_db_session()) + + # API Handler classes + + @cache_dependency + def get_task_handler(self) -> TaskHandler: + return TaskHandler(task_repository=self.get_task_repository()) diff --git a/tofu_api/models/base.py b/tofu_api/models/base.py index 342f9b3..b063539 100644 --- a/tofu_api/models/base.py +++ b/tofu_api/models/base.py @@ -1,16 +1,17 @@ -from typing import Any, Iterable, Optional - -from sqlalchemy import Column, Integer, inspect -from sqlalchemy.orm import InstanceState, as_declarative - -from tofu_api.common.database import Col, MetaData - __all__ = [ 'BaseModel', ] +from typing import Any, Iterable, Optional, Union -@as_declarative(name='BaseModel', metadata=MetaData()) +from sqlalchemy import Column, Integer, inspect +from sqlalchemy.orm import InstanceState, as_declarative +from validataclass.dataclasses import ValidataclassMixin + +from tofu_api.common.database import Col, MetaData + + +@as_declarative(metadata=MetaData()) class BaseModel: """ Declarative base class for database models. @@ -46,9 +47,9 @@ class BaseModel: """ Return the object's data as a dictionary. - By default, the dictionary will contain all table columns (with their column name as key) defined in the model. This can be - overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are listed in `fields` will be - included in the dictionary, except for fields listed in `exclude`. + By default, the dictionary will contain all table columns (with their column name as key) defined in the model. + This can be overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are + listed in `fields` will be included in the dictionary, except for fields listed in `exclude`. """ # Determine fields to include in dictionary (starting will all table columns) included_fields = set(column.name for column in self.__table__.columns) @@ -60,3 +61,15 @@ class BaseModel: return { field: getattr(self, field) for field in included_fields } + + def update_from(self, data: Union[dict, ValidataclassMixin]) -> None: + """ + Updates the object with data from either a dictionary or a validataclass object (requires the ValidataclassMixin). + """ + if isinstance(data, ValidataclassMixin): + data = data.to_dict() + + # TODO: Is it a good idea to just iterate over data and setattr? Or should we check __table__.columns? + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) diff --git a/tofu_api/repositories/__init__.py b/tofu_api/repositories/__init__.py new file mode 100644 index 0000000..2ee789a --- /dev/null +++ b/tofu_api/repositories/__init__.py @@ -0,0 +1,2 @@ +from .base_repository import BaseRepository +from .task_repository import TaskRepository diff --git a/tofu_api/repositories/base_repository.py b/tofu_api/repositories/base_repository.py new file mode 100644 index 0000000..d8dae41 --- /dev/null +++ b/tofu_api/repositories/base_repository.py @@ -0,0 +1,94 @@ +__all__ = [ + 'BaseRepository', + 'T_Model', +] + +from abc import ABC, abstractmethod +from typing import Generic, Optional, Type, TypeVar + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from tofu_api.models import BaseModel +from .exceptions import ObjectNotFoundException + +T_Model = TypeVar('T_Model', bound=BaseModel) + + +class BaseRepository(Generic[T_Model], ABC): + """ + Base class for repositories. + """ + + # Database session + session: Session + + @property + @abstractmethod + def model_cls(self) -> Type[T_Model]: + """ + Set this to the model class. + """ + raise NotImplementedError + + def __init__(self, *, session: Session): + self.session = session + + @staticmethod + def _or_raise(resource: Optional[T_Model], exception_msg: Optional[str] = None) -> T_Model: + if resource is None: + raise ObjectNotFoundException(exception_msg) + return resource + + def fetch_by_id(self, resource_id: int) -> T_Model: + """ + Fetches a resource by ID. + + Raises an ObjectNotFoundException if no resource with the ID was found. + """ + resource = self.session.get(self.model_cls, resource_id) + return self._or_raise(resource, f'Resource with ID {resource_id} was not found.') + + def fetch_all(self) -> list[T_Model]: + """ + Fetches all resources of the repository type. + """ + return self.session.scalars( + select(self.model_cls) + ).all() + + def commit_session(self) -> None: + """ + Commits the current database session. + """ + self.commit_session() + + def rollback_session(self) -> None: + """ + Rolls back the current database session. + """ + self.rollback_session() + + def save_resource(self, *resources: T_Model, commit: bool = True) -> None: + """ + Saves one or multiple resources to the database by adding them to the session and committing the session. + Set `commit` to False to skip committing (the session will still be flushed, though). + """ + for resource in resources: + self.session.add(resource) + self.session.flush() + + if commit: + self.session.commit() + + def delete_resource(self, *resources: T_Model, commit: bool = True) -> None: + """ + Deletes one or multiple resources from the database. + Set `commit` to False to skip committing (the session will still be flushed, though). + """ + for resource in resources: + self.session.delete(resource) + self.session.flush() + + if commit: + self.session.commit() diff --git a/tofu_api/repositories/exceptions.py b/tofu_api/repositories/exceptions.py new file mode 100644 index 0000000..b3e5919 --- /dev/null +++ b/tofu_api/repositories/exceptions.py @@ -0,0 +1,9 @@ +from tofu_api.common.exceptions import AppException + + +class ObjectNotFoundException(AppException): + """ + Exception raised when a database object was not found, i.e. does not exist or is inaccessible for the user. + """ + status_code = 404 + code = 'not_found' diff --git a/tofu_api/repositories/task_repository.py b/tofu_api/repositories/task_repository.py new file mode 100644 index 0000000..0be0741 --- /dev/null +++ b/tofu_api/repositories/task_repository.py @@ -0,0 +1,9 @@ +from tofu_api.models import Task +from .base_repository import BaseRepository + + +class TaskRepository(BaseRepository[Task]): + """ + Repository for tasks. + """ + model_cls = Task