diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 224fcb0..aca6681 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -33,6 +33,7 @@ jobs: uses: docker/build-push-action@v2 with: push: true + file: prod.Dockerfile tags: ${{ secrets.DOCKER_REPO_API }} - name: Redeploy on Server diff --git a/Dockerfile b/Dockerfile index 0977f40..7f83aab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,22 @@ FROM python:3.8-slim +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONFAULTHANDLER 1 + # Let service stop gracefully STOPSIGNAL SIGQUIT # Copy project files into working directory WORKDIR /app -ADD . /app -# Install project dependencies -RUN pip install -U pipenv -RUN pipenv install --system --deploy +RUN apt-get update && apt-get install gcc -y + +COPY Pipfile Pipfile.lock ./ + +RUN pip install pipenv +RUN pipenv install --deploy --system + +ADD . /app # Run the API. -CMD python launch.py runserver --host 0.0.0.0 --port 5000 --initdb --debug +CMD python launch.py runserver --initdb --verbose --debug diff --git a/Pipfile b/Pipfile index 52f75cc..a546b23 100644 --- a/Pipfile +++ b/Pipfile @@ -6,15 +6,16 @@ verify_ssl = true [dev-packages] black = "*" flake8 = "*" +requests = "*" pre-commit = "*" pytest-asyncio = "*" [packages] -quart = "*" -quart-cors = "*" -postdb = "*" pyjwt = "*" +postdb = "*" aiohttp = "*" +fastapi = "*" +uvicorn = {extras = ["standard"], version = "*"} uvloop = {markers = "platform_system == 'linux'", version = "*"} [requires] @@ -24,4 +25,5 @@ python_version = "3.8" allow_prereleases = true [scripts] +test = "python -m pytest" lint = "pre-commit run --all-files" diff --git a/Pipfile.lock b/Pipfile.lock index 2c0fdf1..7b26f4d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d1098dc8f5225c3344a7c179385cf5c772f54642c1a7eafbd183f079f010f104" + "sha256": "2804a90e1720edb543650fa9af7cc7c0fb2e5bbdec33239b80cab491dc33d4b7" }, "pipfile-spec": 6, "requires": { @@ -16,55 +16,56 @@ ] }, "default": { - "aiofiles": { + "aiohttp": { "hashes": [ - "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27", - "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092" + "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe", + "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe", + "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5", + "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8", + "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd", + "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb", + "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c", + "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87", + "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0", + "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290", + "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5", + "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287", + "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde", + "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf", + "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8", + "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16", + "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf", + "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809", + "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213", + "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f", + "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013", + "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b", + "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9", + "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5", + "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb", + "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df", + "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4", + "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439", + "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f", + "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22", + "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f", + "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5", + "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970", + "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009", + "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc", + "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", + "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" ], - "version": "==0.6.0" + "index": "pypi", + "version": "==3.7.4.post0" }, - "aiohttp": { + "asgiref": { "hashes": [ - "sha256:119feb2bd551e58d83d1b38bfa4cb921af8ddedec9fad7183132db334c3133e0", - "sha256:16d0683ef8a6d803207f02b899c928223eb219111bd52420ef3d7a8aa76227b6", - "sha256:2eb3efe243e0f4ecbb654b08444ae6ffab37ac0ef8f69d3a2ffb958905379daf", - "sha256:2ffea7904e70350da429568113ae422c88d2234ae776519549513c8f217f58a9", - "sha256:40bd1b101b71a18a528ffce812cc14ff77d4a2a1272dfb8b11b200967489ef3e", - "sha256:418597633b5cd9639e514b1d748f358832c08cd5d9ef0870026535bd5eaefdd0", - "sha256:481d4b96969fbfdcc3ff35eea5305d8565a8300410d3d269ccac69e7256b1329", - "sha256:4c1bdbfdd231a20eee3e56bd0ac1cd88c4ff41b64ab679ed65b75c9c74b6c5c2", - "sha256:5563ad7fde451b1986d42b9bb9140e2599ecf4f8e42241f6da0d3d624b776f40", - "sha256:58c62152c4c8731a3152e7e650b29ace18304d086cb5552d317a54ff2749d32a", - "sha256:5b50e0b9460100fe05d7472264d1975f21ac007b35dcd6fd50279b72925a27f4", - "sha256:5d84ecc73141d0a0d61ece0742bb7ff5751b0657dab8405f899d3ceb104cc7de", - "sha256:5dde6d24bacac480be03f4f864e9a67faac5032e28841b00533cd168ab39cad9", - "sha256:5e91e927003d1ed9283dee9abcb989334fc8e72cf89ebe94dc3e07e3ff0b11e9", - "sha256:62bc216eafac3204877241569209d9ba6226185aa6d561c19159f2e1cbb6abfb", - "sha256:6c8200abc9dc5f27203986100579fc19ccad7a832c07d2bc151ce4ff17190076", - "sha256:6ca56bdfaf825f4439e9e3673775e1032d8b6ea63b8953d3812c71bd6a8b81de", - "sha256:71680321a8a7176a58dfbc230789790639db78dad61a6e120b39f314f43f1907", - "sha256:7c7820099e8b3171e54e7eedc33e9450afe7cd08172632d32128bd527f8cb77d", - "sha256:7dbd087ff2f4046b9b37ba28ed73f15fd0bc9f4fdc8ef6781913da7f808d9536", - "sha256:822bd4fd21abaa7b28d65fc9871ecabaddc42767884a626317ef5b75c20e8a2d", - "sha256:8ec1a38074f68d66ccb467ed9a673a726bb397142c273f90d4ba954666e87d54", - "sha256:950b7ef08b2afdab2488ee2edaff92a03ca500a48f1e1aaa5900e73d6cf992bc", - "sha256:99c5a5bf7135607959441b7d720d96c8e5c46a1f96e9d6d4c9498be8d5f24212", - "sha256:b84ad94868e1e6a5e30d30ec419956042815dfaea1b1df1cef623e4564c374d9", - "sha256:bc3d14bf71a3fb94e5acf5bbf67331ab335467129af6416a437bd6024e4f743d", - "sha256:c2a80fd9a8d7e41b4e38ea9fe149deed0d6aaede255c497e66b8213274d6d61b", - "sha256:c44d3c82a933c6cbc21039326767e778eface44fca55c65719921c4b9661a3f7", - "sha256:cc31e906be1cc121ee201adbdf844522ea3349600dd0a40366611ca18cd40e81", - "sha256:d5d102e945ecca93bcd9801a7bb2fa703e37ad188a2f81b1e65e4abe4b51b00c", - "sha256:dd7936f2a6daa861143e376b3a1fb56e9b802f4980923594edd9ca5670974895", - "sha256:dee68ec462ff10c1d836c0ea2642116aba6151c6880b688e56b4c0246770f297", - "sha256:e76e78863a4eaec3aee5722d85d04dcbd9844bc6cd3bfa6aa880ff46ad16bfcb", - "sha256:eab51036cac2da8a50d7ff0ea30be47750547c9aa1aa2cf1a1b710a1827e7dbe", - "sha256:f4496d8d04da2e98cc9133e238ccebf6a13ef39a93da2e87146c8c8ac9768242", - "sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0", - "sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2" + "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", + "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" ], - "index": "pypi", - "version": "==3.7.4" + "markers": "python_version >= '3.6'", + "version": "==3.3.4" }, "async-timeout": { "hashes": [ @@ -76,175 +77,99 @@ }, "asyncpg": { "hashes": [ - "sha256:062e4ff80e68fe56066c44a8c51989a98785904bf86f49058a242a5887be6ce3", - "sha256:0f4604a88386d68c46bf7b50c201a9718515b0d2df6d5e9ce024d78ed0f7189c", - "sha256:1bbe5e829de506c743cbd5240b3722e487c53669a5f1e159abcc3b92a64a985e", - "sha256:1d3efdec14f3fbcc665b77619f8b420564f98b89632a21694be2101dafa6bcf2", - "sha256:1f514b13bc54bde65db6cd1d0832ae27f21093e3cb66f741e078fab77768971c", - "sha256:2cb730241dfe650b9626eae00490cca4cfeb00871ed8b8f389f3a4507b328683", - "sha256:2e3875c82ae609b21e562e6befdc35e52c4290e49d03e7529275d59a0595ca97", - "sha256:348ad471d9bdd77f0609a00c860142f47c81c9123f4064d13d65c8569415d802", - "sha256:3af9a8511569983481b5cf94db17b7cbecd06b5398aac9c82e4acb69bb1f4090", - "sha256:82e23ba5b37c0c7ee96f290a95cbf9815b2d29b302e8b9c4af1de9b7759fd27b", - "sha256:b37efafbbec505287bd1499a88f4b59ff2b470709a1d8f7e4db198d3e2c5a2c4", - "sha256:ccd75cfb4710c7e8debc19516e2e1d4c9863cce3f7a45a3822980d04b16f4fdd", - "sha256:d1cb6e5b58a4e017335f2a1886e153a32bd213ffa9f7129ee5aced2a7210fa3c", - "sha256:e7a67fb0244e4a5b3baaa40092d0efd642da032b5e891d75947dab993b47d925", - "sha256:f1df7cfd12ef484210717e7827cc2d4d550b16a1b4dd4566c93914c7a2259352" + "sha256:11102ac2febbc208427f39e4555537ecf188bd70ef7b285fc92c6c16b748b4c6", + "sha256:255839c8c52ebd72d6d0159564d7eb8f70fcf6cc9ce7cdc7e98328fd3279bf52", + "sha256:2710b5740cbd572e0fddc20986a44707f05d3f84e29fab72abe87fb8c2fc6885", + "sha256:43c44d323c3bd6514fbe6a892ccfdc551259bd92e98dd34ad1a52bad8c7974f3", + "sha256:812dafa4c9e264d430adcc0f5899f0dc5413155a605088af696f952d72d36b5e", + "sha256:98bef539326408da0c2ed0714432e4c79e345820697914318013588ff235b581", + "sha256:a19429d480a387346ae74b38da20e8da004337f14e5066f4bd6a10a8bbe74d3c", + "sha256:a2031df7573c80186339039cc2c4e684648fea5eaa9537c24f18c509bda2cd3f", + "sha256:a88654ede00596a7bdaa08066ff0505aed491f790621dcdb478066c7ddfd1a3d", + "sha256:b784138e69752aaa905b60c5a07a891445706824358fe1440d47113db72c8946", + "sha256:bd6e1f3db9889b5d987b6a1cab49c5b5070756290f3420a4c7a63d942d73ab69", + "sha256:ceedd46f569f5efb8b4def3d1dd6a0d85e1a44722608d68aa1d2d0f8693c1bff", + "sha256:d82d94badd34c8adbc5c85b85085317444cd9e062fc8b956221b34ba4c823b56", + "sha256:df84f3e93cd08cb31a252510a2e7be4bb15e6dff8a06d91f94c057a305d5d55d", + "sha256:f86378bbfbec7334af03bad4d5fd432149286665ecc8bfbcb7135da56b15d34b" ], "markers": "python_full_version >= '3.5.0'", - "version": "==0.22.0" + "version": "==0.23.0" }, "attrs": { "hashes": [ - "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", - "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" + "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", + "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" - }, - "blinker": { - "hashes": [ - "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" - ], - "version": "==1.4" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==21.2.0" }, "chardet": { "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" + "version": "==4.0.0" }, - "h11": { + "click": { "hashes": [ - "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", - "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" + "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", + "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" ], "markers": "python_version >= '3.6'", - "version": "==0.12.0" + "version": "==8.0.1" }, - "h2": { + "colorama": { "hashes": [ - "sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25", - "sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d" + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" ], - "markers": "python_full_version >= '3.6.1'", - "version": "==4.0.0" + "version": "==0.4.4" }, - "hpack": { + "fastapi": { "hashes": [ - "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", - "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095" + "sha256:39569a18914075b2f1aaa03bcb9dc96a38e0e5dabaf3972e088c9077dfffa379", + "sha256:8359e55d8412a5571c0736013d90af235d6949ec4ce978e9b63500c8f4b6f714" ], - "markers": "python_full_version >= '3.6.1'", - "version": "==4.0.0" + "index": "pypi", + "version": "==0.65.2" }, - "hypercorn": { + "h11": { "hashes": [ - "sha256:5ba1e719c521080abd698ff5781a2331e34ef50fc1c89a50960538115a896a9a", - "sha256:8007c10f81566920f8ae12c0e26e146f94ca70506da964b5a727ad610aa1d821" + "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", + "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" ], - "markers": "python_version >= '3.7'", - "version": "==0.11.2" + "markers": "python_version >= '3.6'", + "version": "==0.12.0" }, - "hyperframe": { - "hashes": [ - "sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1", - "sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34" - ], - "markers": "python_full_version >= '3.6.1'", - "version": "==6.0.0" + "httptools": { + "hashes": [ + "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb", + "sha256:200fc1cdf733a9ff554c0bb97a4047785cfaad9875307d6087001db3eb2b417f", + "sha256:3ab1f390d8867f74b3b5ee2a7ecc9b8d7f53750bd45714bf1cb72a953d7dfa77", + "sha256:78d03dd39b09c99ec917d50189e6743adbfd18c15d5944392d2eabda688bf149", + "sha256:79dbc21f3612a78b28384e989b21872e2e3cf3968532601544696e4ed0007ce5", + "sha256:80ffa04fe8c8dfacf6e4cef8277347d35b0442c581f5814f3b0cf41b65c43c6e", + "sha256:813871f961edea6cb2fe312f2d9b27d12a51ba92545380126f80d0de1917ea15", + "sha256:94505026be56652d7a530ab03d89474dc6021019d6b8682281977163b3471ea0", + "sha256:a23166e5ae2775709cf4f7ad4c2048755ebfb272767d244e1a96d55ac775cca7", + "sha256:a289c27ccae399a70eacf32df9a44059ca2ba4ac444604b00a19a6c1f0809943", + "sha256:a7594f9a010cdf1e16a58b3bf26c9da39bbf663e3b8d46d39176999d71816658", + "sha256:b08d00d889a118f68f37f3c43e359aab24ee29eb2e3fe96d64c6a2ba8b9d6557", + "sha256:cc9be041e428c10f8b6ab358c6b393648f9457094e1dcc11b4906026d43cd380", + "sha256:d5682eeb10cca0606c4a8286a3391d4c3c5a36f0c448e71b8bd05be4e1694bfb", + "sha256:fd3b8905e21431ad306eeaf56644a68fdd621bf8f3097eff54d0f6bdf7262065" + ], + "version": "==0.2.0" }, "idna": { "hashes": [ - "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", - "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" - ], - "markers": "python_version >= '3.4'", - "version": "==3.1" - }, - "itsdangerous": { - "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", - "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.3" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", - "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", - "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", - "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", - "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", - "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", - "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", - "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", - "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" + "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", + "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.1" + "markers": "python_full_version >= '3.5.0'", + "version": "==3.2" }, "multidict": { "hashes": [ @@ -297,52 +222,109 @@ "index": "pypi", "version": "==0.2.3" }, - "priority": { - "hashes": [ - "sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe", - "sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb" + "pydantic": { + "hashes": [ + "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd", + "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739", + "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f", + "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840", + "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23", + "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287", + "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62", + "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b", + "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb", + "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820", + "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3", + "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b", + "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e", + "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3", + "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316", + "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b", + "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4", + "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20", + "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e", + "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505", + "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1", + "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833" ], - "version": "==1.3.0" + "markers": "python_full_version >= '3.6.1'", + "version": "==1.8.2" }, "pyjwt": { "hashes": [ - "sha256:a5c70a06e1f33d81ef25eecd50d50bd30e34de1ca8b2b9fa3fe0daaabcf69bf7", - "sha256:b70b15f89dc69b993d8a8d32c299032d5355c82f9b5b7e851d1a6d706dffe847" + "sha256:934d73fbba91b0483d3857d1aff50e96b2a892384ee2c17417ed3203f173fca1", + "sha256:fba44e7898bbca160a2b2b501f492824fc8382485d3a6f11ba5d0c1937ce6130" ], "index": "pypi", - "version": "==2.0.1" + "version": "==2.1.0" }, - "quart": { + "python-dotenv": { "hashes": [ - "sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02", - "sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707" + "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544", + "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f" ], - "index": "pypi", - "version": "==0.14.1" + "version": "==0.17.1" }, - "quart-cors": { + "pyyaml": { "hashes": [ - "sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e", - "sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c" + "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", + "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", + "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", + "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", + "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", + "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", + "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", + "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", + "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", + "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", + "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", + "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", + "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", + "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", + "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", + "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", + "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", + "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", + "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", + "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", + "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", + "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", + "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", + "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", + "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", + "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", + "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", + "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", + "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], - "index": "pypi", - "version": "==0.3.0" + "version": "==5.4.1" }, - "toml": { + "starlette": { "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed", + "sha256:7d49f4a27f8742262ef1470608c59ddbc66baf37c148e938c7038e6bc7a998aa" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", - "version": "==0.10.2" + "markers": "python_version >= '3.6'", + "version": "==0.14.2" }, "typing-extensions": { "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", + "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", + "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" ], - "version": "==3.7.4.3" + "version": "==3.10.0.0" + }, + "uvicorn": { + "extras": [ + "standard" + ], + "hashes": [ + "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae", + "sha256:45ad7dfaaa7d55cab4cd1e85e03f27e9d60bc067ddc59db52a2b0aeca8870292" + ], + "index": "pypi", + "version": "==0.14.0" }, "uvloop": { "hashes": [ @@ -360,21 +342,50 @@ "markers": "platform_system == 'linux'", "version": "==0.15.2" }, - "werkzeug": { - "hashes": [ - "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", - "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.0.1" - }, - "wsproto": { - "hashes": [ - "sha256:868776f8456997ad0d9720f7322b746bbe9193751b5b290b7f924659377c8c38", - "sha256:d8345d1808dd599b5ffb352c25a367adb6157e664e140dbecba3f9bc007edb9f" - ], - "markers": "python_full_version >= '3.6.1'", - "version": "==1.0.0" + "watchgod": { + "hashes": [ + "sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29", + "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7" + ], + "version": "==0.7" + }, + "websockets": { + "hashes": [ + "sha256:0dd4eb8e0bbf365d6f652711ce21b8fd2b596f873d32aabb0fbb53ec604418cc", + "sha256:1d0971cc7251aeff955aa742ec541ee8aaea4bb2ebf0245748fbec62f744a37e", + "sha256:1d6b4fddb12ab9adf87b843cd4316c4bd602db8d5efd2fb83147f0458fe85135", + "sha256:230a3506df6b5f446fed2398e58dcaafdff12d67fe1397dff196411a9e820d02", + "sha256:276d2339ebf0df4f45df453923ebd2270b87900eda5dfd4a6b0cfa15f82111c3", + "sha256:2cf04601633a4ec176b9cc3d3e73789c037641001dbfaf7c411f89cd3e04fcaf", + "sha256:3ddff38894c7857c476feb3538dd847514379d6dc844961dc99f04b0384b1b1b", + "sha256:48c222feb3ced18f3dc61168ca18952a22fb88e5eb8902d2bf1b50faefdc34a2", + "sha256:51d04df04ed9d08077d10ccbe21e6805791b78eac49d16d30a1f1fe2e44ba0af", + "sha256:597c28f3aa7a09e8c070a86b03107094ee5cdafcc0d55f2f2eac92faac8dc67d", + "sha256:5c8f0d82ea2468282e08b0cf5307f3ad022290ed50c45d5cb7767957ca782880", + "sha256:7189e51955f9268b2bdd6cc537e0faa06f8fffda7fb386e5922c6391de51b077", + "sha256:7df3596838b2a0c07c6f6d67752c53859a54993d4f062689fdf547cb56d0f84f", + "sha256:826ccf85d4514609219725ba4a7abd569228c2c9f1968e8be05be366f68291ec", + "sha256:836d14eb53b500fd92bd5db2fc5894f7c72b634f9c2a28f546f75967503d8e25", + "sha256:85db8090ba94e22d964498a47fdd933b8875a1add6ebc514c7ac8703eb97bbf0", + "sha256:85e701a6c316b7067f1e8675c638036a796fe5116783a4c932e7eb8e305a3ffe", + "sha256:900589e19200be76dd7cbaa95e9771605b5ce3f62512d039fb3bc5da9014912a", + "sha256:9147868bb0cc01e6846606cd65cbf9c58598f187b96d14dd1ca17338b08793bb", + "sha256:9e7fdc775fe7403dbd8bc883ba59576a6232eac96dacb56512daacf7af5d618d", + "sha256:ab5ee15d3462198c794c49ccd31773d8a2b8c17d622aa184f669d2b98c2f0857", + "sha256:ad893d889bc700a5835e0a95a3e4f2c39e91577ab232a3dc03c262a0f8fc4b5c", + "sha256:b2e71c4670ebe1067fa8632f0d081e47254ee2d3d409de54168b43b0ba9147e0", + "sha256:b43b13e5622c5a53ab12f3272e6f42f1ce37cd5b6684b2676cb365403295cd40", + "sha256:b4ad84b156cf50529b8ac5cc1638c2cf8680490e3fccb6121316c8c02620a2e4", + "sha256:be5fd35e99970518547edc906efab29afd392319f020c3c58b0e1a158e16ed20", + "sha256:caa68c95bc1776d3521f81eeb4d5b9438be92514ec2a79fececda814099c8314", + "sha256:d144b350045c53c8ff09aa1cfa955012dd32f00c7e0862c199edcabb1a8b32da", + "sha256:d2c2d9b24d3c65b5a02cac12cbb4e4194e590314519ed49db2f67ef561c3cf58", + "sha256:e9e5fd6dbdf95d99bc03732ded1fc8ef22ebbc05999ac7e0c7bf57fe6e4e5ae2", + "sha256:ebf459a1c069f9866d8569439c06193c586e72c9330db1390af7c6a0a32c4afd", + "sha256:f31722f1c033c198aa4a39a01905951c00bd1c74f922e8afc1b1c62adbcdd56a", + "sha256:f68c352a68e5fdf1e97288d5cec9296664c590c25932a8476224124aaf90dbcd" + ], + "version": "==9.1" }, "yarl": { "hashes": [ @@ -438,49 +449,64 @@ }, "attrs": { "hashes": [ - "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", - "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" + "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", + "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==21.2.0" }, "black": { "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" + "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04", + "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7" ], "index": "pypi", - "version": "==20.8b1" + "version": "==21.6b0" + }, + "certifi": { + "hashes": [ + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" + ], + "version": "==2021.5.30" }, "cfgv": { "hashes": [ - "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", - "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" + "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1", + "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1" ], "markers": "python_full_version >= '3.6.1'", - "version": "==3.2.0" + "version": "==3.3.0" }, - "click": { + "chardet": { "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" + "version": "==4.0.0" + }, + "click": { + "hashes": [ + "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", + "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" + ], + "markers": "python_version >= '3.6'", + "version": "==8.0.1" }, "colorama": { "hashes": [ "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" ], - "markers": "sys_platform == 'win32'", "version": "==0.4.4" }, "distlib": { "hashes": [ - "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", - "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" + "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736", + "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c" ], - "version": "==0.3.1" + "version": "==0.3.2" }, "filelock": { "hashes": [ @@ -491,19 +517,27 @@ }, "flake8": { "hashes": [ - "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839", - "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b" + "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b", + "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907" ], "index": "pypi", - "version": "==3.8.4" + "version": "==3.9.2" }, "identify": { "hashes": [ - "sha256:7b435803dc79a0f0ce887887a62ad360f3a9e8162ac0db9ee649d5d24085bf30", - "sha256:ec4698722b9a8a6f2aa5f9ff9cc687956494f0ba734941659ad7f018cf457e44" + "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421", + "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.6.1" + "markers": "python_full_version >= '3.6.1'", + "version": "==2.2.10" + }, + "idna": { + "hashes": [ + "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", + "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" + ], + "markers": "python_full_version >= '3.5.0'", + "version": "==3.2" }, "iniconfig": { "hashes": [ @@ -528,10 +562,10 @@ }, "nodeenv": { "hashes": [ - "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9", - "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c" + "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b", + "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7" ], - "version": "==1.5.0" + "version": "==1.6.0" }, "packaging": { "hashes": [ @@ -558,11 +592,11 @@ }, "pre-commit": { "hashes": [ - "sha256:16212d1fde2bed88159287da88ff03796863854b04dc9f838a55979325a3d20e", - "sha256:399baf78f13f4de82a29b649afd74bef2c4e28eb4f021661fc7f29246e8c7a3a" + "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378", + "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4" ], "index": "pypi", - "version": "==2.10.1" + "version": "==2.13.0" }, "py": { "hashes": [ @@ -574,43 +608,43 @@ }, "pycodestyle": { "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", + "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.6.0" + "version": "==2.7.0" }, "pyflakes": { "hashes": [ - "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", - "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" + "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", + "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.2.0" + "version": "==2.3.1" }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { "hashes": [ - "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9", - "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839" + "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", + "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" ], "markers": "python_version >= '3.6'", - "version": "==6.2.2" + "version": "==6.2.4" }, "pytest-asyncio": { "hashes": [ - "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d", - "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700" + "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f", + "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea" ], "index": "pypi", - "version": "==0.14.0" + "version": "==0.15.1" }, "pyyaml": { "hashes": [ @@ -626,131 +660,111 @@ "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", + "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", + "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", + "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", + "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", + "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", - "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc" + "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", + "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", + "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", + "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==5.4.1" }, "regex": { "hashes": [ - "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", - "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", - "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", - "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", - "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", - "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", - "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", - "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", - "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", - "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", - "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", - "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", - "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", - "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", - "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", - "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", - "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", - "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", - "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", - "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", - "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", - "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", - "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", - "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", - "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", - "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", - "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", - "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", - "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", - "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", - "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", - "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", - "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", - "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", - "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", - "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", - "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", - "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", - "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", - "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", - "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" - ], - "version": "==2020.11.13" + "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5", + "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79", + "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31", + "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500", + "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11", + "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14", + "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3", + "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439", + "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c", + "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82", + "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711", + "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093", + "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a", + "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb", + "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8", + "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17", + "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000", + "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d", + "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480", + "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc", + "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0", + "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9", + "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765", + "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e", + "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a", + "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07", + "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f", + "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac", + "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7", + "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed", + "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968", + "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7", + "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2", + "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4", + "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87", + "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8", + "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10", + "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29", + "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605", + "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6", + "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042" + ], + "version": "==2021.4.4" + }, + "requests": { + "hashes": [ + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" + ], + "index": "pypi", + "version": "==2.25.1" }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.15.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, - "typed-ast": { - "hashes": [ - "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", - "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", - "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", - "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", - "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", - "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", - "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", - "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", - "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", - "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", - "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", - "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", - "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", - "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", - "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", - "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", - "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", - "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", - "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", - "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", - "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", - "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", - "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", - "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", - "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", - "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", - "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", - "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", - "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", - "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" - ], - "version": "==1.4.2" - }, - "typing-extensions": { + "urllib3": { "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", + "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" ], - "version": "==3.7.4.3" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.5" }, "virtualenv": { "hashes": [ - "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d", - "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3" + "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467", + "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.4.2" + "version": "==20.4.7" } } } diff --git a/README.md b/README.md index 941068e..0bf5d8a 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,17 @@ [![Licence](https://img.shields.io/badge/licence-MIT-blue.svg)](/LICENCE) [![Discord](https://discord.com/api/guilds/501090983539245061/widget.png?style=shield)](https://discord.gg/twt) [![Test and deploy](https://github.com/Tech-With-Tim/API/workflows/Release%20-%20Test%2C%20Build%20%26%20Redeploy/badge.svg)](https://github.com/Tech-With-Tim/API/actions?query=workflow%3A%22Release+-+Test%2C+Build+%26+Redeploy%22) + -API for the Tech With Tim website using [Quart](https://pgjones.gitlab.io/quart/). +API for the Tech With Tim website using [FastAPI](https://fastapi.tiangolo.com/). ## 📝 Table of Contents + - [🏁 Getting Started](#-getting-started) - [Discord application](#discord-application) - [Prerequisites](#prerequisites) @@ -81,10 +83,11 @@ DISCORD_CLIENT_SECRET= And fill in the variables with the values below: -- ``SECRET_KEY`` is the key used for the JWT token encoding. -- ``DB_URI`` is the PostgreSQL database URI. -- ``DISCORD_CLIENT_ID`` is the Discord application ID. Copy it from your Discord application page (see below). -- ``DISCORD_CLIENT_SECRET`` is the Discord application secret. Copy it from your Discord application page (see below). +- `POSTGRES_URI` is the PostgreSQL database URI. +- `SECRET_KEY` is the key used for JWT token encoding. +- `TEST_POSTGRES_URI` is the PostgreSQL database URI for tests. +- `DISCORD_CLIENT_ID` is the Discord application ID. Copy it from your Discord application page (see below). +- `DISCORD_CLIENT_SECRET` is the Discord application secret. Copy it from your Discord application page (see below). ![Client ID and secret](https://cdn.discordapp.com/attachments/721750194797936823/794646777840140298/unknown.png) @@ -110,9 +113,9 @@ Both the API and the [frontend](https://github.com/Tech-With-Tim/Frontend) can b - Deploy the API: - ```sh - docker-compose up - ``` + ```sh + docker-compose up --build api + ``` ## ✅ Linting @@ -135,17 +138,18 @@ To test the API, we use the [pytest](https://docs.pytest.org/en/stable/) framewo Run the tests: ```sh -pipenv run pytest +pipenv run test ``` -**When you contribute, you need to add tests on the features you add.** An example can be seen in [tests/test_index.py](/tests/test_index.py). +**When you contribute, you need to add tests on the features you add.** ## ⛏️ Built Using - [Python](https://www.python.org/) - Language -- [Quart](https://pgjones.gitlab.io/quart/) - Backend module +- [FastAPI](https://fastapi.tiangolo.com/) - Backend framework - [PostDB](https://github.com/SylteA/postDB) - Database module -- [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) - Test framework (asynchronous version of [pytest](https://docs.pytest.org/en/stable/)) +- [pytest](https://docs.pytest.org/en/stable/) - Testing framework +- [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) - Testing plugin for [pytest](https://docs.pytest.org/en/stable/) ## ✍️ Authors diff --git a/api/app.py b/api/app.py index 77bcef2..fc524d2 100644 --- a/api/app.py +++ b/api/app.py @@ -1,45 +1,19 @@ -from quart import Quart, Response, exceptions, jsonify -from datetime import datetime, date -from aiohttp import ClientSession -from typing import Any, Optional -from quart_cors import cors +from fastapi import FastAPI, HTTPException +from utils.response import JSONResponse +from api import versions import logging -import json - -import utils - -from api.blueprints import auth, guilds, users log = logging.getLogger() -class JSONEncoder(json.JSONEncoder): - def default(self, o: Any) -> Any: - if isinstance(o, (datetime, date)): - o.replace(microsecond=0) - return o.isoformat() - - return super().default(o) - - -class API(Quart): - """Quart subclass to implement more API like handling.""" - - http_session: Optional[ClientSession] = None - request_class = utils.Request - json_encoder = JSONEncoder +class API(FastAPI): + """FastAPI subclass to implement more API like handling.""" def __init__(self, *args, **kwargs): - kwargs.setdefault("static_folder", None) super().__init__(*args, **kwargs) - async def handle_request(self, request: utils.Request) -> Response: - response = await super().handle_request(request) - log.info(f"{request.method} @ {request.base_url} -> {response.status_code}") - return response - - async def handle_http_exception(self, error: exceptions.HTTPException): + async def handle_http_exception(self, error: HTTPException): """ Returns errors as JSON instead of default HTML Uses custom error handler if one exists. @@ -53,35 +27,23 @@ async def handle_http_exception(self, error: exceptions.HTTPException): headers = error.get_headers() headers["Content-Type"] = "application/json" - return ( - jsonify(error=error.name, message=error.description), - error.status_code, - headers, + return JSONResponse( + headers=headers, + status_code=error.status_code, + content={"error": error.name, "message": error.description}, ) - async def startup(self) -> None: - self.http_session = ClientSession() - return await super().startup() - -# Set up app -app = API(__name__) -app.asgi_app = utils.TokenAuthMiddleware(app.asgi_app, app) -app = cors(app, allow_origin="*") # TODO: Restrict the origin(s) in production. -# Set up blueprints -auth.setup(app=app, url_prefix="/auth") -users.setup(app=app, url_prefix="/users") -guilds.setup(app=app, url_prefix="/guilds") +app = API() +app.router.default_response_class = JSONResponse +app.include_router(versions.v1.router) -@app.route("/") -async def index(): - """Index endpoint used for testing.""" - return jsonify(status="OK") +app.add_exception_handler(HTTPException, app.handle_http_exception) -@app.errorhandler(500) -async def error_500(error: BaseException): +@app.exception_handler(500) +async def error_500(request, error: HTTPException): """ TODO: Handle the error with our own error handling system. """ @@ -90,7 +52,10 @@ async def error_500(error: BaseException): exc_info=(type(error), error, error.__traceback__), ) - return ( - jsonify(error="Internal Server Error", message="Server got itself in trouble"), - 500, + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "Server got itself in trouble", + }, ) diff --git a/api/blueprints/auth/__init__.py b/api/blueprints/auth/__init__.py deleted file mode 100644 index 5217297..0000000 --- a/api/blueprints/auth/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from quart import Quart, Blueprint - - -bp: Blueprint = Blueprint("auth", __name__) - - -def setup(app: Quart, url_prefix: str) -> None: - from . import views # noqa F401 - - # Import the views package to load routes. - - # register the blueprint to our Quart instance. - app.register_blueprint(bp, url_prefix=url_prefix) diff --git a/api/blueprints/auth/views/__init__.py b/api/blueprints/auth/views/__init__.py deleted file mode 100644 index 65963c4..0000000 --- a/api/blueprints/auth/views/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import tokens # noqa F401 diff --git a/api/blueprints/auth/views/tokens.py b/api/blueprints/auth/views/tokens.py deleted file mode 100644 index 8612c16..0000000 --- a/api/blueprints/auth/views/tokens.py +++ /dev/null @@ -1,198 +0,0 @@ -from quart import current_app, request, redirect, jsonify -from urllib.parse import quote_plus, parse_qs, urlparse -from quart.exceptions import MethodNotAllowed -from datetime import datetime, timedelta -from typing import List, Tuple -import jwt -import os - -from api.models import Token, User -from api.app import API -from .. import bp -import utils - - -DISCORD_ENDPOINT = "https://discord.com/api" -request: utils.Request -SCOPES = ["identify"] -current_app: API - - -async def exchange_code( - *, code: str, scope: str, redirect_uri: str, grant_type: str = "authorization_code" -) -> Tuple[dict, int]: - """Exchange discord oauth code for access and refresh tokens.""" - async with current_app.http_session.post( - "%s/v6/oauth2/token" % DISCORD_ENDPOINT, - data=dict( - code=code, - scope=scope, - grant_type=grant_type, - redirect_uri=redirect_uri, - client_id=os.environ["DISCORD_CLIENT_ID"], - client_secret=os.environ["DISCORD_CLIENT_SECRET"], - ), - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) as response: - return await response.json(), response.status - - -async def get_user(access_token: str) -> dict: - """Coroutine to fetch User data from discord using the users `access_token`""" - async with current_app.http_session.get( - "%s/v6/users/@me" % DISCORD_ENDPOINT, - headers={"Authorization": "Bearer %s" % access_token}, - ) as response: - return await response.json() - - -def format_scopes(scopes: List[str]) -> str: - """Format a list of scopes.""" - return " ".join(scopes) - - -def get_redirect(callback: str, scopes: List[str]) -> str: - """Generates the correct oauth link depending on our provided arguments.""" - return ( - "{BASE}/oauth2/authorize?response_type=code" - "&client_id={client_id}" - "&scope={scopes}" - "&redirect_uri={redirect_uri}" - "&prompt=consent" - ).format( - BASE=DISCORD_ENDPOINT, - client_id=os.environ["DISCORD_CLIENT_ID"], - scopes=format_scopes(scopes), - redirect_uri=quote_plus(callback), - ) - - -def is_valid_url(string: str) -> bool: - """Returns boolean describing if the provided string is a url""" - result = urlparse(string) - return all((result.scheme, result.netloc)) - - -@bp.route("/discord/redirect", methods=["GET"]) -async def redirect_to_discord_oauth_portal(): - """Redirect user to correct oauth link depending on specified domain and requested scopes.""" - qs = parse_qs(request.query_string.decode()) - - callback = qs.get( - "callback", (request.scheme + "://" + request.host + "/auth/discord/callback") - ) - - if isinstance(callback, list): # - callback = callback[0] - - if not is_valid_url(callback): - return ( - jsonify( - {"error": "Bad Request", "message": "Not a well formed redirect URL."} - ), - 400, - ) - - return redirect(get_redirect(callback=callback, scopes=SCOPES)) - - -@bp.route("/discord/callback", methods=["GET", "POST"]) -async def discord_oauth_callback(): - """ - Callback endpoint for finished discord authorization flow. - - GET -> Only used in DEBUG mode. - Gets code from querystring. - - POST -> Gets code from request data. - """ - - if request.method == "GET": - if not current_app.debug: - # A GET request to this endpoint should only be used in testing. - raise MethodNotAllowed(("POST",)) - - qs = parse_qs(request.query_string.decode()) - code = qs.get("code") - if code is not None: - code = code[0] - callback = request.scheme + "://" + request.host + "/auth/discord/callback" - elif request.method == "POST": - data = await request.json - - code = data.get("code") - callback = data.get("callback", "") - else: - raise RuntimeWarning("Unexpected request method. (%s)" % request.method) - - if code is None: - return ( - jsonify( - { - "error": "Bad Request", - "message": "Missing code in %s." % "querystring arguments" - if request.method == "GET" - else "JSON data", - } - ), - 400, - ) - - if not is_valid_url(callback): - return ( - jsonify( - {"error": "Bad Request", "message": "Not a well formed redirect URL."} - ), - 400, - ) - - access_data, status_code = await exchange_code( - code=code, scope=format_scopes(SCOPES), redirect_uri=callback - ) - - if access_data.get("error", False): - if status_code == 400: - return ( - jsonify( - { - "error": "Bad Request", - "message": "Discord returned 400 status.", - "data": access_data, - } - ), - 400, - ) - - raise RuntimeWarning( - "Unpredicted status_code.\n%s\n%s" % (str(access_data), status_code) - ) - - expires_at = datetime.utcnow() + timedelta(seconds=access_data["expires_in"]) - expires_at.replace(microsecond=0) - - user_data = await get_user(access_token=access_data["access_token"]) - user_data["id"] = uid = int(user_data["id"]) - - user = await User.fetch(id=uid) - - if user is None: - user = await User.create( - id=user_data["id"], - username=user_data["username"], - discriminator=user_data["discriminator"], - avatar=user_data["avatar"], - ) - - await Token( - user_id=user.id, - data=access_data, - expires_at=expires_at, - token=access_data["access_token"], - ).update() - - token = jwt.encode( - {"uid": user.id, "exp": expires_at, "iat": datetime.utcnow()}, - key=os.environ["SECRET_KEY"], - ) - - return jsonify(token=token, exp=expires_at) diff --git a/api/blueprints/guilds/__init__.py b/api/blueprints/guilds/__init__.py deleted file mode 100644 index be8c628..0000000 --- a/api/blueprints/guilds/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from quart import Quart, Blueprint - - -bp: Blueprint = Blueprint("guilds", __name__) - - -def setup(app: Quart, url_prefix: str) -> None: - from . import views # noqa F401 - - # Import the views package to load routes. - - # register the blueprint to our Quart instance. - app.register_blueprint(bp, url_prefix=url_prefix) diff --git a/api/blueprints/guilds/views/__init__.py b/api/blueprints/guilds/views/__init__.py deleted file mode 100644 index f812a03..0000000 --- a/api/blueprints/guilds/views/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import guilds, configs # noqa F401 diff --git a/api/blueprints/guilds/views/configs.py b/api/blueprints/guilds/views/configs.py deleted file mode 100644 index 41e18e4..0000000 --- a/api/blueprints/guilds/views/configs.py +++ /dev/null @@ -1,128 +0,0 @@ -from quart import jsonify -from typing import Optional, Union, Literal - -from api.models import Guild, GuildConfig -from .. import bp -import utils - - -@bp.route("//config", methods=["POST"]) -@utils.app_only -@utils.expects_data( - xp_enabled=Optional[bool], - xp_multiplier=Optional[float], - eco_enabled=Optional[bool], - muted_role_id=Optional[Union[str, int]], - do_logging=Optional[bool], - log_channel_id=Optional[Union[str, int]], - do_verification=Optional[bool], - verification_type=Optional[ - Literal[ - "DISCORD_INTEGRATED", - "DISCORD_CODE", - "DISCORD_INTEGRATED_CODE", - "DISCORD_CAPTCHA", - "DISCORD_INTEGRATED_CAPTCHA", - "DISCORD_REACTION", - "DISCORD_INTEGRATED_REACTION", - ] - ], - verification_channel_id=Optional[Union[str, int]], -) -async def post_guild_config(guild_id: int, **data): - """Create a Config for a guild""" - - guild = await Guild.fetch_or_404(guild_id) - - try: - guild_config = await GuildConfig.create(guild_id, **data) - except ValueError as e: - return jsonify(error="Bad request", message=str(e) + "."), 400 - - if guild_config is None: - # GuildConfig already exists - return ( - jsonify( - error="Conflict", - message=f"Guild with ID {guild_id} already has a config.", - ), - 409, - ) - - return ( - jsonify( - { - name: str(value) - if name.endswith("_id") and value is not None - else value - for name, value in guild_config.as_dict().items() - } - ), - 201, - {"Location": f"/guilds/{guild.id}/config"}, - ) - - -@bp.route("//config", methods=["GET"]) -@utils.app_only -async def get_guild_config(guild_id: int): - """Get the Config for a guild""" - - guild_config = await GuildConfig.fetch_or_404(guild_id) - - return jsonify( - { - name: str(value) if name.endswith("_id") and value is not None else value - for name, value in guild_config.as_dict().items() - } - ) - - -@bp.route("//config", methods=["PATCH"]) -@utils.app_only -@utils.expects_data( - xp_enabled=Optional[bool], - xp_multiplier=Optional[float], - eco_enabled=Optional[bool], - muted_role_id=Optional[Union[str, int]], - do_logging=Optional[bool], - log_channel_id=Optional[Union[str, int]], - do_verification=Optional[bool], - verification_type=Optional[ - Literal[ - "DISCORD_INTEGRATED", - "DISCORD_CODE", - "DISCORD_INTEGRATED_CODE", - "DISCORD_CAPTCHA", - "DISCORD_INTEGRATED_CAPTCHA", - "DISCORD_REACTION", - "DISCORD_INTEGRATED_REACTION", - ] - ], - verification_channel_id=Optional[Union[str, int]], -) -async def patch_guild_config(guild_id: int, **data): - """Patch the Config for a guild""" - - guild_config = await GuildConfig.fetch_or_404(guild_id) - - await guild_config.update(**data) - - return jsonify( - { - name: str(value) if name.endswith("_id") and value is not None else value - for name, value in guild_config.as_dict().items() - } - ) - - -@bp.route("//config", methods=["DELETE"]) -@utils.app_only -async def delete_guild_config(guild_id: int): - """Delete the Config for a guild""" - - guild_config = await GuildConfig.fetch_or_404(guild_id) - - await guild_config.delete() - - return "", 204 diff --git a/api/blueprints/guilds/views/guilds.py b/api/blueprints/guilds/views/guilds.py deleted file mode 100644 index ac454c9..0000000 --- a/api/blueprints/guilds/views/guilds.py +++ /dev/null @@ -1,95 +0,0 @@ -from quart import jsonify -from typing import Optional, Union - -from api.models import Guild -from .. import bp -import utils - - -request: utils.Request - - -@bp.route("", methods=["POST"]) -@utils.app_only -@utils.expects_data( - id=Union[str, int], - name=str, - owner_id=Union[str, int], - icon_hash=Optional[str], -) -async def post_guild( - id: Union[str, int], - name: str, - owner_id: Union[str, int], - icon_hash: Optional[str] = None, -): - """Create a guild from the request body""" - - guild = await Guild.create(id, name, owner_id, icon_hash) - - if guild is None: - # Guild already exists - return ( - jsonify( - error="Conflict", - message=f"Guild with ID {int(id)} already exists.", - ), - 409, - ) - - return ( - jsonify( - id=str(guild.id), - name=guild.name, - owner_id=str(guild.owner_id), - icon_hash=guild.icon_hash, - ), - 201, - {"Location": f"/guilds/{guild.id}"}, - ) - - -@bp.route("/", methods=["GET"]) -async def get_guild(guild_id: int): - """Get a guild from its ID""" - - guild = await Guild.fetch_or_404(guild_id) - - return jsonify( - id=str(guild.id), - name=guild.name, - owner_id=str(guild.owner_id), - icon_hash=guild.icon_hash, - ) - - -@bp.route("/", methods=["PATCH"]) -@utils.app_only -@utils.expects_data( - name=Optional[str], - owner_id=Optional[Union[str, int]], - icon_hash=Optional[str], -) -async def patch_guild(guild_id: int, **data): - """Patch a guild from its ID""" - - guild = await Guild.fetch_or_404(guild_id) - await guild.update(**data) - - return jsonify( - id=str(guild.id), - name=guild.name, - owner_id=str(guild.owner_id), - icon_hash=guild.icon_hash, - ) - - -@bp.route("/", methods=["DELETE"]) -@utils.app_only -async def delete_guild(guild_id: int): - """Delete a guild from its ID""" - - guild = await Guild.fetch_or_404(guild_id) - await guild.delete() - - return "", 204 diff --git a/api/blueprints/users/__init__.py b/api/blueprints/users/__init__.py deleted file mode 100644 index 1a1c4d3..0000000 --- a/api/blueprints/users/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from quart import Quart, Blueprint - - -bp: Blueprint = Blueprint("users", __name__) - - -def setup(app: Quart, url_prefix: str) -> None: - from . import views # noqa F401 - - # Import the views package to load routes. - - # register the blueprint to our Quart instance. - app.register_blueprint(bp, url_prefix=url_prefix) diff --git a/api/blueprints/users/views/__init__.py b/api/blueprints/users/views/__init__.py deleted file mode 100644 index da71954..0000000 --- a/api/blueprints/users/views/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import users # noqa F401 diff --git a/api/blueprints/users/views/users.py b/api/blueprints/users/views/users.py deleted file mode 100644 index 699176d..0000000 --- a/api/blueprints/users/views/users.py +++ /dev/null @@ -1,127 +0,0 @@ -from quart import request, jsonify -import time - -from api.models import User -from .. import bp -import utils - - -request: utils.Request - - -@bp.route("", methods=["GET"]) -@utils.auth_required -async def bulk_get_users(): - """GET `User` objects by bulk. - - Query parameters: - type: Only fetch users of this type. - username: Only fetch users with this username. - discriminator: Only fetch users with this discriminator - page: Pagination page. - limit: max number of records to return. - """ - start = time.perf_counter() - - qs = { - "type": request.args.get("type"), - "username": request.args.get("username"), - "discriminator": request.args.get("discriminator"), - } - - try: - page = int(request.args.get("page", "0")) - limit = int(request.args.get("limit", "100")) - except ValueError as e: - invalid_arg = str(e).split()[-1] - return ( - jsonify( - error="Bad Request", message="Invalid literal for int, %s" % invalid_arg - ), - 400, - ) - - query = """ - SELECT json_agg(json_build_object( - 'id', u.id::TEXT, - 'username', u.username, - 'discriminator', u.discriminator, - 'avatar', u.avatar, - 'type', u.type - )) - FROM users u""" - - checks = [] - args = [] - i = 1 - - for key, val in qs.items(): - if val is not None: - checks.append("%s = $%s" % (key, i)) - args.append(val), - i += 1 - - if checks: - query += "\n WHERE\n " + "\n AND\n ".join(checks) - - limit = max(min(100, limit), 1) # Restrict to minimum 1, maximum 100. - offset = page * limit - - query += "\n LIMIT %s OFFSET %s" % (limit, offset) - - records = await User.pool.fetchval(query, *args) - records = records or [] - - return jsonify( - page=page, limit=limit, users=records, time=time.perf_counter() - start - ) - - -@bp.route("/@me", methods=["GET"]) -@utils.auth_required -async def get_user_info(): - """GET authorized User object.""" - query = """ - SELECT - id::TEXT, - username, - discriminator, - avatar, - type - FROM users - WHERE id = $1; - """ - - user = await User.pool.fetchrow(query, request.user_id) - - return jsonify(**user) - - -@bp.route("/", methods=["GET"]) -@utils.auth_required -async def get_specific_user_info(user_id: int): - """GET specific User object.""" - - query = """ - SELECT - id::TEXT, - username, - discriminator, - avatar, - type - FROM users - WHERE id = $1; - """ - - user = await User.pool.fetchrow(query, user_id) - - if user is None: - return ( - jsonify( - error="NotFound", - message="Could not find the requested user in our database.", - ), - 400, - ) - - return jsonify(**user) diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..a9ccacf --- /dev/null +++ b/api/config.py @@ -0,0 +1,65 @@ +import os +import typing +import logging + +__debug = False +log = logging.getLogger("Config") + + +def debug(): + return __debug + + +def set_debug(value): + global __debug + __debug = value + + +def postgres_uri() -> str: + """Connection URI for PostgreSQL database.""" + value = os.environ.get("POSTGRES_URI") + + if value: + return value + + raise EnvironmentError('Required environment variable "POSTGRES_URI" is missing') + + +def secret_key() -> typing.Optional[str]: + """Key for validating and creating JWT tokens""" + value = os.environ.get("SECRET_KEY", None) + + if not value: + log.warning('Optional environment variable "SECRET_KEY" is missing') + + return value + + +def discord_client_id() -> typing.Optional[str]: + """The client id of the application used for authentication""" + value = os.environ.get("DISCORD_CLIENT_ID", 0) + + if not value: + log.warning('Optional environment variable "DISCORD_CLIENT_ID" is missing') + + return value + + +def discord_client_secret() -> typing.Optional[str]: + """The client secret of the application used for authentication""" + value = os.environ.get("DISCORD_CLIENT_SECRET", "") + + if not value: + log.warning('Optional environment variable "DISCORD_CLIENT_SECRET" is missing') + + return value + + +def test_postgres_uri() -> typing.Optional[str]: + """Connection URI for PostgreSQL database for testing.""" + value = os.environ.get("TEST_POSTGRES_URI", "") + + if not value: + log.warning('Optional environment variable "TEST_DB_URI" is missing') + + return value diff --git a/api/models/__init__.py b/api/models/__init__.py index 1ab310f..0df14bd 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -1,11 +1,5 @@ -from typing import List, Type from postDB import Model +from typing import List, Type -from .token import Token -from .user import User -from .guild import Guild -from .guild_config import GuildConfig - - -models_ordered: List[Type[Model]] = [User, Token, Guild, GuildConfig] +models_ordered: List[Type[Model]] = [] diff --git a/api/models/guild.py b/api/models/guild.py deleted file mode 100644 index b4fe9f8..0000000 --- a/api/models/guild.py +++ /dev/null @@ -1,183 +0,0 @@ -from postDB import Model, Column, types -from quart import exceptions -from typing import Optional, Union, Literal -from datetime import datetime -from http import HTTPStatus - -import utils - - -VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"}) -VALID_ICON_FORMATS = VALID_STATIC_FORMATS | {"gif"} - - -class Guild(Model): - """ - Guild model for storing information about discord guilds. - - :param int id: The guilds id. - :param str name: The guilds name. - :param int owner_id: The guilds owner id. - :param Optional[str] icon_hash: The guilds icon hash. - - """ - - id = Column(types.Integer(big=True), primary_key=True) - name = Column(types.String()) - owner_id = Column(types.Integer(big=True)) - icon_hash = Column(types.String(), nullable=True) - - @classmethod - async def fetch(cls, id: Union[str, int]) -> Optional["Guild"]: - """Fetch a guild with the given ID.""" - query = "SELECT * FROM guilds WHERE id = $1" - record = await cls.pool.fetchrow(query, int(id)) - - if record is None: - return None - - return cls(**record) - - @classmethod - async def fetch_or_404(cls, id: Union[str, int]) -> Optional["Guild"]: - """ - Fetch a guild with the given ID or send a 404 error. - - :param Union[str, int] guild_id: The guild's id. - """ - - if guild := await cls.fetch(id): - return guild - - http_status = HTTPStatus.NOT_FOUND - http_status.description = f"Guild with ID {id} doesn't exist." - raise exceptions.NotFound(http_status) - - @classmethod - async def create( - cls, - id: Union[str, int], - name: str, - owner_id: Union[str, int], - icon_hash: Optional[str] = None, - ) -> Optional["Guild"]: - """ - Create a new Guild instance. - - Returns the new instance if created. - Returns `None` if a Unique Violation occurred. - """ - - query = """ - INSERT INTO guilds (id, name, owner_id, icon_hash) - VALUES ($1, $2, $3, $4) - ON CONFLICT DO NOTHING - RETURNING *; - """ - - record = await cls.pool.fetchrow(query, int(id), name, int(owner_id), icon_hash) - - if record is None: - return None - - return cls(**record) - - async def update(self, **fields) -> Optional["Guild"]: - """Update the Guild with the given arguments.""" - - if not fields: - return self - - allowed_fields = ("name", "owner_id", "icon_hash") - fields = { - name: fields.get(name, getattr(self, name)) for name in allowed_fields - } - - query = """ - UPDATE guilds - SET - name = $2, - owner_id = $3, - icon_hash = $4 - WHERE id = $1 - RETURNING *; - """ - record = await self.pool.fetchrow( - query, - int(self.id), - fields["name"], - int(fields["owner_id"]), - fields["icon_hash"], - ) - - if record is None: - return None - - for field, value in record.items(): - setattr(self, field, value) - - return self - - async def delete(self) -> Optional["Guild"]: - """Delete the Guild.""" - - query = """ - DELETE FROM guilds - WHERE id = $1 - RETURNING *; - """ - record = await self.pool.fetchrow(query, int(self.id)) - - if record is None: - return None - - for field, value in record.items(): - setattr(self, field, value) - - return self - - @property - def created_at(self) -> datetime: - """Returns datetime of the guild creation.""" - return utils.snowflake_time(self.id) - - def is_icon_animated(self) -> bool: - """Indicates if the guild has an animated icon.""" - return bool(self.icon_hash and self.icon_hash.startswith("a_")) - - def icon_url_as( - self, - *, - fmt: Literal["jpeg", "jpg", "webp", "png", "gif"] = None, - static_format: Literal["jpeg", "jpg", "webp", "png"] = "webp", - size: Literal[16, 32, 64, 128, 256, 512, 1024, 2048, 4096] = 128, - ) -> Optional[str]: - """Constructs a link to discord's CDN for the guild's icon.""" - - if (size & (size - 1)) or size not in range(16, 4097): - raise ValueError("size must be a power of 2 between 16 and 4096") - - if fmt is not None and fmt not in VALID_ICON_FORMATS: - raise ValueError( - "format must be None or one of {}".format(VALID_ICON_FORMATS) - ) - - if fmt == "gif" and not self.is_icon_animated(): - raise ValueError("non animated avatars do not support gif format") - - if static_format not in VALID_STATIC_FORMATS: - raise ValueError( - "static_format must be one of {}".format(VALID_STATIC_FORMATS) - ) - - if self.icon_hash is None: - return None - - if fmt is None: - fmt = "gif" if self.is_icon_animated() else static_format - - return ( - "https://cdn.discordapp.com/icons/{0.id}/{0.icon_hash}.{1}?size={2}".format( - self, fmt, size - ) - ) diff --git a/api/models/guild_config.py b/api/models/guild_config.py deleted file mode 100644 index 89c00ce..0000000 --- a/api/models/guild_config.py +++ /dev/null @@ -1,215 +0,0 @@ -from postDB import Model, Column, types -from quart import exceptions -from typing import Optional, Union, Literal -from enum import Enum -from http import HTTPStatus - - -class VerificationTypes(Enum): - DISCORD_INTEGRATED = "DISCORD_INTEGRATED" - DISCORD_CODE = "DISCORD_CODE" - DISCORD_INTEGRATED_CODE = "DISCORD_INTEGRATED_CODE" - DISCORD_CAPTCHA = "DISCORD_CAPTCHA" - DISCORD_INTEGRATED_CAPTCHA = "DISCORD_INTEGRATED_CAPTCHA" - DISCORD_REACTION = "DISCORD_REACTION" - DISCORD_INTEGRATED_REACTION = "DISCORD_INTEGRATED_REACTION" - - -class GuildConfig(Model): - """ - Configuration for a guild, for storing information about what the guild configured. - - :param int guild_id: The guild's id. - :param bool xp_enabled: Wheter XP for every message is enabled. - :param float xp_multiplier: XP multiplier for every message. - :param bool eco_enabled: Wheter economy commands are enabled. - :param int muted_role_id: The muted role's id. - :param bool do_logging: Whether to do logging. - :param int log_channel_id: The logging channel's id. - :param bool do_verification: Wheter to do verification. - :param str verification_type: The verification's type. - :param int verification_channel_id: The verification channel's id. - """ - - guild_id = Column( - types.ForeignKey("guilds", "id", sql_type=types.Integer(big=True)), - primary_key=True, - ) - xp_enabled = Column(types.Boolean()) - xp_multiplier = Column(types.Real()) - eco_enabled = Column(types.Boolean()) - muted_role_id = Column(types.Integer(big=True), nullable=True) - do_logging = Column(types.Boolean()) - log_channel_id = Column(types.Integer(big=True), nullable=True) - do_verification = Column(types.Boolean()) - verification_type = Column(types.String()) # enum - verification_channel_id = Column(types.Integer(big=True), nullable=True) - - @classmethod - async def fetch(cls, guild_id: Union[str, int]) -> Optional["GuildConfig"]: - """Fetch a GuildConfig with the given guild ID.""" - query = "SELECT * FROM guildconfigs WHERE guild_id = $1" - record = await cls.pool.fetchrow(query, int(guild_id)) - - if record is None: - return None - - return cls(**record) - - @classmethod - async def fetch_or_404(cls, guild_id: Union[str, int]) -> Optional["GuildConfig"]: - """ - Fetch a guild configuration with the given ID or send a 404 error. - - :param Union[str, int] guild_id: The guild's id. - """ - - if guild_config := await cls.fetch(guild_id): - return guild_config - - http_status = HTTPStatus.NOT_FOUND - http_status.description = ( - f"Guild with ID {guild_id} doesn't exist or doesn't have a configuration." - ) - raise exceptions.NotFound(http_status) - - @classmethod - async def create( - cls, - guild_id: Union[str, int], - *, - xp_enabled: Optional[bool] = False, - xp_multiplier: Optional[float] = 1.0, - eco_enabled: Optional[bool] = False, - muted_role_id: Optional[Union[str, int]] = None, - do_logging: Optional[bool] = False, - log_channel_id: Optional[Union[str, int]] = None, - do_verification: Optional[bool] = False, - verification_type: Optional[ - Literal[ - "DISCORD_INTEGRATED", - "DISCORD_CODE", - "DISCORD_INTEGRATED_CODE", - "DISCORD_CAPTCHA", - "DISCORD_INTEGRATED_CAPTCHA", - "DISCORD_REACTION", - "DISCORD_INTEGRATED_REACTION", - ] - ] = "DISCORD_INTEGRATED", - verification_channel_id: Optional[Union[str, int]] = None, - ) -> Optional["GuildConfig"]: - """ - Create a new GuildConfig instance. - - Returns the new instance if created. - Returns `None` if a Unique Violation occurred. - """ - - if verification_type not in VerificationTypes.__members__: - raise ValueError( - f"verification_type must be one of {[m for m in VerificationTypes.__members__]}" - ) - - query = """ - INSERT INTO guildconfigs (guild_id, xp_enabled, xp_multiplier, eco_enabled, muted_role_id, - do_logging, log_channel_id, do_verification, verification_type, verification_channel_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT DO NOTHING - RETURNING *; - """ - - record = await cls.pool.fetchrow( - query, - int(guild_id), - xp_enabled, - xp_multiplier, - eco_enabled, - int(muted_role_id) if muted_role_id else None, - do_logging, - int(log_channel_id) if log_channel_id else None, - do_verification, - verification_type, - int(verification_channel_id) if verification_channel_id else None, - ) - - if record is None: - return None - - return cls(**record) - - async def update(self, **fields) -> Optional["GuildConfig"]: - """Update the GuildConfig with the given arguments.""" - - allowed_fields = ( - "xp_enabled", - "xp_multiplier", - "eco_enabled", - "muted_role_id", - "do_logging", - "log_channel_id", - "do_verification", - "verification_type", - "verification_channel_id", - ) - fields = { - name: fields.get(name, getattr(self, name)) for name in allowed_fields - } - - for name in ( - "muted_role_id", - "log_channel_id", - "verification_channel_id", - ): - if fields[name] is not None: - fields[name] = int(fields[name]) - - if ( - "verification_type" in fields - and fields["verification_type"] not in VerificationTypes.__members__ - ): - raise ValueError( - f"verification_type must be one of {[m for m in VerificationTypes.__members__]}" - ) - - query = """ - UPDATE guildconfigs - SET - xp_enabled = $2, - xp_multiplier = $3, - eco_enabled = $4, - muted_role_id = $5, - do_logging = $6, - log_channel_id = $7, - do_verification = $8, - verification_type = $9, - verification_channel_id = $10 - WHERE guild_id = $1 - RETURNING *; - """ - record = await self.pool.fetchrow(query, int(self.guild_id), *fields.values()) - - if record is None: - return None - - for field, value in record.items(): - setattr(self, field, value) - - return self - - async def delete(self) -> Optional["GuildConfig"]: - """Delete the GuildConfig.""" - - query = """ - DELETE FROM guildconfigs - WHERE guild_id = $1 - RETURNING *; - """ - record = await self.pool.fetchrow(query, self.guild_id) - - if record is None: - return None - - for field, value in record.items(): - setattr(self, field, value) - - return self diff --git a/api/models/token.py b/api/models/token.py deleted file mode 100644 index ffd19c5..0000000 --- a/api/models/token.py +++ /dev/null @@ -1,43 +0,0 @@ -from postDB import Model, Column, types - - -class Token(Model): - """ - Token class to store OAuth2 Tokens. - - :param str token: The access_token sent from discord_data. - :param dict data: All data returned by discord API. - :param int user_id: The discord user this token relates to - :param :class:`datetime.datetime` expires_at: The time this access token expires. - You can still use the refresh_token to generate a new token. - - """ - - user_id = Column( - types.ForeignKey("users", "id", sql_type=types.Integer(big=True)), - primary_key=True, - ) - expires_at = Column(types.DateTime) - token = Column(types.String) - data = Column(types.JSON) - - async def update(self): - """Create or update the Token instance.""" - query = """ - INSERT INTO tokens ( user_id, expires_at, token, data ) - VALUES ( $1, $2, $3, $4 ) - ON CONFLICT (user_id) DO UPDATE SET - expires_at = $2, - token = $3, - data = $4 - RETURNING * - """ - - record = await self.pool.fetchrow( - query, self.user_id, self.expires_at, self.token, self.data - ) - - for field, value in record.items(): - setattr(self, field, value) - - return self diff --git a/api/models/user.py b/api/models/user.py deleted file mode 100644 index eb1eee6..0000000 --- a/api/models/user.py +++ /dev/null @@ -1,116 +0,0 @@ -from postDB import Model, Column, types -from typing import Optional, Union -from datetime import datetime - -import utils - - -VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"}) -VALID_AVATAR_FORMATS = VALID_STATIC_FORMATS | {"gif"} - - -class User(Model): - """ - User class based on some discord data extended to better suit our application. - - Database Attributes: - Attributes stored in the `users` table. - - :param int id: The users Discord ID - :param str username: The users discord username. - :param int discriminator: The users discord discriminator. - :param str avatar: The users avatar hash, could be None. - :param str type: The type of User this is. USER|APP - """ - - id = Column(types.Integer(big=True), unique=True) - # Store the ID as a BIGINT even though it's transferred as a string. - # This is due to a substantial difference in index time and storage space - username = Column(types.String(length=32), primary_key=True) - discriminator = Column(types.String(length=4), primary_key=True) - avatar = Column(types.String, nullable=True) - type = Column(types.String, default="USER") - - @classmethod - async def fetch(cls, id: Union[str, int]) -> Optional["User"]: - """Fetch a user with the given ID.""" - query = "SELECT * FROM users WHERE id = $1" - user = await cls.pool.fetchrow(query, int(id)) - - if user is not None: - user = cls(**user) - - return user - - @classmethod - async def create( - cls, - id: Union[str, int], - username: str, - discriminator: str, - avatar: str = None, - type: str = "USER", - ) -> Optional["User"]: - """ - Create a new User instance. - - Returns the new instance if created. - Returns `None` if a Unique Violation occurred. - """ - query = """ - INSERT INTO users (id, username, discriminator, avatar, type) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT DO NOTHING - RETURNING *; - """ - - record = await cls.pool.fetchrow( - query, int(id), username, discriminator, avatar, type - ) - - if record is None: - return None - - return cls(**record) - - @property - def created_at(self) -> datetime: - """Returns """ - return utils.snowflake_time(self.id) - - def is_avatar_animated(self) -> bool: - """Indicates if the user has an animated avatar.""" - return bool(self.avatar and self.avatar.startswith("a_")) - - @property - def default_avatar_url(self) -> str: - return "https://cdn.discordapp.com/embed/avatars/%s.png" % ( - int(self.discriminator) % 5 - ) - - @property - def avatar_url_as(self, *, fmt=None, static_format="webp", size=1024): - if not size & (size - 1) and size in range(16, 4097): - raise RuntimeWarning("size must be a power of 2 between 16 and 4096") - if fmt is not None and fmt not in VALID_AVATAR_FORMATS: - raise RuntimeWarning( - "format must be None or one of {}".format(VALID_AVATAR_FORMATS) - ) - - if fmt == "gif" and not self.is_avatar_animated(): - raise RuntimeWarning("non animated avatars do not support gif format") - if static_format not in VALID_STATIC_FORMATS: - raise RuntimeWarning( - "static_format must be one of {}".format(VALID_STATIC_FORMATS) - ) - - if self.avatar is None: - return self.default_avatar_url + "?size=%s" % size - - if fmt is None: - fmt = "gif" if self.is_avatar_animated() else static_format - - return ( - "https://cdn.discordapp.com/avatars" - "/{0.id}/{0.avatar}.{1}?size={2}".format(self, fmt, size) - ) diff --git a/api/versions/__init__.py b/api/versions/__init__.py new file mode 100644 index 0000000..81fa23f --- /dev/null +++ b/api/versions/__init__.py @@ -0,0 +1,4 @@ +from . import v1 + + +__all__ = (v1,) diff --git a/api/versions/v1/__init__.py b/api/versions/v1/__init__.py new file mode 100644 index 0000000..0f2565f --- /dev/null +++ b/api/versions/v1/__init__.py @@ -0,0 +1,4 @@ +from .routers.router import router + + +__all__ = (router,) diff --git a/api/versions/v1/routers/router.py b/api/versions/v1/routers/router.py new file mode 100644 index 0000000..3bcd91f --- /dev/null +++ b/api/versions/v1/routers/router.py @@ -0,0 +1,3 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/v1") diff --git a/docker-compose.yml b/docker-compose.yml index 5982f5e..6ac26dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ version: "3.7" services: - - db: + postgres: image: postgres:13 ports: - "127.0.0.1:7777:5432" @@ -30,4 +29,4 @@ services: SECRET_KEY: "${SECRET_KEY}" DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID}" DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET}" - DB_URI: postgres://API:API@db:5432/API + POSTGRES_URI: postgres://API:API@postgres:5432/API diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 01b6ee0..0000000 --- a/docs/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# API docs - -## 📝 Table of Contents - -- [CLI docs](./cli.md) -- [`/auth`](./auth.md) -- [`/guilds`](./guilds.md) -- [`/users`](./users.md) - -## 📚 Writing your own docs - -Copy paste the [_template.md](./_template.md) file and rename it to your endpoint name. Fill in the sections with information about your endpoint: - -> Note that each section is marked by a comment, for example ```` needs to be replaced by the URL. - -- The base URL, for example ``/auth``. - -Repeat these for every endpoint: - -- The request method, usually ``GET`` or ``POST``. -- The URL, for example ``/auth/discord/redirect``. -- A brief description of the endpoint, you can simply copy paste the docstring you wrote. -- The expected data, describe what everything is for and the data type. -- The returned data, describe what is returned and its type. -- The possible status codes, for example ``200`` for success or ``404`` for not found. A whole list of status codes can be found [here](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes). - -You can then add it to the [table of contents](#-table-of-contents) and commit it. diff --git a/docs/_template.md b/docs/_template.md deleted file mode 100644 index 7cd6ba5..0000000 --- a/docs/_template.md +++ /dev/null @@ -1,57 +0,0 @@ -# - -- [ ](#) - -## - - - -### Parameters - -**Location:** - -Parameter | Type | Description ---------- | ---- | ----------- - | | - -#### Example request data - - - -```json -{ - "parameter_name": "example_parameter_value" -} -``` - - - -```http -/url?parameter_name=example_parameter_value -``` - -### Returned data - - - -Parameter | Type | Description ---------- | ---- | ----------- - | | - -#### Example response data - -```json -{ - "parameter_name": "example_parameter_value" -} -``` - -### Status codes - - - -- ``200`` Success - - -- ``400`` Bad Request - diff --git a/docs/auth.md b/docs/auth.md deleted file mode 100644 index 88e1abd..0000000 --- a/docs/auth.md +++ /dev/null @@ -1,82 +0,0 @@ -# ``/auth`` - -- [GET ``/auth/discord/redirect``](#get-authdiscordredirect) -- [POST (or GET) ``/auth/discord/callback``](#post-or-get-authdiscordcallback) - -## GET ``/auth/discord/redirect`` - -Redirect user to correct Discord OAuth link depending on specified domain. - -### Parameters - -**Location:** ``querystring`` - -Parameter | Type | Description ---------- | ---- | ----------- -callback | Optional ``str`` | The URL to redirect to, after the Discord OAuth is passed (default: `http://127.0.0.1:5000/auth/discord/callback`) - -#### Example request data - -For a redirect to ``http://127.0.0.1:5000/auth/discord/callback`` - -```http -/auth/discord/callback?callback=http%3A%2F%2F127.0.0.1%3A5000%2Fauth%2Fdiscord%2Fcallback -``` - -### Status codes - -- ``302`` Moved temporarily - Redirect to the Discord OAuth portal succeeded. - -- ``400`` Bad Request - Callback URL provided isn't a well formed redirect URL. - -## POST (or GET) ``/auth/discord/callback`` - -Callback endpoint for finished discord authorization flow. ``GET`` method only allowed in debug mode, gets the code from the querystring. - -### Parameters - -**Location:** ``body`` (or ``querystring`` with ``GET`` in debug mode) - -Parameter | Type | Description ---------- | ---- | ----------- -code | ``str`` | The code from the Discord OAuth -callback | ``str`` | The same callback URL as provided in [/auth/discord/redirect](./redirect.md#parameters). Optional with ``GET`` method - -#### Example request data - -```json -{ - "code": "VQkVO7eHGom6GYRcUR0hNA9WFrbPVF", - "callback": "http://127.0.0.1:5000/auth/discord/callback" -} -``` - -### Returned data - -Returns the token and its expire date. - -Parameter | Type | Description ---------- | ---- | ----------- -token | ``str`` | Access token for the API -exp | ``str`` | Expire datetime of the token in [ISO 8601](https://wikipedia.org/wiki/ISO_8601) format - -#### Example response data - -```json -{ - "token": "eyJ0eXAiOiJKV1QiLCJhbGcuOiJIVzI1NiJ9.eyJ1aWQiOjQwATM0NjA3OZczMzMxNzYzNQwiZXhwIcoxNjExMDc3MDcvLCJpYXQiUjE2MTA0NzEyNyF9.UN7o5giVI_xLcSAS-6QGumvTXv0Q-wpYU00Xsjcd-_U", - "exp": "2021-01-19T17:07:51.313285" -} -``` - -### Status codes - -- ``200`` Success - Returns the token and its expiry date. - -- ``400`` Bad Request - Code is missing in JSON data or querystring arguments. - Callback URL provided isn't a well formed redirect URL. - Discord returned a `400` status. diff --git a/docs/guilds.md b/docs/guilds.md deleted file mode 100644 index 4e88cb2..0000000 --- a/docs/guilds.md +++ /dev/null @@ -1,581 +0,0 @@ -# ``/guilds`` - -- [POST ``/guilds``](#post-guilds) -- [GET ``/guilds/``](#get-guildsid) -- [PATCH ``/guilds/``](#patch-guildsid) -- [DELETE ``/guilds/``](#delete-guildsid) -- [GET ``/guilds//icon``](#get-guildsidicon) -- [POST ``/guilds//config``](#post-guildsidconfig) -- [GET ``/guilds//config``](#get-guildsidconfig) -- [PATCH ``/guilds//config``](#patch-guildsidconfig) -- [DELETE ``/guilds//config``](#delete-guildsidconfig) - -## POST ``/guilds`` - -Create a guild from the request body. - -### Parameters - -**Location:** ``body`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``str`` or ``int`` | The guild's ID -name | ``str`` | The guild's name -owner_id | ``str`` or ``int`` | The guild's owner's ID -icon_hash | Optional ``str`` | The guild's icon's hash (default: `null`) - -#### Example request data - -```json -{ - "id": 501090983539245061, - "name": "Tech With Tim", - "owner_id": 501089409379205161, - "icon_hash": "a_5aa83d87a200585758846a075ffc52ba" -} -``` - -### Returned data - -Returns the created guild. - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``str`` | The guild's ID -name | ``str`` | The guild's name -owner_id | ``str`` | The guild's owner's ID -icon_hash | ``str`` or ``null`` | The guild's icon's hash - -#### Example response data - -```json -{ - "id": 501090983539245061, - "name": "Tech With Tim", - "owner_id": 501089409379205161, - "icon_hash": "a_5aa83d87a200585758846a075ffc52ba" -} -``` - -### Status codes - -- ``201`` Created - The guild was created in the database. - -- ``400`` Bad Request - Missing a required parameter. - -- ``409`` Conflict - A guild with the provided ID already exists. - -## GET ``/guilds/`` - -Get a guild from its ID. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -#### Example request data - -```http -/guild/501090983539245061 -``` - -### Returned data - -Returns the guild. - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``str`` | The guild's ID -name | ``str`` | The guild's name -owner_id | ``str`` | The guild's owner's ID -icon_hash | ``str`` or ``null`` | The guild's icon's hash - -#### Example response data - -```json -{ - "id": 501090983539245061, - "name": "Tech With Tim", - "owner_id": 501089409379205161, - "icon_hash": "a_5aa83d87a200585758846a075ffc52ba" -} -``` - -### Status codes - -- ``200`` Success - The guild is returned. - -- ``404`` Not found - Guild with provided ID doesn't exist. - -## PATCH ``/guilds/`` - -Update elements of a guild from the request body. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -**Location:** ``body`` - -Parameter | Type | Description ---------- | ---- | ----------- -name | Optional ``str`` | The guild's new name -owner_id | Optional ``str`` or ``int`` | The guild's new owner's ID -icon_hash | Optional ``str`` | The guild's new icon's hash - -#### Example request data - -```http -/guild/501090983539245061 -``` - -```json -{ - "name": "Tech With Tom", -} -``` - -### Returned data - -Returns the updated guild. - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``str`` | The guild's ID -name | ``str`` | The guild's name -owner_id | ``str`` | The guild's owner's ID -icon_hash | ``str`` or ``null`` | The guild's icon's hash - -#### Example response data - -```json -{ - "id": 501090983539245061, - "name": "Tech With Tom", - "owner_id": 501089409379205161, - "icon_hash": "a_5aa83d87a200585758846a075ffc52ba" -} -``` - -### Status codes - -- ``200`` Success - The guild was updated and is returned. - -- ``404`` Not found - Guild with provided ID doesn't exist. - -## DELETE ``/guilds/`` - -Delete a guild from its ID. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -#### Example request data - -```http -/guild/501090983539245061 -``` - -### Returned data - -Returns the deleted guild. - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``str`` | The guild's ID -name | ``str`` | The guild's name -owner_id | ``str`` | The guild's owner's ID -icon_hash | ``str`` or ``null`` | The guild's icon's hash - -#### Example response data - -```json -{ - "id": 501090983539245061, - "name": "Tech With Tim", - "owner_id": 501089409379205161, - "icon_hash": "a_5aa83d87a200585758846a075ffc52ba" -} -``` - -### Status codes - -- ``200`` Success - The guild was deleted and is returned. - -- ``404`` Not found - Guild with provided ID doesn't exist. - -## GET ``/guilds//icon`` - -Get a guild's icon from its ID. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -**Location:** ``querystring`` - -Parameter | Type | Description | Options ---------- | ---- | ----------- | ------- -format | Optional ``str`` | The desired icon format (default: `gif` if animated or `static_format` parameter) | `png`, `jpeg`, `jpg`, `webp`, `gif` only if animated -static_format | Optional ``str`` | The desired static icon format if `format` parameter isn't provided (default: `webp`) | `png`, `jpeg`, `jpg`, `webp` -size | Optional ``int`` | The desired icon size, needs to be a power of 2 between 16 and 4096 (default: `128`) | `16`, `32`, `64`, `128`, `256`, `512`, `1024`, `2048`, `4096` - -#### Example request data - -```http -/guild/501090983539245061/icon?format=png&size=2048 -``` - -### Returned data - -Redirect to Discord's icon CDN. - -### Status codes - -- ``302`` Found - Redirect to Discord's icon CDN. - -- ``400`` Bad Request - `size` parameter must be an integer. - `size` parameter must be a power of 2 between 16 and 4096. - `format` parameter must be one of `png`, `jpeg`, `jpg`, `webp`, `gif` only if animated. - `static_format` parameter must be one of `png`, `jpeg`, `jpg`, `webp`. - Non animated guild avatars do not support `gif` format. - -- ``404`` Not found - Guild with provided ID doesn't exist. - -## POST ``/guilds//config`` - -Create a guild configuration from the request body. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -**Location:** ``body`` - -Parameter | Type | Description ---------- | ---- | ----------- -xp_enabled | Optional ``bool`` | Whether there's is XP for every message sent in the guild (default: `false`) -xp_multiplier | Optional ``float`` | The multiplier of the XP for the guild (default: `1.0`) -eco_enabled | Optional ``bool`` | Whether the economy commands are enabled for the guild (default: `false`) -muted_role_id | Optional ``str`` | The guild's muted role's ID (default: `null`) -do_logging | Optional ``bool`` | Whether to do message logging for the guild (default: `false`) -log_channel_id | Optional ``str`` | The guild's log channel's ID (default: `null`) -do_logging | Optional ``bool`` | Whether to do new member verifictaion for the guild (default: `false`) -verification_type | Optional ``str`` | The guild's verification type, must be one of [these](#verification-types) (default: `DISCORD_INTEGRATED`) -verification_channel_id | Optional ``str`` | The guild's verification channel's ID (default: `null`) - -#### Verification types - -Name | Description ----- | ----------- -``DISCORD_INTEGRATED`` | [Discord's integrated verification.](https://support.discord.com/hc/fr/articles/1500000466882-Rules-Screening-FAQ) -``DISCORD_CODE`` | Send a code to the user in DMs and make him send it in a channel. -``DISCORD_INTEGRATED_CODE`` | Combination of `DISCORD_INTEGRATED` and `DISCORD_CODE`. -``DISCORD_CAPTCHA`` | Send a picture with letters and numbers to the user in DMs then make him write those characters in a channel. -``DISCORD_INTEGRATED_CAPTCHA`` | Combination of `DISCORD_INTEGRATED` and `DISCORD_CAPTCHA`. -``DISCORD_REACTION`` | Make the user react to a message in a channel. -``DISCORD_INTEGRATED_REACTION`` |Combination of `DISCORD_INTEGRATED` and `DISCORD_REACTION`. - -#### Example request data - -```http -/guild/501090983539245061/config -``` - -```json -{ - "xp_enabled": true, - "eco_enabled": true, - "muted_role_id": 583350495117312010, - "do_logging": true, - "log_channel_id": 536617175369121802 -} -``` - -### Returned data - -Returns the created guild configuration. - -Parameter | Type | Description ---------- | ---- | ----------- -guild_id | ``str`` | The guild's ID -xp_enabled | ``bool`` | Whether there's is XP for every message sent in the guild -xp_multiplier | ``float`` | The multiplier of the XP for the guild -eco_enabled | ``bool`` | Whether the economy commands are enabled for the guild -muted_role_id | ``str`` or ``null`` | The guild's muted role's ID -do_logging | ``bool`` | Whether to do message logging for the guild -log_channel_id | ``str`` or ``null`` | The guild's log channel's ID -do_logging | ``bool`` | Whether to do new member verifictaion for the guild -verification_type | ``str`` | The guild's verification type -verification_channel_id | ``str`` or ``null`` | The guild's verification channel's ID - -#### Example response data - -```json -{ - "guild_id": "501090983539245061", - "xp_enabled": true, - "xp_multiplier": 1.0, - "eco_enabled": true, - "muted_role_id": "583350495117312010", - "do_logging": true, - "log_channel_id": "536617175369121802", - "do_verification": false, - "verification_type": "DISCORD_INTEGRATED", - "verification_channel_id": null, -} -``` - -### Status codes - -- ``201`` Created - The guild configuration was created in the database. - -- ``400`` Bad request - `verification_type` parameter must be one of [these](#verification-types). - -- ``404`` Not found - Guild with provided ID doesn't exist. - -- ``409`` Conflict - The guild already has a configuration. - -## GET ``/guilds//config`` - -Get a guild configuration. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -#### Example request data - -```http -/guild/501090983539245061/config -``` - -### Returned data - -Returns the guild configuration. - -Parameter | Type | Description ---------- | ---- | ----------- -guild_id | ``str`` | The guild's ID -xp_enabled | ``bool`` | Whether there's is XP for every message sent in the guild -xp_multiplier | ``float`` | The multiplier of the XP for the guild -eco_enabled | ``bool`` | Whether the economy commands are enabled for the guild -muted_role_id | ``str`` or ``null`` | The guild's muted role's ID -do_logging | ``bool`` | Whether to do message logging for the guild -log_channel_id | ``str`` or ``null`` | The guild's log channel's ID -do_logging | ``bool`` | Whether to do new member verifictaion for the guild -verification_type | ``str`` | The guild's verification type -verification_channel_id | ``str`` or ``null`` | The guild's verification channel's ID - -#### Example response data - -```json -{ - "guild_id": "501090983539245061", - "xp_enabled": true, - "xp_multiplier": 1.0, - "eco_enabled": true, - "muted_role_id": "583350495117312010", - "do_logging": true, - "log_channel_id": "536617175369121802", - "do_verification": false, - "verification_type": "DISCORD_INTEGRATED", - "verification_channel_id": null, -} -``` - -### Status codes - -- ``200`` Success - The guild is returned. - -- ``404`` Not found - Guild with provided ID doesn't exist. - Guild with provided ID doesn't have a configuration. - -## PATCH ``/guilds//config`` - -Update a guild configuration from the request body. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -**Location:** ``body`` - -Parameter | Type | Description ---------- | ---- | ----------- -xp_enabled | Optional ``bool`` | Whether there's is XP for every message sent in the guild -xp_multiplier | Optional ``float`` | The new multiplier of the XP for the guild -eco_enabled | Optional ``bool`` | Whether the economy commands are enabled for the guild -muted_role_id | Optional ``str`` | The guild's new muted role's ID -do_logging | Optional ``bool`` | Whether to do message logging for the guild -log_channel_id | Optional ``str`` | The guild's new log channel's ID -do_logging | Optional ``bool`` | Whether to do new member verifictaion for the guild -verification_type | Optional ``str`` | The guild's new verification type, must be one of [these](#verification-types) -verification_channel_id | Optional ``str`` | The guild's new verification channel's ID - -#### Example request data - -```http -/guild/501090983539245061 -``` - -```json -{ - "xp_multiplier": 2.0, -} -``` - -### Returned data - -Returns the updated guild configuration. - -Parameter | Type | Description ---------- | ---- | ----------- -guild_id | ``str`` | The guild's ID -xp_enabled | ``bool`` | Whether there's is XP for every message sent in the guild -xp_multiplier | ``float`` | The multiplier of the XP for the guild -eco_enabled | ``bool`` | Whether the economy commands are enabled for the guild -muted_role_id | ``str`` or ``null`` | The guild's muted role's ID -do_logging | ``bool`` | Whether to do message logging for the guild -log_channel_id | ``str`` or ``null`` | The guild's log channel's ID -do_logging | ``bool`` | Whether to do new member verifictaion for the guild -verification_type | ``str`` | The guild's verification type -verification_channel_id | ``str`` or ``null`` | The guild's verification channel's ID - -#### Example response data - -```json -{ - "guild_id": "501090983539245061", - "xp_enabled": true, - "xp_multiplier": 2.0, - "eco_enabled": true, - "muted_role_id": "583350495117312010", - "do_logging": true, - "log_channel_id": "536617175369121802", - "do_verification": false, - "verification_type": "DISCORD_INTEGRATED", - "verification_channel_id": null, -} -``` - -### Status codes - -- ``200`` Success - The guild configuration was updated and is returned. - -- ``400`` Bad request - `verification_type` parameter must be one of [these](#verification-types). - -- ``404`` Not found - Guild with provided ID doesn't exist. - Guild with provided ID doesn't have a configuration. - -## DELETE ``/guilds//config`` - -Delete a guild configuration. - -### Parameters - -**Location:** ``url`` - -Parameter | Type | Description ---------- | ---- | ----------- -id | ``int`` | The guild's ID - -#### Example request data - -```http -/guild/501090983539245061 -``` - -### Returned data - -Returns the deleted guild configuration. - -Parameter | Type | Description ---------- | ---- | ----------- -guild_id | ``str`` | The guild's ID -xp_enabled | ``bool`` | Whether there's is XP for every message sent in the guild -xp_multiplier | ``float`` | The multiplier of the XP for the guild -eco_enabled | ``bool`` | Whether the economy commands are enabled for the guild -muted_role_id | ``str`` or ``null`` | The guild's muted role's ID -do_logging | ``bool`` | Whether to do message logging for the guild -log_channel_id | ``str`` or ``null`` | The guild's log channel's ID -do_logging | ``bool`` | Whether to do new member verifictaion for the guild -verification_type | ``str`` | The guild's verification type -verification_channel_id | ``str`` or ``null`` | The guild's verification channel's ID - -#### Example response data - -```json -{ - "guild_id": "501090983539245061", - "xp_enabled": true, - "xp_multiplier": 1.0, - "eco_enabled": true, - "muted_role_id": "583350495117312010", - "do_logging": true, - "log_channel_id": "536617175369121802", - "do_verification": false, - "verification_type": "DISCORD_INTEGRATED", - "verification_channel_id": null, -} -``` - -### Status codes - -- ``200`` Success - The guild configuration was deleted and is returned. - -- ``404`` Not found - Guild with provided ID doesn't exist. - Guild with provided ID doesn't have a configuration. diff --git a/docs/users.md b/docs/users.md deleted file mode 100644 index fa788ad..0000000 --- a/docs/users.md +++ /dev/null @@ -1,67 +0,0 @@ -# ``/users`` - -- [GET ``/users``](#get-users) -- [GET ``/users/@me``](#get-users-me) -- [GET ``/users/``](#get-users-user_id) - -## Model -Parameter | Type | Description ---------------- | --------------------- | ----------- -id | ``Union[int, str]`` | Discord ID -> `int` internally, but `str` when transferred. -username | ``str`` | Discord username. -discriminator | ``str`` | Discord discriminator. -avatar | ``Optional[str]`` | Discord avatar. -type | ``str`` | The type of user this is. - - -## GET ``/users`` -> GET `User` objects by bulk. - -### Parameters - -**Location:** ``querystring`` - -Parameter | Type | Description ---------------- | --------------------- | ----------- -type | ``Optional[str]`` | Only fetch users of this type. -username | ``Optional[str]`` | Only fetch users with this username. -discriminator | ``Optional[str]`` | Only fetch users with this discriminator -page | ``Optional[int]`` | Pagination page. -limit | ``Optional[int]`` | Max number of records to return. - -#### Example request: - -To only fetch users with the `1606` discriminator. -```http -GET /users?discriminator?1606 -``` - -### Status codes - - ``200`` Success\ - Returns: ``List[``[``User``](#model)``]`` - - ``400`` Bad Request - -## GET ``/users/@me`` -> Returns the currently authorized `User` object. - -#### Example request: -```http -GET /users/@me -``` - -### Status codes - - ``200`` Success\ - Returns: [``User``](#model) - -## GET ``/users/`` -> Returns the provided user if it exists. - -#### Example request: -```http -GET /users/144112966176997376 -``` - -### Status codes' - - ``200`` Success\ - Returns: [``User``](#model) - - ``404`` Not Found diff --git a/launch.py b/launch.py index 2cc1913..3c0e693 100644 --- a/launch.py +++ b/launch.py @@ -1,16 +1,12 @@ -from api import app - -from typing import Any, Coroutine, Iterable -from hypercorn.asyncio import serve -from hypercorn.config import Config +from uvicorn.supervisors import ChangeReload +from uvicorn import Config, Server +from typing import Any, Coroutine from postDB import Model +from api import config import logging import asyncio import asyncpg import click -import sys -import os - logging.basicConfig(level=logging.INFO) @@ -25,50 +21,6 @@ asyncio.set_event_loop(loop) -def load_env(fp: str, args: Iterable[str], exit_on_missing: bool = True) -> dict: - """ - Load all env values from `args`. - - :param fp: Local file to load from - :param args: Arguments to load - :param exit_on_missing: Exit on missing env values? - """ - if not (env := {arg: None for arg in args}): - return env # Return if `args` is empty. - - try: - with open(fp) as f: - env_file = { - key.strip(): arg.strip() - for (key, arg) in [ - line.strip().split("=") for line in f.readlines() if line.strip() - ] - } - except FileNotFoundError: - env_file = {} - - for key in args: - try: - env[key] = os.environ[key] - except KeyError: - try: - env[key] = env_file[key] - os.environ[key] = env[key] - except KeyError: - if exit_on_missing: - sys.stderr.write( - "Found no `%s` var in env, exiting..." % key - ), exit(1) - - sys.stderr.write( - "Found no `%s` var in env, setting as empty string." % key - ) - env[key] = "" - os.environ[key] = "" - - return env - - def run_async(coro: Coroutine) -> Any: """ Used to run coroutines outside any coroutine. @@ -136,6 +88,14 @@ async def safe_create_tables(verbose: bool = False) -> None: log.info("Attempting to create %s tables." % len(models_ordered)) + with open("snowflake.sql") as f: + query = f.read() + + if verbose: + print(query) + + await Model.pool.execute(query) + for model in models_ordered: await model.create_table(verbose=verbose) log.info("Created table %s" % model.__tablename__) @@ -154,8 +114,16 @@ async def delete_tables(verbose: bool = False): await model.drop_table(verbose=verbose) log.info("Dropped table %s" % type(model).__tablename__) + await Model.pool.execute("DROP FUNCTION IF EXISTS create_snowflake") + await Model.pool.execute("DROP SEQUENCE IF EXISTS global_snowflake_id_seq") + + +@click.group() +def cli(): + pass + -@app.cli.command(name="initdb") +@cli.command(name="initdb") @click.option("-v", "--verbose", default=False, is_flag=True) def _initdb(verbose: bool): """ @@ -164,36 +132,43 @@ def _initdb(verbose: bool): :param verbose: Print SQL statements when creating models? """ if not run_async( - prepare_postgres(retries=6, interval=10.0, db_uri=ENV["DB_URI"], loop=loop) + prepare_postgres( + retries=6, interval=10.0, db_uri=config.postgres_uri(), loop=loop + ) ): exit(1) # Connecting to our postgres server failed. run_async(safe_create_tables(verbose=verbose)) -@app.cli.command() +@cli.command(name="dropdb") @click.option("-v", "--verbose", default=False, is_flag=True) -def dropdb(verbose: bool): +def _dropdb(verbose: bool): """ Drops all tables defined in the app. :param verbose: Print SQL statements when dropping models? """ if not run_async( - prepare_postgres(retries=6, interval=10.0, db_uri=ENV["DB_URI"], loop=loop) + prepare_postgres( + retries=6, interval=10.0, db_uri=config.postgres_uri(), loop=loop + ) ): exit(1) # Connecting to our postgres server failed. run_async(delete_tables(verbose=verbose)) -@app.cli.command() +@cli.command() +@click.option("-p", "--port", default=5000) @click.option("-h", "--host", default="127.0.0.1") -@click.option("-p", "--port", default="5000") @click.option("-d", "--debug", default=False, is_flag=True) @click.option("-i", "--initdb", default=False, is_flag=True) +@click.option("-r", "--reload", default=False, is_flag=True) @click.option("-v", "--verbose", default=False, is_flag=True) -def runserver(host: str, port: str, debug: bool, initdb: bool, verbose: bool): +def runserver( + host: str, port: str, debug: bool, initdb: bool, verbose: bool, reload: bool +): """ Run the Quart app. @@ -203,30 +178,35 @@ def runserver(host: str, port: str, debug: bool, initdb: bool, verbose: bool): :param initdb: Create models before running API? :param verbose: Set logging to DEBUG instead of INFO """ + config.set_debug(debug) if verbose: logging.basicConfig(level=logging.DEBUG) if not run_async( - prepare_postgres(retries=6, interval=10.0, db_uri=ENV["DB_URI"], loop=loop) + prepare_postgres( + retries=6, interval=10.0, db_uri=config.postgres_uri(), loop=loop + ) ): exit(1) # Connecting to our postgres server failed. - if initdb: - run_async(safe_create_tables(verbose=verbose)) + server_config = Config( + "api.app:app", reload=reload, host=host, port=port, debug=debug + ) + server = Server(config=server_config) - app.debug = debug + async def worker(): + if initdb: + await safe_create_tables(verbose=verbose) - config = Config() - config.bind = [host + ":" + port] - run_async(serve(app, config)) + if reload: + sock = config.bind_socket() + ChangeReload(config, target=server.run, sockets=[sock]).run() + else: + await server.serve() + run_async(worker()) -if __name__ == "__main__": - ENV = load_env( - fp="./local.env", - args=("SECRET_KEY", "DB_URI", "DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"), - ) - app.config.from_mapping(mapping=ENV) - app.cli() +if __name__ == "__main__": + cli() diff --git a/prod.Dockerfile b/prod.Dockerfile new file mode 100644 index 0000000..6aee172 --- /dev/null +++ b/prod.Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.8-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONFAULTHANDLER 1 + +# Let service stop gracefully +STOPSIGNAL SIGQUIT + +# Copy project files into working directory +WORKDIR /app + +RUN apt-get update && apt-get install gcc -y + +COPY Pipfile Pipfile.lock ./ + +RUN pip install pipenv +RUN pipenv install --deploy --system + +ADD . /app + +# Run the API. +CMD python launch.py runserver --initdb --verbose diff --git a/snowflake.sql b/snowflake.sql new file mode 100644 index 0000000..437eb23 --- /dev/null +++ b/snowflake.sql @@ -0,0 +1,21 @@ +CREATE SEQUENCE IF NOT EXISTS global_snowflake_id_seq; + +CREATE OR REPLACE FUNCTION create_snowflake(shard_id INT DEFAULT 1) + RETURNS bigint + LANGUAGE 'plpgsql' +AS $$ +DECLARE + our_epoch bigint := 1609459200; + seq_id bigint; + now_millis bigint; + result bigint:= 0; +BEGIN + SELECT nextval('global_snowflake_id_seq') % 1024 INTO seq_id; + + SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO now_millis; + result := (now_millis - our_epoch) << 22; + result := result | (shard_id << 9); + result := result | (seq_id); + return result; +END; +$$; diff --git a/tests/conftest.py b/tests/conftest.py index e7b68e1..766207a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,10 @@ -from api import app as quart_app -from api.models import User -from launch import load_env, prepare_postgres, safe_create_tables, delete_tables +from launch import prepare_postgres, safe_create_tables, delete_tables +from api import config -from quart.testing import QuartClient +from fastapi.testclient import TestClient from postDB import Model -import pytest import asyncio -import datetime -import jwt -import os +import pytest @pytest.fixture(scope="session") @@ -21,30 +17,15 @@ def event_loop(): @pytest.fixture(scope="session") -def app(event_loop) -> QuartClient: - return quart_app.test_client() - +def app(event_loop) -> TestClient: + from api import app -@pytest.fixture(scope="session") -async def auth_app(event_loop, db) -> QuartClient: - auth_client = quart_app.test_client() - user = await User.create(1, "test", "0000", type="APP") - auth_client.user = user - auth_client.token = jwt.encode( - { - "uid": user.id, - "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1), - "iat": datetime.datetime.utcnow(), - }, - key=os.environ["SECRET_KEY"], - ) - return auth_client + return TestClient(app) @pytest.fixture(scope="session") async def db(event_loop) -> bool: - env = load_env("./local.env", ("TEST_DB_URI",)) - assert await prepare_postgres(db_uri=env["TEST_DB_URI"], loop=event_loop) + assert await prepare_postgres(db_uri=config.test_postgres_uri(), loop=event_loop) await safe_create_tables() yield Model.pool await delete_tables() diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index 9d11249..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,53 +0,0 @@ -from api.blueprints.auth.views.tokens import get_redirect, SCOPES -from launch import load_env - -from quart.testing import QuartClient -import pytest - - -load_env(fp="./local.env", args=("DISCORD_CLIENT_ID",), exit_on_missing=False) - - -@pytest.mark.asyncio -async def test_auth_redirect_no_qs(app: QuartClient): - response = await app.get("/auth/discord/redirect") - correct_location = get_redirect( - callback="http://localhost/auth/discord/callback", - # app.get requests to localhost but `127.0.0.1` should be used in development. - scopes=SCOPES, - ) - assert response.headers["Location"] == correct_location - assert response.mimetype == "text/html" - assert response.status_code == 302 - - -@pytest.mark.asyncio -async def test_auth_redirect_invalid_qs(app: QuartClient): - response = await app.get("/auth/discord/redirect?callback=invalid") - assert await response.json == { - "error": "Bad Request", - "message": "Not a well formed redirect URL.", - } - assert response.mimetype == "application/json" - assert response.status_code == 400 - - -@pytest.mark.asyncio -async def test_auth_redirect_valid_qs(app: QuartClient): - response = await app.get("/auth/discord/redirect?callback=http://test.com") - correct_location = get_redirect( - callback="http://test.com", - scopes=SCOPES, - ) - assert response.headers["Location"] == correct_location - assert response.mimetype == "text/html" - assert response.status_code == 302 - - -@pytest.mark.asyncio -async def test_auth_callback_no_qs(app: QuartClient): - response = await app.get("/auth/discord/callback") - - expected_status = 400 if app.app.debug else 405 - assert response.status_code == expected_status - assert response.mimetype == "application/json" diff --git a/tests/test_decorators.py b/tests/test_decorators.py deleted file mode 100644 index 267f6ea..0000000 --- a/tests/test_decorators.py +++ /dev/null @@ -1,53 +0,0 @@ -from api import app as quart_app - -from quart.testing import QuartClient -from quart import jsonify -import pytest - - -import utils - - -@quart_app.route("/app_only") -@utils.app_only -async def endpoint1(): - return jsonify(status="OK") - - -@quart_app.route("/auth_required") -@utils.auth_required -async def endpoint2(): - return jsonify(status="OK") - - -@pytest.fixture(name="app") -def _test_app() -> QuartClient: - return quart_app.test_client() - - -# TODO: Add tests where we provide authorization and test user type. -# Database and env values are required for this. - - -@pytest.mark.asyncio -async def test_app_only_401(app: QuartClient): - response = await app.get("/app_only") - - assert response.status_code == 401 - assert response.mimetype == "application/json" - assert (await response.json) == { - "error": "Unauthorized", - "message": "No permission -- see authorization schemes", - } - - -@pytest.mark.asyncio -async def test_auth_required_401(app: QuartClient): - response = await app.get("/auth_required") - - assert response.status_code == 401 - assert response.mimetype == "application/json" - assert (await response.json) == { - "error": "Unauthorized", - "message": "No permission -- see authorization schemes", - } diff --git a/tests/test_guilds.py b/tests/test_guilds.py deleted file mode 100644 index dc66975..0000000 --- a/tests/test_guilds.py +++ /dev/null @@ -1,139 +0,0 @@ -from api.models import Guild - -from quart.testing import QuartClient -import pytest - - -def guild_to_dict(guild: Guild) -> dict: - return { - "id": str(guild.id), - "name": guild.name, - "owner_id": str(guild.owner_id), - "icon_hash": guild.icon_hash, - } - - -@pytest.fixture(name="guild", scope="session") -async def _guild(): - return await Guild.create( - id=501090983539245061, - name="Tech With Tim", - owner_id=501089409379205161, - icon_hash="a_5aa83d87a200585758846a075ffc52ba", - ) - - -@pytest.mark.asyncio -@pytest.mark.db -@pytest.mark.parametrize( - ("data", "status_code"), - [ - ( - { - "id": "1", - "name": "a", - "owner_id": "1", - }, - 201, - ), - ( - { - "id": 2, - "name": "b", - "owner_id": 2, - }, - 201, - ), - ( - { - "id": 3, - "name": "c", - "owner_id": 3, - "icon_hash": "abcdefgh", - }, - 201, - ), - ( - { - "id": 3, - "name": "c", - "owner_id": 3, - }, - 409, - ), - ( - {}, - 400, - ), - ( - None, - 400, - ), - ], -) -async def test_create_guild(auth_app: QuartClient, db, data: dict, status_code: int): - response = await auth_app.post( - "/guilds", json=data, headers={"authorization": auth_app.token} - ) - assert response.content_type == "application/json" - assert response.status_code == status_code - if status_code == 201: - assert (await response.json) == { - n: str(data.get(n, "")) or None - for n in ("id", "name", "owner_id", "icon_hash") - } - assert response.headers["Location"] == f"/guilds/{data['id']}" - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_get_guild(app: QuartClient, db, guild: Guild): - response = await app.get(f"/guilds/{guild.id}") - assert response.status_code == 200 - assert response.content_type == "application/json" - assert (await response.json) == guild_to_dict(guild) - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_get_guild_404(app: QuartClient, db): - response = await app.get("/guilds/0") # spamming random digits on keyboard - assert response.status_code == 404 - assert response.content_type == "application/json" - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_patch_guild(auth_app: QuartClient, db): - guild = await Guild.create( - id=420, - name="Tim is the best", - owner_id=501089409379205161, - icon_hash="63fadd7a1f176935279865f88bd3c1e8", - ) - response = await auth_app.patch( - f"/guilds/{guild.id}", - json={"owner_id": 268837679884402688, "name": "avib is the best"}, - headers={"authorization": auth_app.token}, - ) - assert response.status_code == 200 - assert response.content_type == "application/json" - guild = await Guild.fetch(guild.id) # guild was updated in db, need to fetch again - assert (await response.json) == guild_to_dict(guild) - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_delete_guild(auth_app: QuartClient, db): - guild = await Guild.create( - id=781645181744316476, - name="postDB", - owner_id=144112966176997376, - icon_hash="ffa2d83b0779a1cf240f8df018324be6", - ) - response = await auth_app.delete( - f"/guilds/{guild.id}", headers={"authorization": auth_app.token} - ) - assert response.status_code == 204 - guild = await Guild.fetch(guild.id) # guild was deleted in db, need to fetch again - assert guild is None diff --git a/tests/test_index.py b/tests/test_index.py deleted file mode 100644 index 8547147..0000000 --- a/tests/test_index.py +++ /dev/null @@ -1,10 +0,0 @@ -from quart.testing import QuartClient -import pytest - - -@pytest.mark.asyncio -async def test_index(app: QuartClient): - response = await app.get("/") - assert response.status_code == 200 - assert response.content_type == "application/json" - assert await response.get_json() == {"status": "OK"} diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 12b97b1..0000000 --- a/tests/test_users.py +++ /dev/null @@ -1,111 +0,0 @@ -from launch import load_env - -from quart.testing import QuartClient -import pytest - - -load_env(fp="./local.env", args=("SECRET_KEY",), exit_on_missing=False) - - -@pytest.mark.asyncio -async def test_users_no_auth(app: QuartClient): - response = await app.get("/users") - - assert response.status_code == 401 - assert response.mimetype == "application/json" - assert (await response.json) == { - "error": "Unauthorized", - "message": "No permission -- see authorization schemes", - } - - -@pytest.mark.asyncio -async def test_users_me_no_auth(app: QuartClient): - response = await app.get("/users/@me") - - assert response.status_code == 401 - assert response.mimetype == "application/json" - assert (await response.json) == { - "error": "Unauthorized", - "message": "No permission -- see authorization schemes", - } - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_users_me(auth_app: QuartClient): - response = await auth_app.get( - "/users/@me", headers={"Authorization": auth_app.token} - ) - - assert response.status_code == 200 - assert response.mimetype == "application/json" - assert (await response.json) == { - "avatar": None, - "discriminator": "0000", - "id": "1", - "type": "APP", - "username": "test", - } - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_users_specific_user(auth_app: QuartClient): - response = await auth_app.get( - f"/users/{auth_app.user.id}", headers={"Authorization": auth_app.token} - ) - - assert response.status_code == 200 - assert response.mimetype == "application/json" - assert (await response.json) == { - "avatar": None, - "discriminator": "0000", - "id": "1", - "type": "APP", - "username": "test", - } - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_users_invalid_specific_user(auth_app: QuartClient): - response = await auth_app.get( - "/users/100", headers={"Authorization": auth_app.token} - ) - - assert response.status_code == 400 - assert response.mimetype == "application/json" - assert (await response.json) == { - "error": "NotFound", - "message": "Could not find the requested user in our database.", - } - - -@pytest.mark.asyncio -@pytest.mark.db -async def test_users_bulk(auth_app: QuartClient): - response = await auth_app.get("/users", headers={"Authorization": auth_app.token}) - - assert response.status_code == 200 - assert response.mimetype == "application/json" - - data = await response.json - - time = data.pop("time") - - assert (await response.json) == { - "limit": 100, - "page": 0, - "users": [ - { - "avatar": None, - "discriminator": "0000", - "id": "1", - "type": "APP", - "username": "test", - } - ], - } - - assert isinstance(time, float) diff --git a/tests/test_utils/test_snowflake.py b/tests/test_utils/test_snowflake.py new file mode 100644 index 0000000..5d38511 --- /dev/null +++ b/tests/test_utils/test_snowflake.py @@ -0,0 +1,31 @@ +from datetime import datetime +import pytest + +from utils import snowflake_time + + +EXAMPLE_INTERNAL_ID = 6802059911472611845 +EXAMPLE_DISCORD_ID = 144112966176997376 +INVALID_ID = 144112966175 + + +@pytest.mark.parametrize( + "test_id,is_internal,expected", + [ + (EXAMPLE_DISCORD_ID, False, datetime(2016, 2, 2, 16, 13, 28, 626000)), + (EXAMPLE_DISCORD_ID, True, datetime(1971, 2, 21, 7, 17, 47, 826000)), + (EXAMPLE_INTERNAL_ID, True, datetime(2021, 6, 10, 17, 41, 58, 257000)), + (EXAMPLE_INTERNAL_ID, False, datetime(2066, 5, 23, 2, 37, 39, 57000)), + (INVALID_ID, False, datetime(2015, 1, 1, 0, 0, 34, 359000)), + (0, False, datetime(2015, 1, 1, 0, 0)), + (-INVALID_ID, False, datetime(2014, 12, 31, 23, 59, 25, 640000)), + ], +) +def test_snowflake_time(test_id, is_internal, expected): + """ + :param test_id: Example Snowflake ID + :param is_internal: Internal or Discord ID + :param expected: Expected datetime + """ + actual = snowflake_time(id=test_id, internal=is_internal) + assert expected == actual diff --git a/tests/test_validators.py b/tests/test_validators.py deleted file mode 100644 index c00d377..0000000 --- a/tests/test_validators.py +++ /dev/null @@ -1,78 +0,0 @@ -from api import app as quart_app - -from quart.testing import QuartClient -from quart import jsonify -import typing -import pytest - - -import utils - - -@quart_app.route("/testing", methods=["POST"]) -@utils.expects_data( - name=str, id=typing.Union[str, int], type=typing.Literal["ONE", "TWO"] -) -async def endpoint( - name: str, id: typing.Union[str, int], type: typing.Literal["ONE", "TWO"] -): - return jsonify(name=name, id=id, type=type) - - -@pytest.fixture(name="app") -def _test_app() -> QuartClient: - return quart_app.test_client() - - -@pytest.mark.asyncio -async def test_validator_no_data(app: QuartClient): - response = await app.post("/testing") - - assert response.status_code == 400 - assert response.mimetype == "application/json" - assert (await response.json) == { - "error": "Bad Request", - "message": "No json data provided.", - } - - -@pytest.mark.asyncio -async def test_validator_empty_data(app: QuartClient): - response = await app.post("/testing", json={}) - - assert response.status_code == 400 - assert response.mimetype == "application/json" - assert (await response.json) == { - "data": ["name", "id", "type"], - "error": "Bad Request", - "message": "Missing keyword arguments.", - } - - -@pytest.mark.asyncio -async def test_validator_invalid_data(app: QuartClient): - response = await app.post("/testing", json={"name": 5, "id": 1.0, "type": "FOO"}) - - assert response.status_code == 400 - assert response.mimetype == "application/json" - - assert (await response.json) == { - "data": { - "id": "Expected argument of type `Union[str, int]`, got `float`", - "name": "Expected argument of type `str`, got `int`", - "type": "Parameter needs to be one of Literal['ONE', 'TWO']", - }, - "error": "Bad Request", - "message": "JSON Validation failed.", - } - - -@pytest.mark.asyncio -async def test_validator_valid_data(app: QuartClient): - data = {"name": "Sylte", "id": "1", "type": "ONE"} - - response = await app.post("/testing", json=data) - - assert response.status_code == 200 - assert response.mimetype == "application/json" - assert (await response.json) == data diff --git a/utils/__init__.py b/utils/__init__.py index effc34e..7670ed7 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,15 +1,7 @@ -from .validators import expects_data, expects_files -from .decorators import app_only, auth_required -from .middleware import TokenAuthMiddleware from .time import snowflake_time -from .request import Request +from .response import JSONResponse __all__ = ( - "TokenAuthMiddleware", + "JSONResponse", "snowflake_time", - "auth_required", - "expects_files", - "expects_data", - "app_only", - "Request", ) diff --git a/utils/decorators.py b/utils/decorators.py deleted file mode 100644 index 49c5f23..0000000 --- a/utils/decorators.py +++ /dev/null @@ -1,42 +0,0 @@ -from quart import request, Request, exceptions -from typing import Callable, Any -from functools import wraps - - -request: Request - - -def app_only(func: Callable) -> Callable: - """A decorator that restricts view access to "APP" users.""" - - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - if request.user_id is None: - raise exceptions.Unauthorized - - user = await request.user - - if not getattr(user, "type", "USER") == "APP": - raise exceptions.Forbidden - - return await func(*args, **kwargs) - - return wrapper - - -def auth_required(func: Callable) -> Callable: - """A decorator to restrict view access to authorized users.""" - - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - if request.user_id is None: - raise exceptions.Unauthorized - - return await func(*args, **kwargs) - - return wrapper - - -# TODO -# Permission required decorators. -# > Will be used like: `@requires_perm("admin")` or `@requires_perms("admin", "edit_site_settings")` diff --git a/utils/middleware.py b/utils/middleware.py deleted file mode 100644 index 6613870..0000000 --- a/utils/middleware.py +++ /dev/null @@ -1,44 +0,0 @@ -from functools import partial -import jwt -import os - - -class TokenAuthMiddleware: - """ - Class used to authorize requests. - - Authorization headers are used to determine the ID of the user. - If the request was authorized, you can use "await request.user" to get the user that made the request. - Delaying this request is to reduce the average response time. - """ - - def __init__(self, asgi_app, app): - self.asgi_app = asgi_app - self.app = app - - async def __call__(self, scope, recieve, send): - run = partial(self.asgi_app, scope, recieve, send) - - if scope["type"] == "lifespan": - return await run() - - token = None - - for name, value in scope["headers"]: - if name == b"authorization": - token = value.decode() - - if token is None: - return await run() - - try: - payload = jwt.decode( - jwt=token, algorithms=["HS256"], key=os.environ["SECRET_KEY"] - ) - - except jwt.PyJWTError: - return await run() - - scope["jwt"] = payload - - return await run() diff --git a/utils/request.py b/utils/request.py deleted file mode 100644 index 1c3a7c8..0000000 --- a/utils/request.py +++ /dev/null @@ -1,44 +0,0 @@ -from quart import Request as BaseRequest -from typing import Optional - -from api.models import User - - -class Request(BaseRequest): - """Custom request class to implement authorization.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._user = None - self.__has_fetched = False - - @property - def is_authorized(self) -> bool: - return isinstance(self.jwt, dict) - - @property - async def user(self) -> Optional[User]: - """ - The User object is no longer fetched in Authorization Middleware. - This is to reduce avg. response time. - """ - if not self.is_authorized: - return None - - if not self.__has_fetched: - self._user = await User.fetch(id=self.user_id) - self.__has_fetched = True - - return self._user - - @property - def user_id(self) -> Optional[int]: - if not self.is_authorized: - return None - - return int(self.jwt["uid"]) - - @property - def jwt(self) -> Optional[dict]: - return self.scope.get("jwt") diff --git a/utils/response.py b/utils/response.py new file mode 100644 index 0000000..2b57ea9 --- /dev/null +++ b/utils/response.py @@ -0,0 +1,12 @@ +import typing +from datetime import datetime + +from fastapi.responses import JSONResponse as BaseResponse + + +class JSONResponse(BaseResponse): + def render(self, content: typing.Any) -> bytes: + if isinstance(content, datetime): + return content.replace(microsecond=0).isoformat() + + return super().render(content) diff --git a/utils/time.py b/utils/time.py index a5b7392..6eb9e1e 100644 --- a/utils/time.py +++ b/utils/time.py @@ -3,11 +3,20 @@ DISCORD_EPOCH = 1420070400000 MAX_ASYNCIO_SECONDS = 3456000 +OUR_EPOCH = 1609459200 -def snowflake_time(id: int) -> datetime.datetime: +def snowflake_time(id: int, *, internal: bool = True) -> datetime.datetime: """ - :param id: The ID we want to convert. - :return: :class:`datetime.datetime` instance. + :param id: The ID we want to convert. + :param internal: Whether it's a internal ID or not. + + :return: :class:`datetime.datetime` instance. """ - return datetime.datetime.utcfromtimestamp(((id >> 22) + DISCORD_EPOCH) / 1000) + + epoch = OUR_EPOCH + + if not internal: + epoch = DISCORD_EPOCH + + return datetime.datetime.utcfromtimestamp(((id >> 22) + epoch) / 1000) diff --git a/utils/validators.py b/utils/validators.py deleted file mode 100644 index e516680..0000000 --- a/utils/validators.py +++ /dev/null @@ -1,284 +0,0 @@ -from quart import request, Request, jsonify, exceptions -from functools import wraps -import typing - - -request: Request - - -NoneType = type(None) -supported_validation_types = [ - typing.Literal, - typing.Union, - typing.List, - typing.Dict, - NoneType, - float, - list, - dict, - str, - int, -] - -special_validation_types = [typing.Literal, typing.Union, typing.List, typing.Dict] - - -def find_excess(valid_keys: typing.Iterable[str], data: dict) -> typing.List[str]: - """ - Finds excess data in the json `data` provided. - Returns any found arguments. - """ - return [key for key in data.keys() if key not in valid_keys] - - -def is_optional(field: typing.Any) -> bool: - """Returns boolean describing if the provided `field` is optional.""" - return typing.get_origin(field) is typing.Union and type(None) in typing.get_args( - field - ) - - -def format_type_name(field: typing.Any) -> str: - """Format the supported types to a more human-readable string.""" - - if typing.get_origin(field) in special_validation_types: - return str(field).replace("typing.", "") - - return field.__name__ - - -def is_supported(_type: typing.Any) -> bool: - """Is the provided type a supported type in our validation?""" - if typing.get_origin(_type) in special_validation_types: - return True - - return _type in supported_validation_types - - -def is_iterable(field: typing.Any) -> bool: - """ - Returns boolean describing if the provided `field` is iterable. - (Excludes strings, bytes) - """ - return isinstance(field, typing.Iterable) and not isinstance(field, (str, bytes)) - - -def find_missing( - scheme: typing.Dict[str, typing.Union[typing.Tuple[typing.Type], typing.Type]], - data: dict, -) -> typing.List[str]: - """ - Finds missing data in the json `data` provided. - Returns any missing arguments. - """ - return [ - arg - for arg, arg_type in scheme.items() - if arg not in data and not is_optional(arg_type) - ] - - -def validate_list(expected: typing.Any, lst: list) -> typing.Union[str, bool]: - """ - Validate a list against our expected schema. - - Returns False if the list is valid. - Returns error in string format if invalid. - """ - - if not isinstance(lst, list): - return "Expected argument of type `%s`, got `%s`" % ( - format_type_name(expected), - type(lst).__name__, - ) - - each_arg_type = typing.get_args(expected)[0] - - for item in lst: - if not isinstance(item, each_arg_type): - return "Not all list items are of expected value, `%s`, found `%s`" % ( - format_type_name(each_arg_type), - type(item).__name__, - ) - - return False # The list is valid. - - -def validate( - scheme: typing.Dict[str, typing.Union[typing.Tuple[typing.Type], typing.Type]], - data: dict, -) -> typing.Union[dict, bool]: - """ - Validate a dict against the defined scheme. - - Expects find_missing to be ran first - - Returns False if the data matches schema. - Returns error in dict format if invalid. - """ - - errors = {} - for arg, arg_type in scheme.items(): - - if arg not in data: - if is_optional(arg_type): - continue - - if not is_supported(arg_type): - raise ValueError( - "Type `%s` is not a supported validation type." % str(arg_type) - ) - - original = arg_type - - if typing.get_origin(arg_type) == list: - if result := validate_list(expected=arg_type, lst=data[arg]): - errors[arg] = result - - elif typing.get_origin(arg_type) == dict: - arg_type = dict - - elif typing.get_origin(arg_type) == typing.Union: - # Handles both Union and Optional. - arg_type = typing.get_args(arg_type) - - if typing.get_origin(arg_type) == typing.Literal: - if data[arg] not in typing.get_args(arg_type): - errors[arg] = "Parameter needs to be one of %s" % format_type_name( - arg_type - ) - continue - - if is_iterable(arg_type): - for t in arg_type: - if not is_supported(t): - raise ValueError( - "Type `%s` is not a supported validation type." % str(t) - ) - else: - if not is_supported(arg_type): - raise ValueError( - "Type `%s` is not a supported validation type." % str(arg_type) - ) - - if not isinstance(data[arg], arg_type): - errors[arg] = "Expected argument of type `%s`, got `%s`" % ( - format_type_name(original), - type(data[arg]).__name__, - ) - - if errors: - return errors - - return False - - -def expects_data( - __data_type: typing.Literal["json", "form"] = "json", - **scheme: typing.Union[typing.Tuple[typing.Type], typing.Type], -) -> typing.Callable: - """ - A decorator poorly made to ensure the request data fits our validation scheme. - """ - - if not isinstance(scheme, dict): - raise ValueError( - "expects_data does not support validating other data models than dicts." - ) - - if __data_type not in ("json", "form"): - raise ValueError("data_type can only be `json` or `form`.") - - def outer(func: typing.Callable) -> typing.Callable: - @wraps(func) - async def inner(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: - - if not isinstance(scheme, dict): - raise exceptions.BadRequest - - data = await getattr(request, __data_type) - - if data is None: - return ( - jsonify(error="Bad Request", message="No json data provided."), - 400, - ) - - if any(excess := find_excess(valid_keys=scheme.keys(), data=data)): - return ( - jsonify( - error="Bad Request", - message="Unexpected keyword arguments provided.", - data=excess, - ), - 400, - ) - - if any(missing := find_missing(scheme=scheme, data=data)): - return ( - jsonify( - error="Bad Request", - message="Missing keyword arguments.", - data=missing, - ), - 400, - ) - - if out := validate(scheme=scheme, data=data): - # validate returns `False` if data matches scheme. - return ( - jsonify( - error="Bad Request", message="JSON Validation failed.", data=out - ), - 400, - ) - - kwargs.update(data) - - return await func(*args, **kwargs) - - return inner - - return outer - - -def expects_files(*filenames: typing.Tuple[str]) -> typing.Callable: - """ - A decorator to ensure the request files include the provided `filenames`. - """ - - def outer(func: typing.Callable) -> typing.Callable: - @wraps(func) - async def inner(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: - files = await request.files - error = {} - - if files is None: - return ( - jsonify( - error="Bad Request", - message="No files in form data.", - data={"expected_files": filenames}, - ), - 400, - ) - - for filename in filenames: - if filename not in files.keys(): - error[filename] = "This file is required." - - if error: - return ( - jsonify( - error="Bad Request", - message="File validation failed.", - data=error, - ), - 400, - ) - - return await func(*args, files=files, **kwargs) - - return inner - - return outer