Compare commits

...

2 Commits

30 changed files with 766 additions and 262 deletions

View File

@ -8,13 +8,16 @@ RUN echo Creating dev user with UID $DEV_USER_UID && \
USER dev USER dev
ENV PATH="/home/dev/.local/bin:${PATH}" ENV PATH="/home/dev/.local/bin:${PATH}"
# Don't write bytecode cache files
ENV PYTHONDONTWRITEBYTECODE=1
# Install pipenv to generate requirements.txt from Pipfile # Install pipenv to generate requirements.txt from Pipfile
RUN pip install pipenv RUN pip install pipenv
# Generate requirements.txt and install dependencies with pip # Generate requirements.txt and install dependencies with pip
WORKDIR /app WORKDIR /app
COPY Pipfile Pipfile.lock ./ 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 pip install -r tmp_requirements.txt
# Set default command # Set default command

View File

@ -6,12 +6,13 @@ name = "pypi"
[packages] [packages]
gunicorn = "~=20.1" gunicorn = "~=20.1"
werkzeug = "~=2.0" werkzeug = "~=2.0"
flask = "~=2.0" flask = "~=2.2"
pyyaml = "*" pyyaml = "*"
sqlalchemy = "~=1.4" sqlalchemy = "~=1.4"
pymysql = "*" pymysql = "*"
alembic = "~=1.7" alembic = "~=1.7"
python-dateutil = "*" python-dateutil = "*"
validataclass = "~=0.6"
[dev-packages] [dev-packages]

249
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "f63a254c353bc5c8e6cbe2a920f8402ba0da5bec775bfff91f64fa485e9bf95c" "sha256": "84613c292abfb9f596faadd525137db1053dfe39d650638455c11ac684a91487"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -18,88 +18,87 @@
"default": { "default": {
"alembic": { "alembic": {
"hashes": [ "hashes": [
"sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b", "sha256:0a024d7f2de88d738d7395ff866997314c837be6104e90c5724350313dee4da4",
"sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58" "sha256:cd0b5e45b14b706426b833f06369b9a6d5ee03f826ec3238723ce8caaf6e5ffa"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.7.7" "version": "==1.8.1"
}, },
"click": { "click": {
"hashes": [ "hashes": [
"sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e", "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
"sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72" "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==8.1.2" "version": "==8.1.3"
}, },
"flask": { "flask": {
"hashes": [ "hashes": [
"sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264", "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b",
"sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8" "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.1.1" "version": "==2.2.2"
}, },
"greenlet": { "greenlet": {
"hashes": [ "hashes": [
"sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3", "sha256:0118817c9341ef2b0f75f5af79ac377e4da6ff637e5ee4ac91802c0e379dadb4",
"sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711", "sha256:048d2bed76c2aa6de7af500ae0ea51dd2267aec0e0f2a436981159053d0bc7cc",
"sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd", "sha256:07c58e169bbe1e87b8bbf15a5c1b779a7616df9fd3e61cadc9d691740015b4f8",
"sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073", "sha256:095a980288fe05adf3d002fbb180c99bdcf0f930e220aa66fcd56e7914a38202",
"sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708", "sha256:0b181e9aa6cb2f5ec0cacc8cee6e5a3093416c841ba32c185c30c160487f0380",
"sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67", "sha256:1626185d938d7381631e48e6f7713e8d4b964be246073e1a1d15c2f061ac9f08",
"sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23", "sha256:184416e481295832350a4bf731ba619a92f5689bf5d0fa4341e98b98b1265bd7",
"sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1", "sha256:1dd51d2650e70c6c4af37f454737bf4a11e568945b27f74b471e8e2a9fd21268",
"sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08", "sha256:1ec2779774d8e42ed0440cf8bc55540175187e8e934f2be25199bf4ed948cd9e",
"sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd", "sha256:2cf45e339cabea16c07586306a31cfcc5a3b5e1626d365714d283732afed6809",
"sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2", "sha256:2fb0aa7f6996879551fd67461d5d3ab0c3c0245da98be90c89fcb7a18d437403",
"sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa", "sha256:44b4817c34c9272c65550b788913620f1fdc80362b209bc9d7dd2f40d8793080",
"sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8", "sha256:466ce0928e33421ee84ae04c4ac6f253a3a3e6b8d600a79bd43fd4403e0a7a76",
"sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40", "sha256:4f166b4aca8d7d489e82d74627a7069ab34211ef5ebb57c300ec4b9337b60fc0",
"sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab", "sha256:510c3b15587afce9800198b4b142202b323bf4b4b5f9d6c79cb9a35e5e3c30d2",
"sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6", "sha256:5b756e6730ea59b2745072e28ad27f4c837084688e6a6b3633c8b1e509e6ae0e",
"sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc", "sha256:5fbe1ab72b998ca77ceabbae63a9b2e2dc2d963f4299b9b278252ddba142d3f1",
"sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b", "sha256:6200a11f003ec26815f7e3d2ded01b43a3810be3528dd760d2f1fa777490c3cd",
"sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e", "sha256:65ad1a7a463a2a6f863661329a944a5802c7129f7ad33583dcc11069c17e622c",
"sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963", "sha256:694ffa7144fa5cc526c8f4512665003a39fa09ef00d19bbca5c8d3406db72fbe",
"sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3", "sha256:6f5d4b2280ceea76c55c893827961ed0a6eadd5a584a7c4e6e6dd7bc10dfdd96",
"sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d", "sha256:7532a46505470be30cbf1dbadb20379fb481244f1ca54207d7df3bf0bbab6a20",
"sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d", "sha256:76a53bfa10b367ee734b95988bd82a9a5f0038a25030f9f23bbbc005010ca600",
"sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe", "sha256:77e41db75f9958f2083e03e9dd39da12247b3430c92267df3af77c83d8ff9eed",
"sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28", "sha256:7a43bbfa9b6cfdfaeefbd91038dde65ea2c421dc387ed171613df340650874f2",
"sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3", "sha256:7b41d19c0cfe5c259fe6c539fd75051cd39a5d33d05482f885faf43f7f5e7d26",
"sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e", "sha256:7c5227963409551ae4a6938beb70d56bf1918c554a287d3da6853526212fbe0a",
"sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c", "sha256:870a48007872d12e95a996fca3c03a64290d3ea2e61076aa35d3b253cf34cd32",
"sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d", "sha256:88b04e12c9b041a1e0bcb886fec709c488192638a9a7a3677513ac6ba81d8e79",
"sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0", "sha256:8c287ae7ac921dfde88b1c125bd9590b7ec3c900c2d3db5197f1286e144e712b",
"sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497", "sha256:903fa5716b8fbb21019268b44f73f3748c41d1a30d71b4a49c84b642c2fed5fa",
"sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee", "sha256:9537e4baf0db67f382eb29255a03154fcd4984638303ff9baaa738b10371fa57",
"sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713", "sha256:9951dcbd37850da32b2cb6e391f621c1ee456191c6ae5528af4a34afe357c30e",
"sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58", "sha256:9b2f7d0408ddeb8ea1fd43d3db79a8cefaccadd2a812f021333b338ed6b10aba",
"sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a", "sha256:9c88e134d51d5e82315a7c32b914a58751b7353eb5268dbd02eabf020b4c4700",
"sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06", "sha256:9fae214f6c43cd47f7bef98c56919b9222481e833be2915f6857a1e9e8a15318",
"sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88", "sha256:a3a669f11289a8995d24fbfc0e63f8289dd03c9aaa0cc8f1eab31d18ca61a382",
"sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965", "sha256:aa741c1a8a8cc25eb3a3a01a62bdb5095a773d8c6a86470bde7f607a447e7905",
"sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f", "sha256:b0877a9a2129a2c56a2eae2da016743db7d9d6a05d5e1c198f1b7808c602a30e",
"sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4", "sha256:bcb6c6dd1d6be6d38d6db283747d07fda089ff8c559a835236560a4410340455",
"sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5", "sha256:caff52cb5cd7626872d9696aee5b794abe172804beb7db52eed1fd5824b63910",
"sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c", "sha256:cbc1eb55342cbac8f7ec159088d54e2cfdd5ddf61c87b8bbe682d113789331b2",
"sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a", "sha256:cd16a89efe3a003029c87ff19e9fba635864e064da646bc749fc1908a4af18f3",
"sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1", "sha256:ce5b64dfe8d0cca407d88b0ee619d80d4215a2612c1af8c98a92180e7109f4b5",
"sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43", "sha256:d58a5a71c4c37354f9e0c24c9c8321f0185f6945ef027460b809f4bb474bfe41",
"sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627", "sha256:db41f3845eb579b544c962864cce2c2a0257fe30f0f1e18e51b1e8cbb4e0ac6d",
"sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b", "sha256:db5b25265010a1b3dca6a174a443a0ed4c4ab12d5e2883a11c97d6e6d59b12f9",
"sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168", "sha256:dd0404d154084a371e6d2bafc787201612a1359c2dee688ae334f9118aa0bf47",
"sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d", "sha256:de431765bd5fe62119e0bc6bc6e7b17ac53017ae1782acf88fcf6b7eae475a49",
"sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5", "sha256:df02fdec0c533301497acb0bc0f27f479a3a63dcdc3a099ae33a902857f07477",
"sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478", "sha256:e8533f5111704d75de3139bf0b8136d3a6c1642c55c067866fa0a51c2155ee33",
"sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf", "sha256:f2f908239b7098799b8845e5936c2ccb91d8c2323be02e82f8dcb4a80dcf4a25",
"sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce", "sha256:f8bfd36f368efe0ab2a6aa3db7f14598aac454b06849fb633b762ddbede1db90",
"sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c", "sha256:ffe73f9e7aea404722058405ff24041e59d31ca23d1da0895af48050a07b6932"
"sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"
], ],
"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')))))", "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": { "gunicorn": {
"hashes": [ "hashes": [
@ -119,19 +118,19 @@
}, },
"jinja2": { "jinja2": {
"hashes": [ "hashes": [
"sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119", "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852",
"sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9" "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.1.1" "version": "==3.1.2"
}, },
"mako": { "mako": {
"hashes": [ "hashes": [
"sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba", "sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f",
"sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39" "sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==1.2.0" "version": "==1.2.2"
}, },
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
@ -197,6 +196,7 @@
}, },
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf",
"sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
"sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
"sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
@ -208,26 +208,32 @@
"sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
"sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
"sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
"sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782",
"sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
"sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
"sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
"sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
"sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
"sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1",
"sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
"sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
"sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
"sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
"sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
"sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
"sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d",
"sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
"sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
"sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7",
"sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
"sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
"sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
"sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358",
"sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
"sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
"sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
"sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
"sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f",
"sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
"sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
], ],
@ -236,11 +242,11 @@
}, },
"setuptools": { "setuptools": {
"hashes": [ "hashes": [
"sha256:26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8", "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82",
"sha256:47c7b0c0f8fc10eec4cf1e71c6fdadf8decaa74ffa087e68cd1c20db7ad6a592" "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==62.1.0" "version": "==65.3.0"
}, },
"six": { "six": {
"hashes": [ "hashes": [
@ -252,53 +258,66 @@
}, },
"sqlalchemy": { "sqlalchemy": {
"hashes": [ "hashes": [
"sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6", "sha256:0002e829142b2af00b4eaa26c51728f3ea68235f232a2e72a9508a3116bd6ed0",
"sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b", "sha256:0005bd73026cd239fc1e8ccdf54db58b6193be9a02b3f0c5983808f84862c767",
"sha256:1b4eac3933c335d7f375639885765722534bb4e52e51cdc01a667eea822af9b6", "sha256:0292f70d1797e3c54e862e6f30ae474014648bc9c723e14a2fda730adb0a9791",
"sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e", "sha256:036d8472356e1d5f096c5e0e1a7e0f9182140ada3602f8fff6b7329e9e7cfbcd",
"sha256:28aa2ef06c904729620cc735262192e622db9136c26d8587f71f29ec7715628a", "sha256:05f0de3a1dc3810a776275763764bb0015a02ae0f698a794646ebc5fb06fad33",
"sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b", "sha256:0990932f7cca97fece8017414f57fdd80db506a045869d7ddf2dda1d7cf69ecc",
"sha256:2c6c411d8c59afba95abccd2b418f30ade674186660a2d310d364843049fb2c1", "sha256:13e397a9371ecd25573a7b90bd037db604331cf403f5318038c46ee44908c44d",
"sha256:2ffc813b01dc6473990f5e575f210ca5ac2f5465ace3908b78ffd6d20058aab5", "sha256:14576238a5f89bcf504c5f0a388d0ca78df61fb42cb2af0efe239dc965d4f5c9",
"sha256:48036698f20080462e981b18d77d574631a3d1fc2c33b416c6df299ec1d10b99", "sha256:199a73c31ac8ea59937cc0bf3dfc04392e81afe2ec8a74f26f489d268867846c",
"sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4", "sha256:2082a2d2fca363a3ce21cfa3d068c5a1ce4bf720cf6497fb3a9fc643a8ee4ddd",
"sha256:4ba2c1f368bcf8551cdaa27eac525022471015633d5bdafbc4297e0511f62f51", "sha256:22ff16cedab5b16a0db79f1bc99e46a6ddececb60c396562e50aab58ddb2871c",
"sha256:53c7469b86a60fe2babca4f70111357e6e3d5150373bc85eb3b914356983e89a", "sha256:2307495d9e0ea00d0c726be97a5b96615035854972cc538f6e7eaed23a35886c",
"sha256:6204d06bfa85f87625e1831ca663f9dba91ac8aec24b8c65d02fb25cbaf4b4d7", "sha256:2ad2b727fc41c7f8757098903f85fafb4bf587ca6605f82d9bf5604bd9c7cded",
"sha256:63c82c9e8ccc2fb4bfd87c24ffbac320f70b7c93b78f206c1f9c441fa3013a5f", "sha256:2d6495f84c4fd11584f34e62f9feec81bf373787b3942270487074e35cbe5330",
"sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157", "sha256:361f6b5e3f659e3c56ea3518cf85fbdae1b9e788ade0219a67eeaaea8a4e4d2a",
"sha256:95411abc0e36d18f54fa5e24d42960ea3f144fb16caaa5a8c2e492b5424cc82c", "sha256:3e2ef592ac3693c65210f8b53d0edcf9f4405925adcfc031ff495e8d18169682",
"sha256:9837133b89ad017e50a02a3b46419869cf4e9aa02743e911b2a9e25fa6b05403", "sha256:4676d51c9f6f6226ae8f26dc83ec291c088fe7633269757d333978df78d931ab",
"sha256:9bec63b1e20ef69484f530fb4b4837e050450637ff9acd6dccc7003c5013abf8", "sha256:4ba7e122510bbc07258dc42be6ed45997efdf38129bde3e3f12649be70683546",
"sha256:9d8edfb09ed2b865485530c13e269833dab62ab2d582fde21026c9039d4d0e62", "sha256:5102fb9ee2c258a2218281adcb3e1918b793c51d6c2b4666ce38c35101bb940e",
"sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d", "sha256:5323252be2bd261e0aa3f33cb3a64c45d76829989fa3ce90652838397d84197d",
"sha256:9e1a72197529ea00357640f21d92ffc7024e156ef9ac36edf271c8335facbc1a", "sha256:58bb65b3274b0c8a02cea9f91d6f44d0da79abc993b33bdedbfec98c8440175a",
"sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd", "sha256:59bdc291165b6119fc6cdbc287c36f7f2859e6051dd923bdf47b4c55fd2f8bd0",
"sha256:a4efb70a62cbbbc052c67dc66b5448b0053b509732184af3e7859d05fdf6223c", "sha256:5facb7fd6fa8a7353bbe88b95695e555338fb038ad19ceb29c82d94f62775a05",
"sha256:a5dbdbb39c1b100df4d182c78949158073ca46ba2850c64fe02ffb1eb5b70903", "sha256:639e1ae8d48b3c86ffe59c0daa9a02e2bfe17ca3d2b41611b30a0073937d4497",
"sha256:aeea6ace30603ca9a8869853bb4a04c7446856d7789e36694cd887967b7621f6", "sha256:8eb8897367a21b578b26f5713833836f886817ee2ffba1177d446fa3f77e67c8",
"sha256:b2489e70bfa2356f2d421106794507daccf6cc8711753c442fc97272437fc606", "sha256:90484a2b00baedad361402c257895b13faa3f01780f18f4a104a2f5c413e4536",
"sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f", "sha256:9c56e19780cd1344fcd362fd6265a15f48aa8d365996a37fab1495cae8fcd97d",
"sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041", "sha256:b67fc780cfe2b306180e56daaa411dd3186bf979d50a6a7c2a5b5036575cbdbb",
"sha256:cfd8e4c64c30a5219032e64404d468c425bdbc13b397da906fc9bee6591fc0dd", "sha256:c0dcf127bb99458a9d211e6e1f0f3edb96c874dd12f2503d4d8e4f1fd103790b",
"sha256:d17316100fcd0b6371ac9211351cb976fd0c2e12a859c1a57965e3ef7f3ed2bc", "sha256:c23d64a0b28fc78c96289ffbd0d9d1abd48d267269b27f2d34e430ea73ce4b26",
"sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70", "sha256:ccfd238f766a5bb5ee5545a62dd03f316ac67966a6a658efb63eeff8158a4bbf",
"sha256:da25e75ba9f3fabc271673b6b413ca234994e6d3453424bea36bb5549c5bbaec", "sha256:cd767cf5d7252b1c88fcfb58426a32d7bd14a7e4942497e15b68ff5d822b41ad",
"sha256:e255a8dd5572b0c66d6ee53597d36157ad6cf3bc1114f61c54a65189f996ab03", "sha256:ce8feaa52c1640de9541eeaaa8b5fb632d9d66249c947bb0d89dd01f87c7c288",
"sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044", "sha256:d2e054aed4645f9b755db85bc69fc4ed2c9020c19c8027976f66576b906a74f1",
"sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228", "sha256:e16c2be5cb19e2c08da7bd3a87fed2a0d4e90065ee553a940c4fc1a0fb1ab72b",
"sha256:effadcda9a129cc56408dd5b2ea20ee9edcea24bd58e6a1489fa27672d733182" "sha256:e4b12e3d88a8fffd0b4ca559f6d4957ed91bd4c0613a4e13846ab8729dc5c251",
"sha256:e570cfc40a29d6ad46c9aeaddbdcee687880940a3a327f2c668dd0e4ef0a441d",
"sha256:eb30cf008850c0a26b72bd1b9be6730830165ce049d239cfdccd906f2685f892",
"sha256:f37fa70d95658763254941ddd30ecb23fc4ec0c5a788a7c21034fc2305dab7cc",
"sha256:f5ebeeec5c14533221eb30bad716bc1fd32f509196318fb9caa7002c4a364e4c",
"sha256:f5fa526d027d804b1f85cdda1eb091f70bde6fb7d87892f6dd5a48925bc88898"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.4.35" "version": "==1.4.41"
},
"validataclass": {
"hashes": [
"sha256:154021e42be7168aa6465e3c8d05b7add3ac5a78a5c1b02f17efdf886c345d9e",
"sha256:db198754479aac855a62a4195f2ba6d0d0893cac10a949946566169ca92d4895"
],
"index": "pypi",
"version": "==0.6.2"
}, },
"werkzeug": { "werkzeug": {
"hashes": [ "hashes": [
"sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6", "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f",
"sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74" "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.1.1" "version": "==2.2.2"
} }
}, },
"develop": {} "develop": {}

View File

@ -0,0 +1,5 @@
{
"dev": {
"api_host": "http://localhost:5000"
}
}

28
api_tests/tasks.http Normal file
View File

@ -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

View File

@ -1,5 +1,5 @@
FLASK_APP=tofu_api.app FLASK_APP=tofu_api.app
FLASK_ENV=development FLASK_DEBUG=true
FLASK_CONFIG_FILE=config.dev.yml FLASK_CONFIG_FILE=config.dev.yml
# Show SQLAlchemy 2.0 deprecation warnings # Show SQLAlchemy 2.0 deprecation warnings

View File

@ -1,5 +1,5 @@
from tofu_api.common.rest import BaseBlueprint from tofu_api.common.rest import BaseBlueprint
from .tasks import TaskApiBlueprint from .tasks import TaskBlueprint
class TofuApiBlueprint(BaseBlueprint): class TofuApiBlueprint(BaseBlueprint):
@ -12,4 +12,4 @@ class TofuApiBlueprint(BaseBlueprint):
url_prefix = '/api' url_prefix = '/api'
def init_blueprint(self) -> None: def init_blueprint(self) -> None:
self.register_blueprint(TaskApiBlueprint(self.app)) self.register_blueprint(TaskBlueprint(self.app))

View File

@ -1 +1,2 @@
from .task_api import TaskApiBlueprint from .task_blueprint import TaskBlueprint
from .task_handler import TaskHandler

View File

@ -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(
'/<int:task_id>',
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/<int:task_id>` 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

View File

@ -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(
'/<int:task_id>',
view_func=TaskItemView.as_view(task_handler=task_handler),
methods=['GET', 'PATCH', 'DELETE'],
)

View File

@ -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)

View File

@ -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/<int:task_id>` 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()

View File

@ -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

View File

@ -1,3 +1,4 @@
import logging
import os import os
import sys import sys
import warnings import warnings
@ -6,11 +7,12 @@ from flask import Flask
from tofu_api.api import TofuApiBlueprint from tofu_api.api import TofuApiBlueprint
from tofu_api.common.config import Config 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 from tofu_api.dependencies import Dependencies
# Enable deprecation warnings in dev environment # 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.*') warnings.filterwarnings('default', module='tofu_api.*')
@ -20,7 +22,7 @@ class App(Flask):
""" """
# Override Flask classes # Override Flask classes
config_class = Config config_class = Config
json_encoder = JSONEncoder json_provider_class = JSONProvider
# Set type hint for config # Set type hint for config
config: Config config: Config
@ -42,6 +44,10 @@ class App(Flask):
# Load app configuration from YAML file # Load app configuration from YAML file
self.config.from_yaml(os.getenv('FLASK_CONFIG_FILE', default='config.yml')) 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 # Initialize DI container
self.dependencies = Dependencies() self.dependencies = Dependencies()
@ -51,6 +57,24 @@ class App(Flask):
# Register blueprints # Register blueprints
self.register_blueprint(TofuApiBlueprint(self)) 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: def init_database(self) -> None:
""" """
Initialize database connection and models. Initialize database connection and models.

View File

@ -0,0 +1 @@
from .base import AppException

View File

@ -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,
}

View File

@ -1 +1 @@
from .json_encoder import JSONEncoder from .json_provider import JSONProvider

View File

@ -1,16 +1,16 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from flask.json import JSONEncoder as _FlaskJSONEncoder from flask.json.provider import DefaultJSONProvider
__all__ = [ __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: def default(self, obj: Any) -> Any:
@ -25,5 +25,5 @@ class JSONEncoder(_FlaskJSONEncoder):
if hasattr(obj, 'to_dict'): if hasattr(obj, 'to_dict'):
return obj.to_dict() return obj.to_dict()
# Fallback to the Flask JSONEncoder # Fallback to the default JSON provider
return super().default(obj) return super().default(obj)

View File

@ -1 +1,3 @@
from .base_blueprint import BaseBlueprint from .base_blueprint import BaseBlueprint
from .base_method_view import BaseMethodView
from .error_handler import RestApiErrorHandler

View File

@ -1,16 +1,11 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Callable, Type, TYPE_CHECKING from typing import TYPE_CHECKING
from flask import Blueprint from flask import Blueprint
from flask.views import View
if TYPE_CHECKING: if TYPE_CHECKING:
from tofu_api.app import App from tofu_api.app import App
__all__ = [
'BaseBlueprint',
]
class BaseBlueprint(Blueprint, ABC): class BaseBlueprint(Blueprint, ABC):
""" """
@ -62,11 +57,3 @@ class BaseBlueprint(Blueprint, ABC):
Register child blueprints and URL rules. Register child blueprints and URL rules.
""" """
raise NotImplementedError 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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -1,6 +1,26 @@
from typing import TypeVar
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from tofu_api.api.tasks import TaskHandler
from tofu_api.common.database import SQLAlchemy 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: class Dependencies:
@ -15,10 +35,22 @@ class Dependencies:
# Database dependencies # Database dependencies
@cache_dependency
def get_sqlalchemy(self) -> SQLAlchemy: def get_sqlalchemy(self) -> SQLAlchemy:
if SQLAlchemy not in self._dependency_cache: return SQLAlchemy()
self._dependency_cache[SQLAlchemy] = SQLAlchemy()
return self._dependency_cache[SQLAlchemy]
# No caching necessary here
def get_db_session(self) -> Session: def get_db_session(self) -> Session:
return self.get_sqlalchemy().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())

View File

@ -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__ = [ __all__ = [
'BaseModel', '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: class BaseModel:
""" """
Declarative base class for database models. Declarative base class for database models.
@ -46,9 +47,9 @@ class BaseModel:
""" """
Return the object's data as a dictionary. 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 By default, the dictionary will contain all table columns (with their column name as key) defined in the model.
overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are listed in `fields` will be This can be overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are
included in the dictionary, except for fields listed in `exclude`. 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) # Determine fields to include in dictionary (starting will all table columns)
included_fields = set(column.name for column in self.__table__.columns) included_fields = set(column.name for column in self.__table__.columns)
@ -60,3 +61,15 @@ class BaseModel:
return { return {
field: getattr(self, field) for field in included_fields 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)

View File

@ -0,0 +1,2 @@
from .base_repository import BaseRepository
from .task_repository import TaskRepository

View File

@ -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()

View File

@ -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'

View File

@ -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