diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..0ae3339 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,338 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "anyio" +version = "3.6.2" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.6.2" +files = [ + {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, + {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16,<0.22)"] + +[[package]] +name = "black" +version = "22.12.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, + {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, + {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, + {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, + {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, + {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, + {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, + {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, + {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, + {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, + {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, + {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "fastapi" +version = "0.89.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "fastapi-0.89.1-py3-none-any.whl", hash = "sha256:f9773ea22290635b2f48b4275b2bf69a8fa721fda2e38228bed47139839dc877"}, + {file = "fastapi-0.89.1.tar.gz", hash = "sha256:15d9271ee52b572a015ca2ae5c72e1ce4241dd8532a534ad4f7ec70c376a580f"}, +] + +[package.dependencies] +pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" +starlette = "0.22.0" + +[package.extras] +all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] +doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer[all] (>=0.6.1,<0.8.0)"] +test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.10.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.6.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "isort" +version = "5.11.4" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "isort-5.11.4-py3-none-any.whl", hash = "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b"}, + {file = "isort-5.11.4.tar.gz", hash = "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6"}, +] + +[package.extras] +colors = ["colorama (>=0.4.3,<0.5.0)"] +pipfile-deprecated-finder = ["pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] + +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "pathspec" +version = "0.10.3" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"}, + {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, +] + +[[package]] +name = "platformdirs" +version = "2.6.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, + {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, +] + +[package.extras] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pydantic" +version = "1.10.4" +description = "Data validation and settings management using python type hints" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5635de53e6686fe7a44b5cf25fcc419a0d5e5c1a1efe73d49d48fe7586db854"}, + {file = "pydantic-1.10.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6dc1cc241440ed7ca9ab59d9929075445da6b7c94ced281b3dd4cfe6c8cff817"}, + {file = "pydantic-1.10.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51bdeb10d2db0f288e71d49c9cefa609bca271720ecd0c58009bd7504a0c464c"}, + {file = "pydantic-1.10.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cec42b95dbb500a1f7120bdf95c401f6abb616bbe8785ef09887306792e66e"}, + {file = "pydantic-1.10.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8775d4ef5e7299a2f4699501077a0defdaac5b6c4321173bcb0f3c496fbadf85"}, + {file = "pydantic-1.10.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:572066051eeac73d23f95ba9a71349c42a3e05999d0ee1572b7860235b850cc6"}, + {file = "pydantic-1.10.4-cp310-cp310-win_amd64.whl", hash = "sha256:7feb6a2d401f4d6863050f58325b8d99c1e56f4512d98b11ac64ad1751dc647d"}, + {file = "pydantic-1.10.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39f4a73e5342b25c2959529f07f026ef58147249f9b7431e1ba8414a36761f53"}, + {file = "pydantic-1.10.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:983e720704431a6573d626b00662eb78a07148c9115129f9b4351091ec95ecc3"}, + {file = "pydantic-1.10.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75d52162fe6b2b55964fbb0af2ee58e99791a3138588c482572bb6087953113a"}, + {file = "pydantic-1.10.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdf8d759ef326962b4678d89e275ffc55b7ce59d917d9f72233762061fd04a2d"}, + {file = "pydantic-1.10.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05a81b006be15655b2a1bae5faa4280cf7c81d0e09fcb49b342ebf826abe5a72"}, + {file = "pydantic-1.10.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d88c4c0e5c5dfd05092a4b271282ef0588e5f4aaf345778056fc5259ba098857"}, + {file = "pydantic-1.10.4-cp311-cp311-win_amd64.whl", hash = "sha256:6a05a9db1ef5be0fe63e988f9617ca2551013f55000289c671f71ec16f4985e3"}, + {file = "pydantic-1.10.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:887ca463c3bc47103c123bc06919c86720e80e1214aab79e9b779cda0ff92a00"}, + {file = "pydantic-1.10.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdf88ab63c3ee282c76d652fc86518aacb737ff35796023fae56a65ced1a5978"}, + {file = "pydantic-1.10.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a48f1953c4a1d9bd0b5167ac50da9a79f6072c63c4cef4cf2a3736994903583e"}, + {file = "pydantic-1.10.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a9f2de23bec87ff306aef658384b02aa7c32389766af3c5dee9ce33e80222dfa"}, + {file = "pydantic-1.10.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:cd8702c5142afda03dc2b1ee6bc358b62b3735b2cce53fc77b31ca9f728e4bc8"}, + {file = "pydantic-1.10.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6e7124d6855b2780611d9f5e1e145e86667eaa3bd9459192c8dc1a097f5e9903"}, + {file = "pydantic-1.10.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b53e1d41e97063d51a02821b80538053ee4608b9a181c1005441f1673c55423"}, + {file = "pydantic-1.10.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:55b1625899acd33229c4352ce0ae54038529b412bd51c4915349b49ca575258f"}, + {file = "pydantic-1.10.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:301d626a59edbe5dfb48fcae245896379a450d04baeed50ef40d8199f2733b06"}, + {file = "pydantic-1.10.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6f9d649892a6f54a39ed56b8dfd5e08b5f3be5f893da430bed76975f3735d15"}, + {file = "pydantic-1.10.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d7b5a3821225f5c43496c324b0d6875fde910a1c2933d726a743ce328fbb2a8c"}, + {file = "pydantic-1.10.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f2f7eb6273dd12472d7f218e1fef6f7c7c2f00ac2e1ecde4db8824c457300416"}, + {file = "pydantic-1.10.4-cp38-cp38-win_amd64.whl", hash = "sha256:4b05697738e7d2040696b0a66d9f0a10bec0efa1883ca75ee9e55baf511909d6"}, + {file = "pydantic-1.10.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a9a6747cac06c2beb466064dda999a13176b23535e4c496c9d48e6406f92d42d"}, + {file = "pydantic-1.10.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eb992a1ef739cc7b543576337bebfc62c0e6567434e522e97291b251a41dad7f"}, + {file = "pydantic-1.10.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:990406d226dea0e8f25f643b370224771878142155b879784ce89f633541a024"}, + {file = "pydantic-1.10.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e82a6d37a95e0b1b42b82ab340ada3963aea1317fd7f888bb6b9dfbf4fff57c"}, + {file = "pydantic-1.10.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9193d4f4ee8feca58bc56c8306bcb820f5c7905fd919e0750acdeeeef0615b28"}, + {file = "pydantic-1.10.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2b3ce5f16deb45c472dde1a0ee05619298c864a20cded09c4edd820e1454129f"}, + {file = "pydantic-1.10.4-cp39-cp39-win_amd64.whl", hash = "sha256:9cbdc268a62d9a98c56e2452d6c41c0263d64a2009aac69246486f01b4f594c4"}, + {file = "pydantic-1.10.4-py3-none-any.whl", hash = "sha256:4948f264678c703f3877d1c8877c4e3b2e12e549c57795107f08cf70c6ec7774"}, + {file = "pydantic-1.10.4.tar.gz", hash = "sha256:b9a3859f24eb4e097502a3be1fb4b2abb79b6103dd9e2e0edb70613a4459a648"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "starlette" +version = "0.22.0" +description = "The little ASGI library that shines." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "starlette-0.22.0-py3-none-any.whl", hash = "sha256:b5eda991ad5f0ee5d8ce4c4540202a573bb6691ecd0c712262d0bc85cf8f2c50"}, + {file = "starlette-0.22.0.tar.gz", hash = "sha256:b092cbc365bea34dd6840b42861bdabb2f507f8671e642e8272d2442e08ea4ff"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] + +[[package]] +name = "uvicorn" +version = "0.20.0" +description = "The lightning-fast ASGI server." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "uvicorn-0.20.0-py3-none-any.whl", hash = "sha256:c3ed1598a5668208723f2bb49336f4509424ad198d6ab2615b7783db58d919fd"}, + {file = "uvicorn-0.20.0.tar.gz", hash = "sha256:a4e12017b940247f836bc90b72e725d7dfd0c8ed1c51eb365f5ba30d9f5127d8"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "9aca61fa5aa5079a9956bbf807122d4a297e77aa2494bb55aa6a33059bb11919" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4381c9f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[tool.poetry] +name = "starlette-discord" +version = "0.2.1" +description = "\"Login with Discord\" support for Starlette and FastAPI." +authors = ["nwunderly <>", "BeeMoe5 <>"] +license = "MIT" +readme = "README.md" +packages = [{include = "starlette_discord"}] + +[tool.poetry.dependencies] +python = "^3.8" +oauthlib = "^3.2.2" +starlette = ">=0.13.6" + + +[tool.poetry.group.dev.dependencies] +black = "^22.12.0" +isort = "^5.11.4" +fastapi = "^0.89.1" +uvicorn = "^0.20.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/starlette_discord/__init__.py b/starlette_discord/__init__.py index b824af2..d8ccdc7 100644 --- a/starlette_discord/__init__.py +++ b/starlette_discord/__init__.py @@ -13,5 +13,5 @@ __copyright__ = "Copyright 2021 nwunderly" __version__ = "0.2.1" -from .client import DiscordOAuthClient, DiscordOAuthSession from .models import Connection, DiscordObject, Guild, User +from .oauth import DiscordOAuth2Client, DiscordOAuth2Session diff --git a/starlette_discord/client.py b/starlette_discord/client.py index 372fbe6..e69de29 100644 --- a/starlette_discord/client.py +++ b/starlette_discord/client.py @@ -1,428 +0,0 @@ -from datetime import datetime - -from oauthlib.common import generate_token, urldecode -from oauthlib.oauth2 import ( - InsecureTransportError, - WebApplicationClient, - is_secure_transport, -) -from starlette.responses import RedirectResponse - -from .models import Connection, Guild, User -from .oauth import OAuth2Session - -DISCORD_URL = "https://discord.com" -API_URL = DISCORD_URL + "/api/v9" - - -class DiscordOAuthSession(OAuth2Session): - """Session containing data for a single authorized user. Handles authorization internally. - - .. warning:: - It is recommended to not construct this class directly. - Use `DiscordOAuthClient.session` or `DiscordOAuthClient.session_from_token` instead. - - .. note:: - Either the 'code' or 'token' parameter must be provided, but not both. - - Parameters - ---------- - code: Optional[:class:`str`] - Authorization code included with user request after redirect from Discord. - token: Optional[Dict[:class:`str`, Union[:class:`str`, :class`int`, :class:`float`]]] - A previously generated, valid, access token to use instead of the OAuth code exchange - client_id: :class:`str` - Your Discord application client ID. - scope: :class:`str` - Discord authorization scopes separated by %20. - redirect_uri: :class:`str` - Your Discord application redirect URI. - code: Optional[:class:`str`] - Authorization code included with user request after redirect from Discord. - token: Optional[Dict[:class:`str`, Union[:class:`str`, :class:`int`, :class`float`]]] - A previously generated, valid, access token to use instead of the OAuth code exchange - """ - - def __init__(self, client_id, client_secret, scope, redirect_uri, *, code, token): - client = WebApplicationClient(client_id, token=token) - if (not (code or token)) or (code and token): - raise ValueError( - "Either 'code' or 'token' parameter must be provided, but not both." - ) - elif token: - if not isinstance(token, dict): - raise TypeError( - "Parameter 'token' must be an instance of dict with at least the 'access_token' key.'" - ) - if "access_token" not in token: - raise ValueError("Parameter 'token' requires 'access_token' key.") - elif ( - "token_type" not in token - ): # this is not required for the discord class but for the parent class - token["token_type"] = "Bearer" - - elif code: - client.populate_code_attributes({"code": code}) - - self._discord_client_secret = client_secret - self._cached_user = None - self._cached_guilds = None - self._cached_connections = None - - super().__init__( - client_id=client_id, - scope=scope, - redirect_uri=redirect_uri, - token=token, - client=client, - ) - - @property - def token(self): - """Dict[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]]: The session's current OAuth token, if one exists.""" - # this is pretty much just for documentation purposes. - return super().token - - @token.setter - def token(self, value): - # stolen from oauth.py to allow the client to set this still. - self._client.token = value - self._client.populate_token_attributes(value) - - @property - def session_expired(self): - return datetime.fromtimestamp(self.token["expires_at"]) < datetime.now() - - @property - def cached_user(self): - """:class:`dict`: The session's cached user, if an `identify()` request has previously been made.""" - return self._cached_user - - @property - def cached_guilds(self): - """List[:class:`dict`]: The session's cached guilds, if a `guilds()` request has previously been made.""" - return self._cached_guilds - - @property - def cached_connections(self): - """List[:class:`dict`]: The session's cached account connections, if a `connections()` request has previously been made.""" - return self._cached_connections - - def new_state(self): - """Generate a new state string for verifying authorizations. - - Returns - ------- - :class:`str` - The state string that was generated. - """ - return generate_token() - - async def ensure_token( - self, - ): - if not self.token: - url = API_URL + "/oauth2/token" - self.token = await self.fetch_token( - url, - code=self._client.code, - client_secret=self._discord_client_secret, - ) - - async def _discord_request(self, url_fragment, method="GET"): - await self.ensure_token() - - access_token = self.token["access_token"] - url = API_URL + url_fragment - headers = {"Authorization": "Authorization: Bearer " + access_token} - async with self.request(method, url, headers=headers) as resp: - resp.raise_for_status() - return await resp.json() - - async def identify(self): - """Identify a user. - - Returns - ------- - :class:`User` - The user who authorized the application. - """ - data_user = await self._discord_request("/users/@me") - user = User(data=data_user) - self._cached_user = user - return user - - async def guilds(self): - """Fetch a user's guild list. - - Returns - ------- - List[:class:`Guild`] - The user's guild list. - """ - data_guilds = await self._discord_request("/users/@me/guilds") - guilds = [Guild(data=g) for g in data_guilds] - self._cached_guilds = guilds - return guilds - - async def connections(self): - """Fetch a user's linked 3rd-party accounts. - - Returns - ------- - List[:class:`Connection`] - The user's connections. - """ - data_connections = await self._discord_request("/users/@me/connections") - connections = [Connection(data=c) for c in data_connections] - self._cached_connections = connections - return connections - - async def join_guild(self, guild_id, user_id=None): - """Add a user to a guild. - - Parameters - ---------- - guild_id: :class:`int` - The ID of the guild to add the user to. - user_id: Optional[:class:`int`] - ID of the user, if known. If not specified, will first identify the user. - """ - if not user_id: - user = await self.identify() - user_id = user.id - return await self._discord_request( - f"/guilds/{guild_id}/members/{user_id}", method="PUT" - ) - - async def join_group_dm(self, dm_channel_id, user_id=None): - """Add a user to a group DM. - - Parameters - ---------- - dm_channel_id: :class:`int` - The ID of the DM channel to add the user to. - user_id: Optional[:class:`int`] - ID of the user, if known. If not specified, will first identify the user. - """ - if not user_id: - user = await self.identify() - user_id = user.id - return await self._discord_request( - f"/channels/{dm_channel_id}/recipients/{user_id}", method="PUT" - ) - - async def refresh_token( - self, - token_url, - refresh_token=None, - body="", - auth=None, - timeout=None, - headers=None, - verify_ssl=True, - proxies=None, - **kwargs, - ): - """Fetch a new access token using a refresh token. - :param token_url: The token endpoint, must be HTTPS. - :param refresh_token: The refresh_token to use. - :param body: Optional application/x-www-form-urlencoded body to add the - include in the token request. Prefer kwargs over body. - :param auth: An auth tuple or method as accepted by `requests`. - :param timeout: Timeout of the request in seconds. - :param headers: A dict of headers to be used by `requests`. - :param verify: Verify SSL certificate. - :param proxies: The `proxies` argument will be passed to `requests`. - :param kwargs: Extra parameters to include in the token request. - :return: A token dict - """ - if not token_url: - raise ValueError("No token endpoint set for auto_refresh.") - - if not is_secure_transport(token_url): - raise InsecureTransportError() - - refresh_token = refresh_token or self.token.get("refresh_token") - - kwargs.update(self.auto_refresh_kwargs) - body = self._client.prepare_refresh_body( - body=body, refresh_token=refresh_token, **kwargs - ) - - if headers is None: - headers = { - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - } - - async with self.post( - token_url, - data=dict(urldecode(body)), - auth=auth, - timeout=timeout, - headers=headers, - verify_ssl=verify_ssl, - withhold_token=True, - # proxy=proxies, - ) as resp: - text = await resp.text() - (resp,) = self._invoke_hooks("refresh_token_response", resp) - - self.token = self._client.parse_request_body_response(text, scope=self.scope) - if "refresh_token" not in self.token: - self.token["refresh_token"] = refresh_token - return self.token - - async def refresh(self): - if self.session_expired: - refreshed_token = await self.refresh_token( - API_URL + "/oauth2/token", - client_secret=self._discord_client_secret, - client_id=self.client_id, - ) - self.token = refreshed_token - return refreshed_token - return self.token - - async def __aenter__(self): - await self.ensure_token() - await self.refresh() - - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.close() - - -class DiscordOAuthClient: - """Client for Discord Oauth2. - - Parameters - ---------- - client_id: Union[:class:`str`, :class:`int`] - Discord application client ID. - client_secret: :class:`str` - Discord application client secret. - redirect_uri: :class:`str` - Discord application redirect URI. - scopes: Tuple[:class:`str`] - Discord authorization scopes. - """ - - def __init__(self, client_id, client_secret, redirect_uri, scopes=("identify",)): - self.client_id = str(client_id) - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.scope = " ".join(scope for scope in scopes) - - def redirect(self, state=None, prompt=None, redirect_uri=None): - """Returns a RedirectResponse that directs to Discord login. - - Parameters - ---------- - state: Optional[:class:`str`] - Optional state parameter for Discord redirect URL. - Docs can be found `here `_. - prompt: Optional[:class:`str`] - Optional prompt parameter for Discord redirect URL. - If ``consent``, user is prompted to re-approve authorization. If ``none``, skips authorization if user has already authorized. - Defaults to ``consent``. - redirect_uri: Optional[:class:`str`] - Optional redirect URI to pass to Discord. Defaults to the client's redirect URI. - """ - client_id = f"client_id={self.client_id}" - redirect_uri = f"redirect_uri={redirect_uri or self.redirect_uri}" - scopes = f"scope={self.scope}" - response_type = "response_type=code" - url = ( - DISCORD_URL - + f"/api/oauth2/authorize?{client_id}&{redirect_uri}&{scopes}&{response_type}" - ) - if state: - url += f"&state={state}" - if prompt: - url += f"&prompt={prompt}" - return RedirectResponse(url) - - def session(self, code) -> DiscordOAuthSession: - """Create a new DiscordOAuthSession from an authorization code. - - Parameters - ---------- - code: :class:`str` - The OAuth2 code provided by the Discord API. - - Returns - ------- - :class:`DiscordOAuthSession` - A new OAuth session. - """ - return DiscordOAuthSession( - code=code, - token=None, - client_id=self.client_id, - client_secret=self.client_secret, - scope=self.scope, - redirect_uri=self.redirect_uri, - ) - - def session_from_token(self, token) -> DiscordOAuthSession: - """Create a new DiscordOAuthSession from an existing token. - - Parameters - ---------- - token: Dict[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]] - An existing (valid) access token to use instead of the OAuth code exchange. - - Returns - ------- - :class:`DiscordOAuthSession` - A new OAuth session. - """ - return DiscordOAuthSession( - code=None, - token=token, - client_id=self.client_id, - client_secret=self.client_secret, - scope=self.scope, - redirect_uri=self.redirect_uri, - ) - - async def login(self, code): - """Shorthand for session setup + identify. - - Parameters - ---------- - code: :class:`str` - The OAuth2 code provided by the authorization request. - - Returns - ------- - :class:`User` - The user who authorized the application. - """ - async with self.session(code) as session: - user = await session.identify() - return user - - # TODO: decide if I want to keep this. not a fan of the method name. - async def login_return_token(self, code): - """Shorthand for session setup + identify. Returns user and token. - - Parameters - ---------- - code: :class:`str` - The OAuth2 code provided by the authorization request. - - Returns - ------- - Tuple[user, token] - user: :class:`User` - The user who authorized the application. - token: Dict[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]] - The access token provided by the Discord API. - """ - async with self.session(code) as session: - user = await session.identify() - return user, session.token diff --git a/starlette_discord/oauth.py b/starlette_discord/oauth.py index a6ee01f..e7efc75 100644 --- a/starlette_discord/oauth.py +++ b/starlette_discord/oauth.py @@ -1,544 +1,290 @@ -######################################################################################################## -# This file was originally found at: https://gist.github.com/kellerza/5ca798f49983bb702bc6e7a05ba53def # -# Thanks, kellerza! <3 # -# # -# - nwunder # -######################################################################################################## - -"""OAuth2Support for aiohttp.ClientSession. -Based on the requests_oauthlib class -https://github.com/requests/requests-oauthlib/blob/master/requests_oauthlib/oauth2_session.py -""" -# pylint: disable=line-too-long,bad-continuation -import logging +import datetime import aiohttp -from oauthlib.common import generate_token, urldecode -from oauthlib.oauth2 import ( - InsecureTransportError, - LegacyApplicationClient, - TokenExpiredError, - WebApplicationClient, - is_secure_transport, -) +from oauthlib.common import generate_token +from oauthlib.oauth2 import WebApplicationClient +from starlette.responses import RedirectResponse -log = logging.getLogger(__name__) # pylint: disable=invalid-name +from .models import Connection, Guild, User +DISCORD_URL = "https://discord.com" +API_URL = DISCORD_URL + "/api/v9" -class TokenUpdated(Warning): - """Exception.""" +class DiscordTokenUpdated(Exception): def __init__(self, token): - super(TokenUpdated, self).__init__() + super().__init__() self.token = token -class OAuth2Session(aiohttp.ClientSession): - """Versatile OAuth 2 extension to :class:`requests.Session`. - Supports any grant type adhering to :class:`oauthlib.oauth2.Client` spec - including the four core OAuth 2 grants. - Can be used to create authorization urls, fetch tokens and access protected - resources using the :class:`requests.Session` interface you are used to. - - :class:`oauthlib.oauth2.WebApplicationClient` (default): Authorization Code Grant - - :class:`oauthlib.oauth2.MobileApplicationClient`: Implicit Grant - - :class:`oauthlib.oauth2.LegacyApplicationClient`: Password Credentials Grant - - :class:`oauthlib.oauth2.BackendApplicationClient`: Client Credentials Grant - Note that the only time you will be using Implicit Grant from python is if - you are driving a user agent able to obtain URL fragments. - """ - +class DiscordOAuth2Session(aiohttp.ClientSession): def __init__( - self, - client_id=None, - client=None, - auto_refresh_url=None, - auto_refresh_kwargs=None, - scope=None, - redirect_uri=None, - token=None, - state=None, - token_updater=None, - **kwargs + self, client_id, client_secret, scope, redirect_uri, *, code, token, **kwargs ): - """Construct a new OAuth 2 client session. - :param client_id: Client id obtained during registration - :param client: :class:`oauthlib.oauth2.Client` to be used. Default is - WebApplicationClient which is useful for any - hosted application but not mobile or desktop. - :param scope: List of scopes you wish to request access to - :param redirect_uri: Redirect URI you registered as callback - :param token: Token dictionary, must include access_token - and token_type. - :param state: State string used to prevent CSRF. This will be given - when creating the authorization url and must be supplied - when parsing the authorization response. - Can be either a string or a no argument callable. - :auto_refresh_url: Refresh token endpoint URL, must be HTTPS. Supply - this if you wish the client to automatically refresh - your access tokens. - :auto_refresh_kwargs: Extra arguments to pass to the refresh token - endpoint. - :token_updater: Method with one argument, token, to be used to update - your token database on automatic token refresh. If not - set a TokenUpdated warning will be raised when a token - has been refreshed. This warning will carry the token - in its token argument. - :param kwargs: Arguments to pass to the Session constructor. - """ - super().__init__(**kwargs) - self._client = client or WebApplicationClient(client_id, token=token) - self.token = token or {} - self.scope = scope - self.redirect_uri = redirect_uri - self.state = state or generate_token - self._state = state - self.auto_refresh_url = auto_refresh_url - self.auto_refresh_kwargs = auto_refresh_kwargs or {} - self.token_updater = token_updater - - if self.token_updater: - assert self.auto_refresh_url, "Auto refresh URL required if token updater" - - # Allow customizations for non compliant providers through various - # hooks to adjust requests and responses. - self.compliance_hook = { - "access_token_response": set(), - "refresh_token_response": set(), - "protected_request": set(), - } + self._client = client = WebApplicationClient(client_id=client_id, token=token) - def new_state(self): - """Generates a state string to be used in authorizations.""" - try: - self._state = self.state() - log.debug("Generated new state %s.", self._state) - except TypeError: - self._state = self.state - log.debug("Re-using previously supplied state %s.", self._state) - return self._state + if (not (code or token)) or (code and token): + raise ValueError( + "Either 'code' or 'token' parameter must be provided, but not both." + ) - @property - def client_id(self): - """Get the client_id.""" - return getattr(self._client, "client_id", None) + elif token: + if not isinstance(token, dict): + raise TypeError( + "Parameter 'token' must be an instance of dict with at least the 'access_token' key.'" + ) - @client_id.setter - def client_id(self, value): - """Set the client_id.""" - self._client.client_id = value + if "access_token" not in token: + raise ValueError("Parameter 'token' requires 'access_token' key.") - @client_id.deleter - def client_id(self): - """Remove the client_id.""" - del self._client.client_id + elif "token_type" not in token: + token["token_type"] = "Bearer" + + elif code: + client.populate_code_attributes({"code": code}) + + self._cached_user = self._cached_guilds = self._cached_connections = None + self._discord_client_secret = client_secret + self.code = code + self.redirect_uri = redirect_uri + self.scope = scope + + super().__init__(**kwargs) @property def token(self): - """Get the token.""" return getattr(self._client, "token", None) @token.setter def token(self, value): - """Set the token.""" self._client.token = value - # pylint: disable=W0212 - self._client._populate_attributes(value) + self._client.populate_token_attributes(value) @property - def access_token(self): - """Get the access_token.""" - return getattr(self._client, "access_token", None) + def session_expired(self): + return ( + datetime.datetime.fromtimestamp(self.token["expires_at"]) + < datetime.datetime.now() + ) - @access_token.setter - def access_token(self, value): - """Set the access_token.""" - self._client.access_token = value + @property + def cached_user(self): + return self._cached_user - @access_token.deleter - def access_token(self): - """Remove the access_token.""" - del self._client.access_token + @property + def cached_guilds(self): + return self._cached_guilds @property - def authorized(self): - """Boolean that indicates whether this session has an OAuth token - or not. If `self.authorized` is True, you can reasonably expect - OAuth-protected requests to the resource to succeed. If - `self.authorized` is False, you need the user to go through the OAuth - authentication dance before OAuth-protected requests to the resource - will succeed. - """ - return bool(self.access_token) - - def authorization_url(self, url, state=None, **kwargs): - """Form an authorization URL. - :param url: Authorization endpoint url, must be HTTPS. - :param state: An optional state string for CSRF protection. If not - given it will be generated for you. - :param kwargs: Extra parameters to include. - :return: authorization_url, state - """ - state = state or self.new_state() - return ( - self._client.prepare_request_uri( - url, - redirect_uri=self.redirect_uri, - scope=self.scope, - state=state, - **kwargs - ), - state, - ) + def cached_connections(self): + return self._cached_connections - async def fetch_token( - self, - token_url, - code=None, - authorization_response=None, - body="", - auth=None, - username=None, - password=None, - method="POST", - force_querystring=False, - timeout=None, - headers=None, - verify_ssl=True, - proxies=None, - include_client_id=None, - client_id=None, - client_secret=None, - **kwargs - ): - """Generic method for fetching an access token from the token endpoint. - If you are using the MobileApplicationClient you will want to use - `token_from_fragment` instead of `fetch_token`. - The current implementation enforces the RFC guidelines. - :param token_url: Token endpoint URL, must use HTTPS. - :param code: Authorization code (used by WebApplicationClients). - :param authorization_response: Authorization response URL, the callback - URL of the request back to you. Used by - WebApplicationClients instead of code. - :param body: Optional application/x-www-form-urlencoded body to add the - include in the token request. Prefer kwargs over body. - :param auth: An auth tuple or method as accepted by `requests`. - :param username: Username required by LegacyApplicationClients to appear - in the request body. - :param password: Password required by LegacyApplicationClients to appear - in the request body. - :param method: The HTTP method used to make the request. Defaults - to POST, but may also be GET. Other methods should - be added as needed. - :param force_querystring: If True, force the request body to be sent - in the querystring instead. - :param timeout: Timeout of the request in seconds. - :param headers: Dict to default request headers with. - :param verify: Verify SSL certificate. - :param proxies: The `proxies` argument is passed onto `requests`. - :param include_client_id: Should the request body include the - `client_id` parameter. Default is `None`, - which will attempt to autodetect. This can be - forced to always include (True) or never - include (False). - :param client_secret: The `client_secret` paired to the `client_id`. - This is generally required unless provided in the - `auth` tuple. If the value is `None`, it will be - omitted from the request, however if the value is - an empty string, an empty string will be sent. - :param kwargs: Extra parameters to include in the token request. - :return: A token dict - """ - if not is_secure_transport(token_url): - raise InsecureTransportError() + @staticmethod + def new_state(): + return generate_token() - if not code and authorization_response: - log.debug("-- response %s", authorization_response) - self._client.parse_request_uri_response( - str(authorization_response), state=self._state - ) - code = self._client.code - log.debug("--code %s", code) - elif not code and isinstance(self._client, WebApplicationClient): - code = self._client.code - if not code: - raise ValueError( - "Please supply either code or " "authorization_response parameters." - ) + async def ensure_token(self): - # Earlier versions of this library build an HTTPBasicAuth header out of - # `username` and `password`. The RFC states, however these attributes - # must be in the request body and not the header. - # If an upstream server is not spec compliant and requires them to - # appear as an Authorization header, supply an explicit `auth` header - # to this function. - # This check will allow for empty strings, but not `None`. - # - # Refernences - # 4.3.2 - Resource Owner Password Credentials Grant - # https://tools.ietf.org/html/rfc6749#section-4.3.2 - - if isinstance(self._client, LegacyApplicationClient): - if username is None: - raise ValueError( - "`LegacyApplicationClient` requires both the " - "`username` and `password` parameters." - ) - if password is None: - raise ValueError( - "The required parameter `username` was supplied, " - "but `password` was not." - ) + if not self.token: + self.token = await self.fetch_token(self.code) - # merge username and password into kwargs for `prepare_request_body` - if username is not None: - kwargs["username"] = username - if password is not None: - kwargs["password"] = password - - # is an auth explicitly supplied? - if auth is not None: - # if we're dealing with the default of `include_client_id` (None): - # we will assume the `auth` argument is for an RFC compliant server - # and we should not send the `client_id` in the body. - # This approach allows us to still force the client_id by submitting - # `include_client_id=True` along with an `auth` object. - if include_client_id is None: - include_client_id = False - - # otherwise we may need to create an auth header - else: - # since we don't have an auth header, we MAY need to create one - # it is possible that we want to send the `client_id` in the body - # if so, `include_client_id` should be set to True - # otherwise, we will generate an auth header - if include_client_id is not True: - client_id = self.client_id - if client_id: - log.debug( - 'Encoding `client_id` "%s" with `client_secret` ' - "as Basic auth credentials.", - client_id, - ) - client_secret = client_secret if client_secret is not None else "" - auth = aiohttp.BasicAuth(login=client_id, password=client_secret) + if self.session_expired and self.token: + await self.refresh_token() - if include_client_id: - # this was pulled out of the params - # it needs to be passed into prepare_request_body - if client_secret is not None: - kwargs["client_secret"] = client_secret + async def _discord_request( + self, url_fragment: str, method="GET" + ): # todo: maybe deprecate? + # reasoning is this might be implemented in a way thats too specific to use - body = self._client.prepare_request_body( - code=code, - body=body, - redirect_uri=self.redirect_uri, - include_client_id=include_client_id, - **kwargs + access_token = self.token["access_token"] + url = API_URL + url_fragment + headers = {"Authorization": "Bearer " + access_token} + + async with self.request(method, url, headers=headers) as resp: + resp.raise_for_status() + return await resp.json() + + async def identify(self): + user_data = await self._discord_request("/users/@me") + user = User(data=user_data) + self._cached_user = user + return user + + async def guilds(self): + guilds_data = await self._discord_request("/users/@me/guilds") + guilds = list(map(lambda g: Guild(data=g), guilds_data)) + self._cached_guilds = guilds + return guilds + + async def connections(self): + connections_data = await self._discord_request("/users/@me/connections") + connections = list(map(lambda c: Connection(data=c), connections_data)) + self._cached_connections = connections + return connections + + async def join_guild(self, guild_id, user_id=None): + + if not user_id: + user = await self.identify() + user_id = user.id + + return await self._discord_request( + f"/guilds/{guild_id}/members/{user_id}", method="PUT" + ) + + async def join_group_dm(self, dm_channel_id, user_id=None): + + if not user_id: + user = await self.identify() + user_id = user.id + + return await self._discord_request( + f"/channels/{dm_channel_id}/recipients/{user_id}", method="PUT" ) - headers = headers or { - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + async def fetch_token(self, code: str): + url = API_URL + "/oauth2/token" + data = { + "client_id": self._client.client_id, + "client_secret": self._discord_client_secret, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.redirect_uri, } - self.token = {} - request_kwargs = {} - if method.upper() == "POST": - request_kwargs["params" if force_querystring else "data"] = dict( - urldecode(body) - ) - elif method.upper() == "GET": - request_kwargs["params"] = dict(urldecode(body)) - else: - raise ValueError("The method kwarg must be POST or GET.") - # print(method, token_url, timeout, headers, auth, verify_ssl, proxies, request_kwargs['data']) - async with self.request( - method=method, - url=token_url, - timeout=timeout, - headers=headers, - auth=auth, - verify_ssl=verify_ssl, - proxy=proxies, - data=request_kwargs["data"], - ) as resp: - log.debug("Request to fetch token completed with status %s.", resp.status) - log.debug("Request headers were %s", headers) - log.debug("Request body was %s", body) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + # can't use ._discord_request because that calls .ensure_token which + # calls .fetch_token (this current method), what to do? + async with self.post(url=url, headers=headers, data=data) as resp: + resp.raise_for_status() text = await resp.text() + self._client.parse_request_body_response(text, scope=self.scope) + return self.token + + async def refresh_token(self): + url = API_URL + "/oauth2/token" + + data = { + "client_id": self._client.client_id, + "client_secret": self._discord_client_secret, + "grant_type": "refresh_token", + "refresh_token": self.token["refresh_token"], + } + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + async with self.post(url=url, headers=headers, data=data) as resp: + resp.raise_for_status() + text = await resp.text() + self._client.parse_request_body_response(text, scope=self.scope) + return self.token + + async def refresh(self): + await self.refresh_token() + raise DeprecationWarning(".refresh() is deprecated, switch to refresh_token") + + async def __aenter__(self) -> "DiscordOAuth2Session": + await self.ensure_token() + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() - log.debug("Response headers were %s and content %s.", resp.headers, text) - (resp,) = self._invoke_hooks("access_token_response", resp) - # print(text, self.scope) - self._client.parse_request_body_response(text, scope=self.scope) - self.token = self._client.token - log.debug("Obtained token %s.", self.token) - return self.token - - def token_from_fragment(self, authorization_response): - """Parse token from the URI fragment, used by MobileApplicationClients. - :param authorization_response: The full URL of the redirect back to you - :return: A token dict + +class DiscordOAuth2Client: + def __init__(self, client_id, client_secret, redirect_uri, scopes=("identify",)): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.scope = " ".join(scope for scope in scopes) + + def redirect(self, state=None, prompt=None, redirect_uri=None): + """Returns a RedirectResponse that directs to Discord login. + + Parameters + ---------- + state: Optional[:class:`str`] + Optional state parameter for Discord redirect URL. + Docs can be found `here `_. + prompt: Optional[:class:`str`] + Optional prompt parameter for Discord redirect URL. + If ``consent``, user is prompted to re-approve authorization. If ``none``, skips authorization if user has already authorized. + Defaults to ``consent``. + redirect_uri: Optional[:class:`str`] + Optional redirect URI to pass to Discord. Defaults to the client's redirect URI. """ - self._client.parse_request_uri_response( - authorization_response, state=self._state + client_id = f"client_id={self.client_id}" + redirect_uri = f"redirect_uri={redirect_uri or self.redirect_uri}" + scopes = f"scope={self.scope}" + response_type = "response_type=code" + url = ( + DISCORD_URL + + f"/api/oauth2/authorize?{client_id}&{redirect_uri}&{scopes}&{response_type}" ) - self.token = self._client.token - return self.token - - async def refresh_token( - self, - token_url, - refresh_token=None, - body="", - auth=None, - timeout=None, - headers=None, - verify_ssl=True, - proxies=None, - **kwargs - ): - """Fetch a new access token using a refresh token. - :param token_url: The token endpoint, must be HTTPS. - :param refresh_token: The refresh_token to use. - :param body: Optional application/x-www-form-urlencoded body to add the - include in the token request. Prefer kwargs over body. - :param auth: An auth tuple or method as accepted by `requests`. - :param timeout: Timeout of the request in seconds. - :param headers: A dict of headers to be used by `requests`. - :param verify: Verify SSL certificate. - :param proxies: The `proxies` argument will be passed to `requests`. - :param kwargs: Extra parameters to include in the token request. - :return: A token dict + if state: + url += f"&state={state}" + if prompt: + url += f"&prompt={prompt}" + return RedirectResponse(url) + + def session(self, code) -> DiscordOAuth2Session: + """Create a new DiscordOAuthSession from an authorization code. + + Parameters + ---------- + code: :class:`str` + The OAuth2 code provided by the Discord API. + + Returns + ------- + :class:`DiscordOAuthSession` + A new OAuth session. """ - if not token_url: - raise ValueError("No token endpoint set for auto_refresh.") + return DiscordOAuth2Session( + code=code, + token=None, + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, + redirect_uri=self.redirect_uri, + ) - if not is_secure_transport(token_url): - raise InsecureTransportError() + def session_from_token(self, token) -> DiscordOAuth2Session: + """Create a new DiscordOAuthSession from an existing token. - refresh_token = refresh_token or self.token.get("refresh_token") + Parameters + ---------- + token: Dict[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]] + An existing (valid) access token to use instead of the OAuth code exchange. - log.debug( - "Adding auto refresh key word arguments %s.", self.auto_refresh_kwargs + Returns + ------- + :class:`DiscordOAuthSession` + A new OAuth session. + """ + return DiscordOAuth2Session( + code=None, + token=token, + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, + redirect_uri=self.redirect_uri, ) - kwargs.update(self.auto_refresh_kwargs) - body = self._client.prepare_refresh_body( - body=body, refresh_token=refresh_token, scope=self.scope, **kwargs - ) - log.debug("Prepared refresh token request body %s", body) - - if headers is None: - headers = { - "Accept": "application/json", - "Content-Type": ("application/x-www-form-urlencoded;charset=UTF-8"), - } - - async with self.post( - token_url, - data=dict(urldecode(body)), - auth=auth, - timeout=timeout, - headers=headers, - verify_ssl=verify_ssl, - withhold_token=True, - # proxy=proxies, - ) as resp: - log.debug("Request to refresh token completed with status %s.", resp.status) - text = await resp.text() - log.debug("Response headers were %s and content %s.", resp.headers, text) - (resp,) = self._invoke_hooks("refresh_token_response", resp) - - self.token = self._client.parse_request_body_response(text, scope=self.scope) - if "refresh_token" not in self.token: - log.debug("No new refresh token given. Re-using old.") - self.token["refresh_token"] = refresh_token - return self.token - - async def _request( - self, - method, - url, - *, - data=None, - headers=None, - withhold_token=False, - client_id=None, - client_secret=None, - **kwargs - ): - """Intercept all requests and add the OAuth 2 token if present.""" - if not is_secure_transport(url): - raise InsecureTransportError() - if self.token and not withhold_token: + async def login(self, code): + """Shorthand for session setup + identify. - url, headers, data = self._invoke_hooks( - "protected_request", url, headers, data - ) - log.debug("Adding token %s to request.", self.token) - try: - url, headers, data = self._client.add_token( - url, http_method=method, body=data, headers=headers - ) - # Attempt to retrieve and save new access token if expired - except TokenExpiredError: - if self.auto_refresh_url: - log.debug( - "Auto refresh is set, attempting to refresh at %s.", - self.auto_refresh_url, - ) - - # We mustn't pass auth twice. - auth = kwargs.pop("auth", None) - if client_id and client_secret and (auth is None): - log.debug( - 'Encoding client_id "%s" with client_secret as Basic auth credentials.', - client_id, - ) - auth = aiohttp.BasicAuth( - login=client_id, password=client_secret - ) - token = await self.refresh_token( - self.auto_refresh_url, auth=auth, **kwargs - ) - if self.token_updater: - log.debug( - "Updating token to %s using %s.", token, self.token_updater - ) - await self.token_updater(token) - url, headers, data = self._client.add_token( - url, http_method=method, body=data, headers=headers - ) - else: - raise TokenUpdated(token) - else: - raise - - log.debug("Requesting url %s using method %s.", url, method) - log.debug("Supplying headers %s and data %s", headers, data) - log.debug("Passing through key word arguments %s.", kwargs) - return await super()._request(method, url, headers=headers, data=data, **kwargs) - - def register_compliance_hook(self, hook_type, hook): - """Register a hook for request/response tweaking. - Available hooks are: - access_token_response invoked before token parsing. - refresh_token_response invoked before refresh token parsing. - protected_request invoked before making a request. - If you find a new hook is needed please send a GitHub PR request - or open an issue. - """ - if hook_type not in self.compliance_hook: - raise ValueError( - "Hook type {} is not in {}.".format(hook_type, self.compliance_hook) - ) - self.compliance_hook[hook_type].add(hook) + Parameters + ---------- + code: :class:`str` + The OAuth2 code provided by the authorization request. - def _invoke_hooks(self, hook_type, *hook_data): - log.debug( - "Invoking %d %s hooks.", len(self.compliance_hook[hook_type]), hook_type - ) - for hook in self.compliance_hook[hook_type]: - log.debug("Invoking hook %s.", hook) - hook_data = hook(*hook_data) - return hook_data + Returns + ------- + :class:`User` + The user who authorized the application. + """ + async with self.session(code) as session: + user = await session.identify() + return user diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 2ca32f3..3a0b42d 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -2,10 +2,10 @@ from auth import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI from fastapi import FastAPI -from starlette_discord.client import DiscordOAuthClient +from starlette_discord import DiscordOAuth2Client app = FastAPI() -client = DiscordOAuthClient( +client = DiscordOAuth2Client( CLIENT_ID, CLIENT_SECRET, REDIRECT_URI,