mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
376 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2eaa5c7b5 | |||
| 175c46e68c | |||
| a58cc11079 | |||
| 218a375c27 | |||
| 567b1577c6 | |||
| 3c3687d11f | |||
| 19dfac8340 | |||
| b61feafe5a | |||
| 0c342c8b3e | |||
| dbcba8fad7 | |||
| b8053e20f2 | |||
| 1896901aa8 | |||
| 383c9132ed | |||
| 57b144c3e7 | |||
| eed5365fe0 | |||
| f5905568c4 | |||
| 096099470e | |||
| e7ed7aca3c | |||
| 6725b275b8 | |||
| 3447a7ef41 | |||
| 99f35fbea4 | |||
| 5c9a3912a9 | |||
| 5d43c0418c | |||
| 87c0076e12 | |||
| 95252ac697 | |||
| 5bb9f96701 | |||
| 750e9dfaa7 | |||
| f34f3c1661 | |||
| d4f83c978c | |||
| 212f280c19 | |||
| f3e2450636 | |||
| d6d496018d | |||
| 78be7fc772 | |||
| 6ebadd8469 | |||
| 557750c8d4 | |||
| e85ef27e6c | |||
| 4ca961a1b4 | |||
| 6a9110e9c1 | |||
| 51ffce09ae | |||
| 1c4e96b365 | |||
| 0db70e8edd | |||
| e46b3a5e19 | |||
| fdd3d4d85a | |||
| 37c9cba42e | |||
| c1544f66bb | |||
| d37f41f6a5 | |||
| b245dd2d51 | |||
| a1fcf11399 | |||
| 8f876da245 | |||
| 23b8e5a2b3 | |||
| 3b7e7c7192 | |||
| b7ecf6e2e0 | |||
| 2ec6aaff03 | |||
| 19f8553f2d | |||
| 05a64ff095 | |||
| a8fc78fcda | |||
| e0e8b40fa2 | |||
| 00165cd6ca | |||
| cd799ddfcd | |||
| fffd6b7c86 | |||
| 439b008a34 | |||
| f38e538892 | |||
| 6aa87a073f | |||
| c38198ccba | |||
| 3be88c8cbf | |||
| 558ced1afb | |||
| 0149e6935d | |||
| d97fdfd7c4 | |||
| 8b85d8c6fb | |||
| 673779490c | |||
| 48154e7e2d | |||
| 20f72b3f63 | |||
| e82c958af2 | |||
| 60c311ab9f | |||
| fbac81c245 | |||
| 9ca67d9228 | |||
| 5ffa18221f | |||
| aceb1f0f61 | |||
| cee5ca8873 | |||
| d961d4ab43 | |||
| 5205150a89 | |||
| 48e58cde5d | |||
| 033e91f8df | |||
| aab3705897 | |||
| d02efa81f2 | |||
| 95a8240da7 | |||
| dd0ddab610 | |||
| d23ac10f90 | |||
| ec18290b8a | |||
| 2c4cd39dc9 | |||
| 830bad0b85 | |||
| f14ef6fa15 | |||
| 7400b1c83d | |||
| e7caf39fba | |||
| 09fd0fb0ca | |||
| 72adb13c0f | |||
| ea0e382f82 | |||
| e70cba5143 | |||
| 8aec244c31 | |||
| 60e163164f | |||
| 86b9b5f3fa | |||
| 401a208767 | |||
| a1bfbda05b | |||
| 7d1f991ce4 | |||
| 1b10378f58 | |||
| 2bbb379994 | |||
| a835f119e1 | |||
| 91d8bac680 | |||
| 3db10a4ce8 | |||
| 590640645b | |||
| 7f02bfdf0c | |||
| e5cef0d9c0 | |||
| 85f9c33b2b | |||
| 148a430da4 | |||
| f7657679ac | |||
| f0479019c3 | |||
| a9a4ceaa78 | |||
| c55c905621 | |||
| 4db2289b7e | |||
| 93172ea1d0 | |||
| 2d935542e1 | |||
| f309ad7746 | |||
| a7ec1364f4 | |||
| eb71ced092 | |||
| 712ad0a73b | |||
| 48c0b137d5 | |||
| dfccfcc3e5 | |||
| 6abe667efb | |||
| c2472215ab | |||
| ac3c1e149c | |||
| cdf989427a | |||
| ebf129edd3 | |||
| 08c30f4baf | |||
| cf6bdc20ef | |||
| 3ece644af8 | |||
| 3991c82c91 | |||
| 9b635253f0 | |||
| b62f41336e | |||
| f7b777c79e | |||
| d18fa8e42a | |||
| 525c62ad26 | |||
| 4000a6a48c | |||
| 5b173ed4c4 | |||
| f56ad73565 | |||
| 003991c8c6 | |||
| e2a32afb80 | |||
| f305a69bb3 | |||
| 84e8babd9e | |||
| aeb46d9b54 | |||
| fafe0bd8e4 | |||
| 9a2ab45957 | |||
| 66978a8cdc | |||
| 1636012700 | |||
| 09206ae1e4 | |||
| 9188475746 | |||
| 34d158a632 | |||
| c06e6aa5ca | |||
| f4f670f048 | |||
| 778d742b6e | |||
| c8392b65b6 | |||
| c0ace9c2e5 | |||
| dfcab7dcbf | |||
| eb0870deb1 | |||
| 5b7ef34523 | |||
| 6ec728e466 | |||
| f12a562a08 | |||
| 17c4c95593 | |||
| 9b72c90944 | |||
| ec34da60a1 | |||
| daa4b6368a | |||
| 931a7a1a6c | |||
| 69d5790078 | |||
| 7571c18a55 | |||
| ff7ce9bdd0 | |||
| e5fc801899 | |||
| b362aa6813 | |||
| 652b961ac8 | |||
| 652713aec4 | |||
| 387b2f166b | |||
| 164b4a056a | |||
| 29e514fea6 | |||
| 310fff78c6 | |||
| f2efdc007c | |||
| b3be767923 | |||
| e86f2f3873 | |||
| 13d84f73d4 | |||
| e31342d3ba | |||
| daf0538bf3 | |||
| 451ce8b0c7 | |||
| b8cce14705 | |||
| bf1c9c650e | |||
| 8f6387536c | |||
| 56535ece11 | |||
| f1767719cb | |||
| c925b06114 | |||
| 402426884d | |||
| df6c8a5a75 | |||
| 99f5ae7125 | |||
| d50a1b7d07 | |||
| fab3bb76f7 | |||
| 5025c66bb2 | |||
| 800c153e96 | |||
| 71bbda0fb7 | |||
| 6e6bac429a | |||
| 1ce091a4d9 | |||
| a8f889be74 | |||
| 5f33c6bfee | |||
| 6a290c49d8 | |||
| b304d5d784 | |||
| cfe83b97d9 | |||
| 2fec2bf560 | |||
| 73dc1a7839 | |||
| 66fe951831 | |||
| 7991bcbf1a | |||
| de9516563a | |||
| 27fefb821c | |||
| c195894db9 | |||
| 6777b4d370 | |||
| 09269c22a2 | |||
| 2e24a2f079 | |||
| 5d9932dd61 | |||
| 062064213a | |||
| a2ae3ffb2b | |||
| 6cb4a0a3eb | |||
| f17c49091f | |||
| c16afc07df | |||
| 1616a96b2c | |||
| 261601230a | |||
| 453a38df54 | |||
| 5b004a849f | |||
| 29d811d3fd | |||
| 36c5739318 | |||
| b3f9c67d34 | |||
| bc8eb802f7 | |||
| a138eead74 | |||
| a700a0e1b1 | |||
| 205a33a241 | |||
| c88fd94c8b | |||
| a2b4e2e87c | |||
| 4a8f1e95ba | |||
| 3a847d921e | |||
| 806fdb9541 | |||
| cf1adbdb01 | |||
| 349d08e799 | |||
| d680c7ed83 | |||
| d4cb7a711b | |||
| bb6e19e7cd | |||
| 1c3ea53e63 | |||
| 88e17029c5 | |||
| 588e91b19f | |||
| 8cc2e7b6f1 | |||
| 222353b532 | |||
| b88b266fd5 | |||
| 60e6fb99af | |||
| 65b60e57b2 | |||
| 16a8402bf4 | |||
| 5896411136 | |||
| 0bb74a7885 | |||
| 86dfb9231f | |||
| 7198ce3eb0 | |||
| 08fecf1eb2 | |||
| 3eda26ca94 | |||
| d907914c7c | |||
| 266ab48fed | |||
| 3325cffa91 | |||
| 43469ac62a | |||
| a5c953fdb6 | |||
| 627c46e458 | |||
| 205eb34adc | |||
| 125e14d377 | |||
| a51c8a700b | |||
| 94e0400ea1 | |||
| 47c5b84093 | |||
| 8b1fbfd16d | |||
| cceb698899 | |||
| 01741df10d | |||
| f91ebf8baa | |||
| 4dde076030 | |||
| 3491001b7f | |||
| 2acec68649 | |||
| 51dab27374 | |||
| 145f5041bf | |||
| 6034505380 | |||
| 8533d74906 | |||
| b2ae57b982 | |||
| 49ffe9bec9 | |||
| fe5d92674e | |||
| 197d28f5c7 | |||
| cd48bb0789 | |||
| 90fc411e9a | |||
| c22b6a84aa | |||
| 9b65642f05 | |||
| 83547dce9c | |||
| efeecceb54 | |||
| ba9b5a40d2 | |||
| 47b5bda277 | |||
| a343b6b1b6 | |||
| 0fe48d3003 | |||
| 23e3760b08 | |||
| 3d31905562 | |||
| 9638c5266b | |||
| ad7ce9f55a | |||
| b0baf3b85a | |||
| d4d3687882 | |||
| faf55ca191 | |||
| d5096a23fb | |||
| ed5841d201 | |||
| bbfc095a00 | |||
| 0fcb68a13d | |||
| f97744c098 | |||
| d1cfa8d27a | |||
| 218dcf25c1 | |||
| 06e06973a4 | |||
| 6f73cfc5f2 | |||
| 6db5bbeaee | |||
| 6ef5077164 | |||
| 45e1ed7022 | |||
| c14b4535a6 | |||
| 411631d2f8 | |||
| f4c3690bd8 | |||
| 56fdea6b5d | |||
| 8a5c053d39 | |||
| 42870cfa23 | |||
| 6cf256cc05 | |||
| 9fec915f62 | |||
| f1d5ab73cd | |||
| cd62972945 | |||
| 998d09170c | |||
| 4ba57181ec | |||
| 8b9d8bdc62 | |||
| 4291d42dc0 | |||
| 79fcc1ce40 | |||
| bfc6778dca | |||
| 701e57c264 | |||
| 163d025c0d | |||
| d9befc6d8c | |||
| 9e50a4c241 | |||
| 9b0cae3794 | |||
| 6160dfb2f7 | |||
| cd013cdb06 | |||
| 26cc7c90e9 | |||
| f28ac3cf22 | |||
| 58fec4b082 | |||
| b91805a5df | |||
| 0fa0df1bdf | |||
| 3f7cacee3e | |||
| 72637fd650 | |||
| aba1284f8e | |||
| 179e1dc9e5 | |||
| 75879a494e | |||
| 73b1ea4713 | |||
| 55dc991c13 | |||
| c30316588a | |||
| db5d6e7481 | |||
| f8d52f58d4 | |||
| 227ee499e4 | |||
| dcdaf6a674 | |||
| d524ba3a37 | |||
| da5e288476 | |||
| baad7cd60d | |||
| e9d6fc33fd | |||
| c2fa0899e9 | |||
| 2dc09ec1f2 | |||
| fba640976f | |||
| 8e7df61a73 | |||
| 41776cf2df | |||
| 23983f0b75 | |||
| 84b457ede5 | |||
| a906e0bf0c | |||
| 3db1aad96a | |||
| 9c909e7a2c | |||
| ad2ef7cb33 | |||
| c851510ca9 | |||
| 71a21c2059 | |||
| d90537eb8d | |||
| 25e9888438 |
+13
@@ -1,4 +1,17 @@
|
||||
.vscode/
|
||||
.cache
|
||||
.idea
|
||||
.python-version
|
||||
.coverage
|
||||
.pytest_cache
|
||||
.DS_Store
|
||||
coverage.xml
|
||||
|
||||
__pycache__
|
||||
tests/__pycache__
|
||||
|
||||
build
|
||||
responder.egg-info/
|
||||
dist/
|
||||
app.py
|
||||
app2.py
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
|
||||
# command to install dependencies
|
||||
install:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# v0.1.6
|
||||
- 500 support.
|
||||
|
||||
# v0.1.5
|
||||
- Improvements to sequential media reading.
|
||||
- File upload support.
|
||||
|
||||
# v0.1.4
|
||||
- Stability.
|
||||
|
||||
# v0.1.3
|
||||
- Sessions support.
|
||||
|
||||
# v0.1.2
|
||||
- Cookies support.
|
||||
|
||||
# v0.1.1
|
||||
- Default routes.
|
||||
|
||||
# v0.1.0
|
||||
- Prototype of static application support.
|
||||
|
||||
# v0.0.10
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.9
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.8
|
||||
- GraphiQL Support.
|
||||
- Improvement to route selection.
|
||||
|
||||
# v0.0.7
|
||||
- Immutable Request object.
|
||||
|
||||
# v0.0.6:
|
||||
- Ability to mount WSGI apps.
|
||||
- Supply content-type when serving up the schema.
|
||||
|
||||
# v0.0.5:
|
||||
- OpenAPI Schema support.
|
||||
- Safe load/dump yaml.
|
||||
|
||||
# v0.0.4:
|
||||
- Asynchronous support for data uploads.
|
||||
- Bug fixes.
|
||||
|
||||
# v0.0.3:
|
||||
- Bug fixes.
|
||||
|
||||
# v0.0.2
|
||||
- Switch to ASGI/Starlette.
|
||||
|
||||
# v0.0.1
|
||||
- Conception!
|
||||
@@ -1,4 +0,0 @@
|
||||
- Kenneth Reitz (primary)
|
||||
- Tom Christie
|
||||
- Bruno Oliveira
|
||||
- serhii73
|
||||
@@ -0,0 +1,13 @@
|
||||
Copyright 2018 Kenneth Reitz
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -5,7 +5,6 @@ name = "pypi"
|
||||
|
||||
[packages]
|
||||
responder = {editable = true, path = "."}
|
||||
uvicorn = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
@@ -14,6 +13,8 @@ black = "*"
|
||||
twine = "*"
|
||||
flask = "*"
|
||||
sphinx = "*"
|
||||
marshmallow = "*"
|
||||
pytest-cov = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
Generated
+151
-57
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "76d2978ee90d2c028b13c9a5abdd2371d74d514045d50fb9b92aec44e72054b3"
|
||||
"sha256": "7bbe1f0addd73250027de73d6fb749aa2be3149af9744b107820c5e10498428e"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -16,6 +16,13 @@
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"aiofiles": {
|
||||
"hashes": [
|
||||
"sha256:021ea0ba314a86027c166ecc4b4c07f2d40fc0f4b3a950d1868a0f2571c2bbee",
|
||||
"sha256:1e644c2573f953664368de28d2aa4c89dfd64550429d0c27c4680ccd3aa4985d"
|
||||
],
|
||||
"version": "==0.4.0"
|
||||
},
|
||||
"aniso8601": {
|
||||
"hashes": [
|
||||
"sha256:7849749cf00ae0680ad2bdfe4419c7a662bef19c03691a19e008c8b9a5267802",
|
||||
@@ -23,12 +30,33 @@
|
||||
],
|
||||
"version": "==3.0.2"
|
||||
},
|
||||
"apispec": {
|
||||
"hashes": [
|
||||
"sha256:c2e6ac6471aaf7c6ec6d12714821898910c6b3c87c189de9a2e3754786b86ada",
|
||||
"sha256:fa7dfa8a292bae9b1e70c44a50bf61901805821726c5b804568c9f2501f57ebb"
|
||||
],
|
||||
"version": "==1.0.0b3"
|
||||
},
|
||||
"asgiref": {
|
||||
"hashes": [
|
||||
"sha256:9b05dcd41a6a89ca8c6e7f7e4089c3f3e76b5af60aebb81ae6d455ad81989c97",
|
||||
"sha256:b21dc4c43d7aba5a844f4c48b8f49d56277bc34937fd9f9cb93ec97fde7e3082"
|
||||
],
|
||||
"version": "==2.3.2"
|
||||
},
|
||||
"async-timeout": {
|
||||
"hashes": [
|
||||
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
|
||||
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
|
||||
],
|
||||
"version": "==3.0.1"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638",
|
||||
"sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a"
|
||||
"sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c",
|
||||
"sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a"
|
||||
],
|
||||
"version": "==2018.8.24"
|
||||
"version": "==2018.10.15"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
@@ -44,6 +72,12 @@
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"docopt": {
|
||||
"hashes": [
|
||||
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
|
||||
],
|
||||
"version": "==0.6.2"
|
||||
},
|
||||
"graphene": {
|
||||
"hashes": [
|
||||
"sha256:b8ec446d17fa68721636eaad3d6adc1a378cb6323e219814c8f98c9928fc9642",
|
||||
@@ -84,6 +118,13 @@
|
||||
],
|
||||
"version": "==2.7"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:a7de3201740a857380421ef286166134e10fe58846bcefbc9d6424a69a0b99ec",
|
||||
"sha256:aca4fc561b7671115a2156f625f2eaa5e0e3527e0adf2870340e7968c0a81f85"
|
||||
],
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
|
||||
@@ -97,6 +138,13 @@
|
||||
],
|
||||
"version": "==1.0"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:82b201ad767eb54de371c08cb1db6ca4ad2a728fa41b831e3781bf944815eb38",
|
||||
"sha256:c250f37ac0e249a8287394a60d91f6240b674642ad999e66cd09463dbccd1d4f"
|
||||
],
|
||||
"version": "==3.0.0b18"
|
||||
},
|
||||
"parse": {
|
||||
"hashes": [
|
||||
"sha256:9dd6048ea212cd032a342f9f6aa2b7bc222f7407c7e37bdc2777fecd36897437"
|
||||
@@ -110,6 +158,12 @@
|
||||
],
|
||||
"version": "==2.2.1"
|
||||
},
|
||||
"python-multipart": {
|
||||
"hashes": [
|
||||
"sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"
|
||||
],
|
||||
"version": "==0.0.5"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:254bf6fda2b7c651837acb2c718e213df29d531eebf00edb54743d10bcb694eb",
|
||||
@@ -122,21 +176,29 @@
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
|
||||
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
|
||||
"sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c",
|
||||
"sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279"
|
||||
],
|
||||
"version": "==2.19.1"
|
||||
"version": "==2.20.0"
|
||||
},
|
||||
"requests-wsgi-adapter": {
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:7080c98ae2614b8d0b7339b611d97a535470d2fb479731f7d588d5f8108ea134"
|
||||
"sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237",
|
||||
"sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5"
|
||||
],
|
||||
"version": "==0.4.0"
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"responder": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
},
|
||||
"rfc3986": {
|
||||
"hashes": [
|
||||
"sha256:632b8fcd2ac37f24334316227f909be4f9d0738cbf409404cff6fa5f69a24093",
|
||||
"sha256:8458571c4c57e1cf23593ad860bb601b6a604df6217f829c2bc70dc4b5af941b"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"rx": {
|
||||
"hashes": [
|
||||
"sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23",
|
||||
@@ -151,26 +213,24 @@
|
||||
],
|
||||
"version": "==1.11.0"
|
||||
},
|
||||
"starlette": {
|
||||
"hashes": [
|
||||
"sha256:ce5c684fad4edb2967cd491518cd3c2724e420508202c2d48f519ea68dcec9d6"
|
||||
],
|
||||
"version": "==0.5.4"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
|
||||
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
|
||||
"sha256:41c3db2fc01e5b907288010dec72f9d0a74e37d6994e6eb56849f59fea2265ae",
|
||||
"sha256:8819bba37a02d143296a4d032373c4dd4aca11f6d4c9973335ca75f9c8475f59"
|
||||
],
|
||||
"version": "==1.23"
|
||||
"version": "==1.24"
|
||||
},
|
||||
"uvicorn": {
|
||||
"hashes": [
|
||||
"sha256:8de03999a936d8704f07cc3b1d3a3edb6922a068b64d84b4f5e49604c8b70a11"
|
||||
"sha256:7c4550c7e6f7c8727fa5ccd5200baf62c9e055895e058933ee88f5d0c246ca0c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.12"
|
||||
},
|
||||
"waitress": {
|
||||
"hashes": [
|
||||
"sha256:40b0f297a7f3af61fbfbdc67e59090c70dc150a1601c39ecc9f5f1d283fb931b",
|
||||
"sha256:d33cd3d62426c0f1b3cd84ee3d65779c7003aae3fc060dee60524d10a57f05a9"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
"version": "==0.3.14"
|
||||
},
|
||||
"websockets": {
|
||||
"hashes": [
|
||||
@@ -197,20 +257,6 @@
|
||||
"sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"
|
||||
],
|
||||
"version": "==6.0"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
"sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c",
|
||||
"sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b"
|
||||
],
|
||||
"version": "==0.14.1"
|
||||
},
|
||||
"whitenoise": {
|
||||
"hashes": [
|
||||
"sha256:133a92ff0ab8fb9509f77d4f7d0de493eca19c6fea973f4195d4184f888f2e02",
|
||||
"sha256:32b57d193478908a48acb66bf73e7a3c18679263e3e64bfebcfac1144a430039"
|
||||
],
|
||||
"version": "==4.1"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@@ -266,10 +312,10 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638",
|
||||
"sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a"
|
||||
"sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c",
|
||||
"sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a"
|
||||
],
|
||||
"version": "==2018.8.24"
|
||||
"version": "==2018.10.15"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
@@ -363,6 +409,38 @@
|
||||
"markers": "sys_platform == 'win32'",
|
||||
"version": "==0.4.0"
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
"sha256:043d55226aec1d2baf4b2fcab5c204561ccf184a388096f41e396c1c092aff38",
|
||||
"sha256:10bfd0b80b01d0684f968abbe1186bc19962e07b4b7601bb43b175b617cf689d",
|
||||
"sha256:17e59864f19b3233032edb0566f26c25cc7f599503fb34d2645b5ce1fd6c2c3c",
|
||||
"sha256:2105ee183c51fed27e2b6801029b3903f5c2774c78e3f53bd920ca468d0f5679",
|
||||
"sha256:236505d15af6c7b7bfe2a9485db4b2bdea21d9239351483326184314418c79a8",
|
||||
"sha256:237284425271db4f30d458b355decf388ab20b05278bdf8dc9a65de0973726c6",
|
||||
"sha256:26d8eea4c840b73c61a1081d68bceb57b21a2d4f7afda6cac8ac38cb05226b00",
|
||||
"sha256:39a3740f7721155f4269aedf67b211101c07bd2111b334dfd69b807156ab15d9",
|
||||
"sha256:4bd0c42db8efc8a60965769796d43a5570906a870bc819f7388860aa72779d1b",
|
||||
"sha256:4dcddadea47ac30b696956bd18365cd3a86724821656601151e263b86d34798f",
|
||||
"sha256:51ea341289ac4456db946a25bd644f5635e5ae3793df262813cde875887d25c8",
|
||||
"sha256:5415cafb082dad78935b3045c2e5d8907f436d15ad24c3fdb8e1839e084e4961",
|
||||
"sha256:5631f1983074b33c35dbb84607f337b9d7e9808116d7f0f2cb7b9d6d4381d50e",
|
||||
"sha256:5e9249bc361cd22565fd98590a53fd25a3dd666b74791ed7237fa99de938bbed",
|
||||
"sha256:6a48746154f1331f28ef9e889c625b5b15a36cb86dd8021b4bdd1180a2186aa5",
|
||||
"sha256:71d376dbac64855ed693bc1ca121794570fe603e8783cdfa304ec6825d4e768f",
|
||||
"sha256:749ebd8a615337747592bd1523dfc4af7199b2bf6403b55f96c728668aeff91f",
|
||||
"sha256:8ec528b585b95234e9c0c31dcd0a89152d8ed82b4567aa62dbcb3e9a0600deee",
|
||||
"sha256:a1a9ccd879811437ca0307c914f136d6edb85bd0470e6d4966c6397927bcabd9",
|
||||
"sha256:abd956c334752776230b779537d911a5a12fcb69d8fd3fe332ae63a140301ae6",
|
||||
"sha256:ad18f836017f2e8881145795f483636564807aaed54223459915a0d4735300cf",
|
||||
"sha256:b07ac0b1533298ddbc54c9bf3464664895f22899fec027b8d6c8d3ac59023283",
|
||||
"sha256:d9385f1445e30e8e42b75a36a7899ea1fd0f5784233a626625d70f9b087de404",
|
||||
"sha256:db2d1fcd32dbeeb914b2660af1838e9c178b75173f95fd221b1f9410b5d3ef1d",
|
||||
"sha256:e1dec211147f1fd7cb7a0f9a96aeeca467a5af02d38911307b3b8c2324f9917e",
|
||||
"sha256:e96dffc1fa57bb8c1c238f3d989341a97302492d09cb11f77df031112621c35c",
|
||||
"sha256:ed4d97eb0ecdee29d0748acd84e6380729f78ce5ba0c7fe3401801634c25a1c5"
|
||||
],
|
||||
"version": "==5.0a3"
|
||||
},
|
||||
"docutils": {
|
||||
"hashes": [
|
||||
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
|
||||
@@ -409,9 +487,10 @@
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
|
||||
"sha256:a7de3201740a857380421ef286166134e10fe58846bcefbc9d6424a69a0b99ec",
|
||||
"sha256:aca4fc561b7671115a2156f625f2eaa5e0e3527e0adf2870340e7968c0a81f85"
|
||||
],
|
||||
"version": "==0.24"
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
@@ -426,6 +505,13 @@
|
||||
],
|
||||
"version": "==1.0"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:82b201ad767eb54de371c08cb1db6ca4ad2a728fa41b831e3781bf944815eb38",
|
||||
"sha256:c250f37ac0e249a8287394a60d91f6240b674642ad999e66cd09463dbccd1d4f"
|
||||
],
|
||||
"version": "==3.0.0b18"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
|
||||
@@ -457,10 +543,10 @@
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
|
||||
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
|
||||
"sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095",
|
||||
"sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f"
|
||||
],
|
||||
"version": "==0.7.1"
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
@@ -505,11 +591,19 @@
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:7e258ee50338f4e46957f9e09a0f10fb1c2d05493fa901d113a8dafd0790de4e",
|
||||
"sha256:9332147e9af2dcf46cd7ceb14d5acadb6564744ddff1fe8c17f0ce60ece7d9a2"
|
||||
"sha256:10e59f84267370ab20cec9305bafe7505ba4d6b93ecbf66a1cce86193ed511d5",
|
||||
"sha256:8c827e7d4816dfe13e9329c8226aef8e6e75d65b939bc74fda894143b6d1df59"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.8.2"
|
||||
"version": "==3.9.1"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
"sha256:513c425e931a0344944f84ea47f3956be0e416d95acbd897a44970c8d926d5d7",
|
||||
"sha256:e360f048b7dae3f2f2a9a4d067b2dd6b6a015d384d1577c994a43f3f7cbad762"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
@@ -527,10 +621,10 @@
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
|
||||
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
|
||||
"sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c",
|
||||
"sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279"
|
||||
],
|
||||
"version": "==2.19.1"
|
||||
"version": "==2.20.0"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
@@ -577,10 +671,10 @@
|
||||
},
|
||||
"tqdm": {
|
||||
"hashes": [
|
||||
"sha256:18f1818ce951aeb9ea162ae1098b43f583f7d057b34d706f66939353d1208889",
|
||||
"sha256:df02c0650160986bac0218bb07952245fc6960d23654648b5d5526ad5a4128c9"
|
||||
"sha256:a0be569511161220ff709a5b60d0890d47921f746f1c737a11d965e1b29e7b2e",
|
||||
"sha256:e293e6d7a7f41a529a27f8d6624ab11544ccbfe82a205af6fad102545099fc21"
|
||||
],
|
||||
"version": "==4.26.0"
|
||||
"version": "==4.27.0"
|
||||
},
|
||||
"twine": {
|
||||
"hashes": [
|
||||
@@ -592,10 +686,10 @@
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
|
||||
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
|
||||
"sha256:41c3db2fc01e5b907288010dec72f9d0a74e37d6994e6eb56849f59fea2265ae",
|
||||
"sha256:8819bba37a02d143296a4d032373c4dd4aca11f6d4c9973335ca75f9c8475f59"
|
||||
],
|
||||
"version": "==1.23"
|
||||
"version": "==1.24"
|
||||
},
|
||||
"webencodings": {
|
||||
"hashes": [
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# Responder: a familiar HTTP Service Framework for Python
|
||||
|
||||

|
||||
[](https://travis-ci.org/kennethreitz/responder)
|
||||
[](https://responder.readthedocs.io/en/latest/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://github.com/kennethreitz/responder/graphs/contributors)
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd bring some of my ideas to the table and see what I could come up with.
|
||||
[](http://python-responder.org/)
|
||||
|
||||
## But will it blend?
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd spread some [Hacktoberfest](https://hacktoberfest.digitalocean.com/) spirit around, bring some of my ideas to the table, and see what I could come up with.
|
||||
|
||||
```python
|
||||
import responder
|
||||
@@ -12,14 +17,29 @@ import responder
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
```
|
||||
|
||||
This gets you a WSGI app, with WhiteNoise pre-installed, jinja2 templating (without additional imports), and a production webserver (ready for slowloris attacks), serving up requests with gzip compression automatically.
|
||||
That `async` declaration is optional. [View documentation](http://python-responder.org).
|
||||
|
||||
This gets you a ASGI app, with a production static files server pre-installed, jinja2 templating (without additional imports), and a production webserver based on uvloop, serving up requests with gzip compression automatically.
|
||||
|
||||
|
||||
## Testimonials
|
||||
|
||||
> "Pleasantly very taken with python-responder. [@kennethreitz](https://twitter.com/kennethreitz) at his absolute best." —Rudraksh M.K.
|
||||
|
||||
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that." — Tom Christie author of [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of [Two Scoops of Django]()
|
||||
|
||||
> "Love what I have seen while it's in progress! Many features of Responder are from my wishlist for Flask, and it's even faster and even easier than Flask!" — Luna C.
|
||||
|
||||
## More Examples
|
||||
|
||||
Class-based views (and setting some headers and stuff):
|
||||
|
||||
@@ -42,7 +62,22 @@ def greet_world(req, resp, *, greeting):
|
||||
|
||||
The `api` instance is available as an object during template rendering.
|
||||
|
||||
Serve a GraphQL API:
|
||||
Here, you can spawn off a background thread to run any function, out-of-request:
|
||||
|
||||
```python
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def sleep(s=10):
|
||||
time.sleep(s)
|
||||
print("slept!")
|
||||
|
||||
sleep()
|
||||
resp.content = "processing"
|
||||
```
|
||||
|
||||
And even serve a GraphQL API:
|
||||
|
||||
```python
|
||||
import graphene
|
||||
@@ -51,7 +86,7 @@ class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
```
|
||||
@@ -80,46 +115,56 @@ Want HSTS?
|
||||
api = responder.API(enable_hsts=True)
|
||||
```
|
||||
|
||||
Boom. ✨🍰✨
|
||||
Boom.
|
||||
|
||||
|
||||
# Installing Responder
|
||||
|
||||
Install the latest release:
|
||||
|
||||
|
||||
$ pipenv install responder
|
||||
✨🍰✨
|
||||
|
||||
|
||||
Or, install from the development branch:
|
||||
|
||||
$ pipenv install -e git+https://github.com/kennethreitz/responder.git#egg=responder
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
|
||||
|
||||
# The Basic Idea
|
||||
|
||||
The primary concept here is to bring the nicities that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.content` sends back bytes.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.content` override this).
|
||||
- Case-insensitive `req.headers` dict (from Requests directly).
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familar friends.
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familiar friends.
|
||||
|
||||
## New Ideas
|
||||
## Ideas
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially `response.media`, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses (still working on that).
|
||||
- Automatic gzipped-responses.
|
||||
- In addition to Falcon's `on_get`, `on_post`, etc methods, Responder features an `on_request` method, which gets called on every type of request, much like Requests.
|
||||
- WhiteNoise is built-in, for serving static files.
|
||||
- Waitress built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Waitress serves well to protect against slowloris attacks, making nginx unnecessary in production.
|
||||
- A production static file server is built-in.
|
||||
- Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris attacks, making nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
|
||||
## Old Ideas
|
||||
|
||||
- Flask-style route expression, with new capabilities -- primarily, the ability to cast a parameter to integers as well as other types that are missing from Flask, all while using Python 3.6+'s new f-string syntax.
|
||||
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially `response.media`, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
|
||||
## Future Ideas
|
||||
|
||||
- I want to be able to "mount" any WSGI app into a sub-route.
|
||||
- Cooke-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- Potentially support ASGI instead of WSGI. Will the tradeoffs be worth it? This is a question to ask. Procedural code works well for 90% use cases.
|
||||
- Cookie-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
|
||||
# The Goal
|
||||
|
||||
The primary goal here is to learn, not to get adoption. Though, who knows how these things will pan out.
|
||||
|
||||
# When can I use it?
|
||||
|
||||
When it's ready. It's not. I started work on this a few days ago. It works surprisingly well, considering! :)
|
||||
----------
|
||||
|
||||
[](https://hacktoberfest.digitalocean.com/)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import responder
|
||||
import graphene
|
||||
|
||||
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def hello_world():
|
||||
return "Hello, World from flask!"
|
||||
|
||||
|
||||
api = responder.API(enable_hsts=False)
|
||||
api.mount("/hello", app)
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
# resp.status = responder.status.ok
|
||||
resp.content = api.template("test.html")
|
||||
|
||||
|
||||
class ThingsResource:
|
||||
def on_request(self, req, resp):
|
||||
resp.status = responder.status.HTTP_200
|
||||
resp.media = ["yolo"]
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
# Alerntatively,
|
||||
api.add_route("/graph", schema, graphiql=True)
|
||||
|
||||
print(
|
||||
api.session()
|
||||
.get(
|
||||
"http://app/",
|
||||
# data="{ hello }",
|
||||
# headers={"Accept": "application/x-yaml"},
|
||||
# data="hello",
|
||||
)
|
||||
.text
|
||||
)
|
||||
|
||||
# print(
|
||||
# api.session()
|
||||
# .get(
|
||||
# "http://app/hello/",
|
||||
# data="{ hello }",
|
||||
# headers={"Accept": "application/x-yaml"},
|
||||
# # data="hello",
|
||||
# )
|
||||
# .text
|
||||
# )
|
||||
# {hello: Hello stranger}
|
||||
|
||||
api.run(port=5000)
|
||||
@@ -22,6 +22,7 @@
|
||||
}
|
||||
|
||||
pre,
|
||||
.pre,
|
||||
.class em,
|
||||
.descname,
|
||||
.method em {
|
||||
@@ -49,9 +50,13 @@
|
||||
.method em,
|
||||
.class em {
|
||||
font-style: italic !important;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.method em,
|
||||
.class em {
|
||||
margin-left: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.method p,
|
||||
@@ -71,6 +76,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
#testimonials p.attribution {
|
||||
margin-top: -1em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* "Quick Search" should be not be shown for now. */
|
||||
div#searchbox h3 {
|
||||
@@ -105,19 +115,13 @@
|
||||
</style>
|
||||
|
||||
<!-- Analytics tracking for Kenneth. -->
|
||||
<script type="text/javascript">
|
||||
var _gauges = _gauges || [];
|
||||
(function () {
|
||||
var t = document.createElement('script');
|
||||
t.type = 'text/javascript';
|
||||
t.async = true;
|
||||
t.id = 'gauges-tracker';
|
||||
t.setAttribute('data-site-id', '588f8e99c88d9013e60fa373');
|
||||
t.setAttribute('data-track-path', 'https://track.gaug.es/track.gif');
|
||||
t.src = 'https://d36ee2fcip1434.cloudfront.net/track.js';
|
||||
var s = document.getElementsByTagName('script')[0];
|
||||
s.parentNode.insertBefore(t, s);
|
||||
})();
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-127383416-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-127383416-1');
|
||||
</script>
|
||||
|
||||
<!-- There are no more hacks. -->
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
API Documentation
|
||||
=================
|
||||
|
||||
|
||||
Web Service (API) Class
|
||||
-----------------------
|
||||
.. module:: responder
|
||||
|
||||
.. autoclass:: API
|
||||
:inherited-members:
|
||||
|
||||
Requests & Responses
|
||||
--------------------
|
||||
|
||||
|
||||
.. autoclass:: Request
|
||||
:inherited-members:
|
||||
|
||||
.. autoclass:: Response
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Utility Functions
|
||||
-----------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_100
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_200
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_400
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_500
|
||||
@@ -0,0 +1,57 @@
|
||||
Deploying Responder
|
||||
===================
|
||||
|
||||
You can deploy Responder anywhere you can deploy a basic Python application.
|
||||
|
||||
Docker Deployment
|
||||
-----------------
|
||||
|
||||
Assuming existing ``api.py`` and ``Pipfile.lock`` containing ``responder``.
|
||||
|
||||
``Dockerfile``::
|
||||
|
||||
from kennethreitz/pipenv
|
||||
|
||||
COPY . /app
|
||||
CMD python3 api.py
|
||||
|
||||
That's it!
|
||||
|
||||
Heroku Deployment
|
||||
-----------------
|
||||
|
||||
The basics::
|
||||
|
||||
$ mkdir my-api
|
||||
$ cd my-api
|
||||
$ git init
|
||||
$ heroku create
|
||||
...
|
||||
|
||||
Install Responder::
|
||||
|
||||
$ pipenv install responder
|
||||
...
|
||||
|
||||
Write out an ``api.py``::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
async def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Write out a ``Procfile``::
|
||||
|
||||
web: python api.py
|
||||
|
||||
That's it! Next, we commit and push to Heroku::
|
||||
|
||||
$ git add -A
|
||||
$ git commit -m 'initial commit'
|
||||
$ git push heroku master
|
||||
+116
-136
@@ -3,130 +3,113 @@
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
A familar HTTP Service Framework
|
||||
================================
|
||||
A familiar HTTP Service Framework
|
||||
=================================
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd bring some of my ideas to the table and see what I could come up with.
|
||||
|Build Status| |image1| |image2| |image3| |image4| |image5|
|
||||
|
||||
But will it blend?
|
||||
------------------
|
||||
.. |Build Status| image:: https://travis-ci.org/kennethreitz/responder.svg?branch=master
|
||||
:target: https://travis-ci.org/kennethreitz/responder
|
||||
.. |image1| image:: https://img.shields.io/pypi/v/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image2| image:: https://img.shields.io/pypi/l/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image3| image:: https://img.shields.io/pypi/pyversions/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image4| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
|
||||
:target: https://github.com/kennethreitz/responder/graphs/contributors
|
||||
.. |image5| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
|
||||
:target: https://saythanks.io/to/kennethreitz
|
||||
|
||||
::
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd
|
||||
spread some `Hacktoberfest <https://hacktoberfest.digitalocean.com/>`_ spirit around, bring some of my ideas to the table, and see what I could come up with.
|
||||
|
||||
import responder
|
||||
.. code:: python
|
||||
|
||||
api = responder.API()
|
||||
import responder
|
||||
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
api = responder.API()
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
This gets you a WSGI app, with WhiteNoise pre-installed, jinja2 templating (without additional imports), and a production webserver (ready for slowloris attacks), serving up requests with gzip compression automatically.
|
||||
That ``async`` declaration is optional.
|
||||
|
||||
-------------
|
||||
This gets you a ASGI app, with a production static files server
|
||||
pre-installed, jinja2 templating (without additional imports), and a
|
||||
production webserver based on uvloop, serving up requests with gzip
|
||||
compression automatically.
|
||||
|
||||
Class-based views (and setting some headers and stuff)::
|
||||
Features
|
||||
--------
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(req, resp, *, greeting): # or on_get...
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
- A pleasant API, with a single import statement.
|
||||
- Class-based views without inheritence.
|
||||
- ASGI framework, the future of Python web services.
|
||||
- The ability to mount any ASGI / WSGI app at a subroute.
|
||||
- *f-string syntax* route declaration.
|
||||
- Mutable response object, passed into each view. No need to return anything.
|
||||
- Background tasks, spawned off in a ``ThreadPoolExecutor``.
|
||||
- GraphQL (with *GraphiQL*) support!
|
||||
- OpenAPI schema generation.
|
||||
- Single-page webapp support!
|
||||
|
||||
|
||||
Render a template, with arguments::
|
||||
|
||||
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.content = api.template("index.html", greeting=greeting)
|
||||
|
||||
|
||||
The ``api`` instance is available as an object during template rendering.
|
||||
|
||||
Serve a GraphQL API::
|
||||
|
||||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
|
||||
|
||||
We can then send a query to our service::
|
||||
>>> requests = api.session()
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello }"})
|
||||
>>> r.json()
|
||||
{'data': {'hello': 'Hello stranger'}}
|
||||
|
||||
|
||||
Or, request YAML back::
|
||||
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello(name:\"john\") }"}, headers={"Accept": "application/x-yaml"})
|
||||
>>> print(r.text)
|
||||
data: {hello: Hello john}
|
||||
|
||||
|
||||
|
||||
Want HSTS?
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
|
||||
Boom. ✨🍰✨
|
||||
|
||||
|
||||
The Basic Idea
|
||||
--------------
|
||||
|
||||
The primary concept here is to bring the nicities that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.content` sends back bytes.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.content` override this).
|
||||
- Case-insensitive `req.headers` dict (from Requests directly).
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familar friends.
|
||||
|
||||
New Ideas
|
||||
---------
|
||||
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses (still working on that).
|
||||
- In addition to Falcon's ``on_get``, ``on_post``, etc methods, Responder features an `on_request` method, which gets called on every type of request, much like Requests.
|
||||
- WhiteNoise is built-in, for serving static files.
|
||||
- Waitress built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Waitress serves well to protect against slowloris attacks, making nginx unneccessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
|
||||
Old Ideas
|
||||
---------
|
||||
|
||||
- Flask-style route expression, with new capabilities -- primarily, the ability to cast a parameter to integers as well as other types that are missing from Flask, all while using Python 3.6+'s new f-string syntax.
|
||||
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially `response.media`, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
|
||||
Future Ideas
|
||||
Testimonials
|
||||
------------
|
||||
|
||||
- I want to be able to "mount" any WSGI app into a sub-route.
|
||||
- Cooke-based sessions are currently an afterthrought, as this is an API framework, but websites are APIs too.
|
||||
- Potentially support ASGI instead of WSGI. Will the tradeoffs be worth it? This is a question to ask. Procedural code works well for 90% use cases.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
“Pleasantly very taken with python-responder.
|
||||
`@kennethreitz <https://twitter.com/kennethreitz>`_ at his absolute
|
||||
best.”
|
||||
|
||||
—Rudraksh M.K.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
..
|
||||
|
||||
"ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that."
|
||||
|
||||
—Tom Christie, author of `Django REST Framework`_
|
||||
|
||||
..
|
||||
|
||||
|
||||
“I love that you are exploring new patterns. Go go go!”
|
||||
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
|
||||
|
||||
..
|
||||
|
||||
|
||||
“The most ambitious crossover event in history.”
|
||||
|
||||
—Pablo Cabezas, `on Tom Christie joining the project`_
|
||||
|
||||
|
||||
.. _APIStar: https://github.com/encode/apistar
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _Two Scoops of Django:
|
||||
.. _on Tom Christie joining the project: https://twitter.com/pabloteleco/status/1050841098321620992?s=20
|
||||
|
||||
User Guides
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
quickstart
|
||||
tour
|
||||
deployment
|
||||
api
|
||||
|
||||
|
||||
Installing Responder
|
||||
--------------------
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
@@ -136,40 +119,37 @@ Installation
|
||||
Only **Python 3.6+** is supported.
|
||||
|
||||
|
||||
API Documentation
|
||||
=================
|
||||
The Basic Idea
|
||||
--------------
|
||||
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
|
||||
- Setting ``resp.text`` sends back unicode, while setting ``resp.content`` sends back bytes.
|
||||
- Setting ``resp.media`` sends back JSON/YAML (``.text``/``.content`` override this).
|
||||
- Case-insensitive ``req.headers`` dict (from Requests directly).
|
||||
- ``resp.status_code``, ``req.method``, ``req.url``, and other familiar friends.
|
||||
|
||||
Ideas
|
||||
-----
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially ``response.media``, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses.
|
||||
- In addition to Falcon's ``on_get``, ``on_post``, etc methods, Responder features an ``on_request`` method, which gets called on every type of request, much like Requests.
|
||||
- A production static files server is built-in.
|
||||
- Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris attacks, making nginx unneccessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
|
||||
Web Service (API) Class
|
||||
-----------------------
|
||||
.. module:: responder
|
||||
Future Ideas
|
||||
------------
|
||||
|
||||
.. autoclass:: API
|
||||
:inherited-members:
|
||||
|
||||
Requests & Responses
|
||||
--------------------
|
||||
- Cookie-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
|
||||
|
||||
.. autoclass:: Request
|
||||
:inherited-members:
|
||||
|
||||
.. autoclass:: Response
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Utility Functions
|
||||
-----------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_100
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_200
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_400
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_500
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
Quick Start!
|
||||
============
|
||||
|
||||
This section of the documentation exists to provide an introduction to the Responder interface,
|
||||
as well as educate the user on basic functionality.
|
||||
|
||||
|
||||
Declare a Web Service
|
||||
---------------------
|
||||
|
||||
The first thing you need to do is declare a web service::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
Hello World!
|
||||
------------
|
||||
|
||||
Then, you can add a view / route to it.
|
||||
|
||||
Here, we'll make the root URL say "hello world!"::
|
||||
|
||||
@api.route("/")
|
||||
def hello_world(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
Run the Server
|
||||
--------------
|
||||
|
||||
Next, we can run our web service easily, with ``api.run()``::
|
||||
|
||||
api.run()
|
||||
|
||||
This will spin up a production web server on port ``5042``, ready for incoming HTTP requests.
|
||||
|
||||
Note: you can pass ``port=5000`` if you want to customize the port. The ``PORT`` environment variable for established web service providers (e.g. Heroku) will automatically be honored.
|
||||
|
||||
|
||||
Accept Route Arguments
|
||||
----------------------
|
||||
|
||||
If you want dynamic URLs, you can use Python's familiar *f-string syntax* to declare variables in your routes::
|
||||
|
||||
@api.route("/hello/{who}")
|
||||
def hello_to(req, resp, *, who):
|
||||
resp.text = f"hello, {who}!"
|
||||
|
||||
A ``GET`` request to ``/hello/brettcannon`` will result in a response of ``hello, brettcannon!``.
|
||||
|
||||
Returning JSON / YAML
|
||||
---------------------
|
||||
|
||||
If you want your API to send back JSON, simply set the ``resp.media`` property to a JSON-serializable Python object::
|
||||
|
||||
|
||||
@api.route("/hello/{who}/json")
|
||||
def hello_to(req, resp, *, who):
|
||||
resp.media = {"hello": who}
|
||||
|
||||
A ``GET`` request to ``/hello/guido/json`` will result in a response of ``{'hello': 'guido'}``.
|
||||
|
||||
If the client requests YAML instead (with a header of ``Accept: application/x-yaml``), YAML will be sent.
|
||||
|
||||
Rendering a Template
|
||||
--------------------
|
||||
|
||||
If you want to render a template, simply use ``api.template``. No need for additional imports::
|
||||
|
||||
@api.route("/hello/{who}/html")
|
||||
def hello_html(req, resp, *, who):
|
||||
resp.content = api.template('hello.html', who=who)
|
||||
|
||||
The ``api`` instance is available as an object during template rendering.
|
||||
|
||||
Setting Response Status Code
|
||||
----------------------------
|
||||
|
||||
If you want to set the response status code, simply set ``resp.status_code``::
|
||||
|
||||
@api.route("/416")
|
||||
def teapot(req, resp):
|
||||
resp.status_code = api.status_codes.HTTP_416 # ...or 416
|
||||
|
||||
|
||||
Setting Response Headers
|
||||
------------------------
|
||||
|
||||
If you want to set a response header, like ``X-Pizza: 42``, simply modify the ``resp.headers`` dictionary::
|
||||
|
||||
@api.route("/pizza")
|
||||
def pizza_pizza(req, resp):
|
||||
resp.headers['X-Pizza'] = 42
|
||||
|
||||
That's it!
|
||||
|
||||
|
||||
Receiving Data & Background Tasks
|
||||
---------------------------------
|
||||
|
||||
If you're expecting to read any request data, on the server, you need to declare your view as async and await the content.
|
||||
|
||||
Here, we'll process our data in the background, while responding immediately to the client::
|
||||
|
||||
import time
|
||||
|
||||
@api.route("/incoming")
|
||||
async def receive_incoming(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def process_data(data):
|
||||
"""Just sleeps for three seconds, as a demo."""
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
# Parse the incoming data as form-encoded.
|
||||
# Note: 'json' and 'yaml' formats are also automatically supported.
|
||||
data = await req.media()
|
||||
|
||||
# Process the data (in the background).
|
||||
process_data(data)
|
||||
|
||||
# Immediately respond that upload was successful.
|
||||
resp.media = {'success': True}
|
||||
|
||||
A ``POST`` request to ``/incoming`` will result in an immediate response of ``{'success': true}``.
|
||||
@@ -0,0 +1,190 @@
|
||||
Feature Tour
|
||||
============
|
||||
|
||||
|
||||
Class-Based Views
|
||||
-----------------
|
||||
|
||||
Class-based views (and setting some headers and stuff)::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(req, resp, *, greeting): # or on_get...
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
|
||||
|
||||
Background Tasks
|
||||
----------------
|
||||
|
||||
Here, you can spawn off a background thread to run any function, out-of-request::
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def sleep(s=10):
|
||||
time.sleep(s)
|
||||
print("slept!")
|
||||
|
||||
sleep()
|
||||
resp.content = "processing"
|
||||
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
|
||||
Serve a GraphQL API::
|
||||
|
||||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
|
||||
Visiting the endpoint will render a *GraphiQL* instance, in the browser.
|
||||
|
||||
|
||||
Built-in Testing Client (Requests)
|
||||
----------------------------------
|
||||
|
||||
We can then send a query to our service::
|
||||
|
||||
>>> requests = api.session()
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello }"})
|
||||
>>> r.json()
|
||||
{'data': {'hello': 'Hello stranger'}}
|
||||
|
||||
|
||||
Or, request YAML back::
|
||||
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello(name:\"john\") }"}, headers={"Accept": "application/x-yaml"})
|
||||
>>> print(r.text)
|
||||
data: {hello: Hello john}
|
||||
|
||||
OpenAPI Schema Support
|
||||
----------------------
|
||||
|
||||
Responder comes with built-in support for OpenAPI / marshmallow::
|
||||
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
api = responder.API(title="Web Service", version="1.0", openapi="3.0")
|
||||
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
schema:
|
||||
$ref = "#/components/schemas/Pet"
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
|
||||
::
|
||||
|
||||
>>> r = api.session().get("http://;/schema.yml")
|
||||
|
||||
>>> print(r.text)
|
||||
components:
|
||||
parameters: {}
|
||||
schemas:
|
||||
Pet:
|
||||
properties:
|
||||
name: {type: string}
|
||||
type: object
|
||||
info: {title: Web Service, version: 1.0}
|
||||
openapi: '3.0'
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200: {description: A pet to be returned, schema: $ref = "#/components/schemas/Pet"}
|
||||
tags: []
|
||||
|
||||
|
||||
Mount a WSGI App (e.g. Flask)
|
||||
-----------------------------
|
||||
|
||||
Responder gives you the ability to mount another ASGI / WSGI app at a subroute::
|
||||
|
||||
import responder
|
||||
from flask import Flask
|
||||
|
||||
api = responder.API()
|
||||
flask = Flask(__name__)
|
||||
|
||||
@flask.route('/')
|
||||
def hello():
|
||||
return 'hello'
|
||||
|
||||
api.mount('/flask', flask)
|
||||
|
||||
That's it!
|
||||
|
||||
Single-Page Web Apps
|
||||
--------------------
|
||||
|
||||
If you have a single-page webapp, you can tell Responder to serve up your ``static/index.html`` at a route, like so::
|
||||
|
||||
api.add_route("/", static=True)
|
||||
|
||||
This will make ``index.html`` the default response to all undefined routes.
|
||||
|
||||
Reading / Writing Cookies
|
||||
-------------------------
|
||||
|
||||
Responder makes it very easy to interact with cookies from a Request, or add some to a Response::
|
||||
|
||||
>>> resp.cookies["hello"] = "world"
|
||||
|
||||
>>> req.cookies
|
||||
{"hello": "world"}
|
||||
|
||||
|
||||
Using Cookie-Based Sessions
|
||||
---------------------------
|
||||
|
||||
Responder has built-in support for cookie-based sessions. To enable cookie-based sessions, simply add something to the ``resp.session`` dictionary::
|
||||
|
||||
>>> resp.session['username'] = 'kennethreitz'
|
||||
|
||||
A cookie called ``Responder-Session`` will be set, which contains all the data in ``resp.session``. It is signed, for verification purposes.
|
||||
|
||||
You can easily read a Request's session data, that can be trusted to have originated from the API::
|
||||
|
||||
>>> req.session
|
||||
{'username': 'kennethreitz'}
|
||||
|
||||
**Note**: if you are using this in production, you should pass the ``secret_key`` argument to ``API(...)``.
|
||||
|
||||
|
||||
HSTS (Redirect to HTTPS)
|
||||
------------------------
|
||||
|
||||
Want HSTS?
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
|
||||
Boom.
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
;addopts= -rsxX -s -v --strict
|
||||
filterwarnings =
|
||||
error::UserWarning
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.1"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
+317
-163
@@ -3,21 +3,28 @@ import json
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import waitress
|
||||
import uvicorn
|
||||
|
||||
import asyncio
|
||||
import jinja2
|
||||
from whitenoise import WhiteNoise
|
||||
from wsgiadapter import WSGIAdapter as RequestsWSGIAdapter
|
||||
from requests import Session as RequestsSession
|
||||
from werkzeug.wsgi import DispatcherMiddleware
|
||||
import itsdangerous
|
||||
from graphql_server import encode_execution_results, json_encode, default_format_error
|
||||
from starlette.routing import Router
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
from apispec import APISpec
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
from apispec import yaml_utils
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
|
||||
from . import models
|
||||
from .status_codes import HTTP_404
|
||||
from . import status_codes
|
||||
from .routes import Route
|
||||
from .formats import get_formats
|
||||
|
||||
from .background import BackgroundQueue
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
# TODO: consider moving status codes here
|
||||
class API:
|
||||
@@ -25,78 +32,154 @@ class API:
|
||||
|
||||
:param static_dir: The directory to use for static files. Will be created for you if it doesn't already exist.
|
||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
"""
|
||||
|
||||
status_codes = status_codes
|
||||
|
||||
def __init__(
|
||||
self, static_dir="static", templates_dir="templates", enable_hsts=False
|
||||
self,
|
||||
*,
|
||||
title=None,
|
||||
version=None,
|
||||
openapi=None,
|
||||
openapi_route="/schema.yml",
|
||||
static_dir="static",
|
||||
static_route="/static",
|
||||
templates_dir="templates",
|
||||
auto_escape=True,
|
||||
secret_key="NOTASECRET",
|
||||
enable_hsts=False,
|
||||
):
|
||||
self.secret_key = secret_key
|
||||
self.title = title
|
||||
self.version = version
|
||||
self.openapi_version = openapi
|
||||
self.static_dir = Path(os.path.abspath(static_dir))
|
||||
self.static_route = static_route
|
||||
self.templates_dir = Path(os.path.abspath(templates_dir))
|
||||
self.built_in_templates_dir = Path(
|
||||
os.path.abspath(os.path.dirname(__file__) + "/templates")
|
||||
)
|
||||
self.routes = {}
|
||||
self.schemas = {}
|
||||
self.session_cookie = "Responder-Session"
|
||||
|
||||
self.hsts_enabled = enable_hsts
|
||||
self.apps = {"/": self._wsgi_app}
|
||||
self.static_files = StaticFiles(directory=str(self.static_dir))
|
||||
self.apps = {self.static_route: self.static_files}
|
||||
|
||||
self.formats = get_formats()
|
||||
|
||||
# Make the static/templates directory if they don't exist.
|
||||
for _dir in (self.static_dir, self.templates_dir):
|
||||
os.makedirs(_dir, exist_ok=True)
|
||||
|
||||
# Mount the whitenoise application.
|
||||
self.whitenoise = WhiteNoise(self.__wsgi_app, root=str(self.static_dir))
|
||||
|
||||
# Cached requests session.
|
||||
self._session = None
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
def __wsgi_app(self, environ, start_response):
|
||||
# def wsgi_app(self, request):
|
||||
"""The actual WSGI application. This is not implemented in
|
||||
:meth:`__call__` so that middlewares can be applied without
|
||||
losing a reference to the app object. Instead of doing this::
|
||||
if self.openapi_version:
|
||||
self.add_route(openapi_route, self.schema_response)
|
||||
|
||||
app = MyMiddleware(app)
|
||||
self.default_endpoint = None
|
||||
self.app = self.dispatch
|
||||
self.add_middleware(GZipMiddleware)
|
||||
if self.hsts_enabled:
|
||||
self.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
It's a better idea to do this instead::
|
||||
# Jinja enviroment
|
||||
self.jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
[str(self.templates_dir), str(self.built_in_templates_dir)],
|
||||
followlinks=True,
|
||||
),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"] if auto_escape else []),
|
||||
)
|
||||
self.jinja_values_base = {"api": self} # Give reference to self.
|
||||
|
||||
app.wsgi_app = MyMiddleware(app.wsgi_app)
|
||||
@property
|
||||
def _apispec(self):
|
||||
spec = APISpec(
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
openapi_version=self.openapi_version,
|
||||
plugins=[MarshmallowPlugin()],
|
||||
)
|
||||
|
||||
Then you still have the original application object around and
|
||||
can continue to call methods on it.
|
||||
for route in self.routes:
|
||||
if self.routes[route].description:
|
||||
operations = yaml_utils.load_operations_from_docstring(
|
||||
self.routes[route].description
|
||||
)
|
||||
spec.add_path(path=route, operations=operations)
|
||||
|
||||
.. versionchanged:: 0.7
|
||||
Teardown events for the request and app contexts are called
|
||||
even if an unhandled error occurs. Other events may not be
|
||||
called depending on when an error occurs during dispatch.
|
||||
See :ref:`callbacks-and-errors`.
|
||||
for name, schema in self.schemas.items():
|
||||
spec.definition(name, schema=schema)
|
||||
|
||||
return spec
|
||||
|
||||
@property
|
||||
def openapi(self):
|
||||
return self._apispec.to_yaml()
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def __call__(self, scope):
|
||||
path = scope["path"]
|
||||
root_path = scope.get("root_path", "")
|
||||
|
||||
# Call into a submounted app, if one exists.
|
||||
for path_prefix, app in self.apps.items():
|
||||
if path.startswith(path_prefix):
|
||||
scope["path"] = path[len(path_prefix) :]
|
||||
scope["root_path"] = root_path + path_prefix
|
||||
try:
|
||||
return app(scope)
|
||||
except TypeError:
|
||||
app = WsgiToAsgi(app)
|
||||
return app(scope)
|
||||
|
||||
return self.app(scope)
|
||||
|
||||
def dispatch(self, scope):
|
||||
# Call the main dispatcher.
|
||||
async def asgi(receive, send):
|
||||
nonlocal scope, self
|
||||
|
||||
req = models.Request(scope, receive=receive, api=self)
|
||||
resp = await self._dispatch_request(req)
|
||||
await resp(receive, send)
|
||||
|
||||
return asgi
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a mashmallow schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
|
||||
self.schemas[name] = schema
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
:param environ: A WSGI environment.
|
||||
:param start_response: A callable accepting a status code,
|
||||
a list of headers, and an optional exception context to
|
||||
start the response.
|
||||
"""
|
||||
|
||||
req = models.Request(environ, start_response)
|
||||
# if not req.dispatched:
|
||||
resp = self._dispatch_request(req)
|
||||
return resp(environ, start_response)
|
||||
def decorator(f):
|
||||
self.add_schema(name=name, schema=f, **options)
|
||||
return f
|
||||
|
||||
def _wsgi_app(self, environ, start_response):
|
||||
return self.whitenoise(environ, start_response)
|
||||
|
||||
def wsgi_app(self, environ, start_response):
|
||||
"""Returns the WSGI app for this application (including all mounted WSGI apps)."""
|
||||
apps = self.apps.copy()
|
||||
main = apps.pop("/")
|
||||
|
||||
return DispatcherMiddleware(main, apps)(environ, start_response)
|
||||
|
||||
def __call__(self, environ, start_response=None):
|
||||
"""The WSGI server calls the Flask application object as the
|
||||
WSGI application. This calls :meth:`wsgi_app` which can be
|
||||
wrapped to applying middleware."""
|
||||
return self.wsgi_app(environ, start_response)
|
||||
return decorator
|
||||
|
||||
def path_matches_route(self, path):
|
||||
"""Given a path portion of a URL, tests that it matches against any registered route.
|
||||
@@ -107,77 +190,157 @@ class API:
|
||||
if route_object.does_match(path):
|
||||
return route
|
||||
|
||||
def _dispatch_request(self, req):
|
||||
def _prepare_cookies(self, resp):
|
||||
# print(resp.cookies)
|
||||
if resp.cookies:
|
||||
header = " ".join([f"{k}={v}" for k, v in resp.cookies.items()])
|
||||
resp.headers["Set-Cookie"] = header
|
||||
|
||||
@property
|
||||
def _signer(self):
|
||||
return itsdangerous.Signer(self.secret_key)
|
||||
|
||||
def _prepare_session(self, resp):
|
||||
|
||||
if resp.session:
|
||||
data = self._signer.sign(json.dumps(resp.session).encode("utf-8"))
|
||||
resp.cookies[self.session_cookie] = data.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def no_response(req, resp, **params):
|
||||
pass
|
||||
|
||||
async def _dispatch_request(self, req):
|
||||
# Set formats on Request object.
|
||||
req.formats = self.formats
|
||||
|
||||
route = self.path_matches_route(req.path)
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
# Get the route.
|
||||
route = self.path_matches_route(req.url.path)
|
||||
route = self.routes.get(route)
|
||||
params = route.incoming_matches(req.url.path)
|
||||
|
||||
if self.hsts_enabled:
|
||||
if req.url.startswith("http://"):
|
||||
url = req.url.replace("http://", "https://", 1)
|
||||
self.redirect(resp, location=url)
|
||||
# Create the response object.
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
cont = False
|
||||
|
||||
if route:
|
||||
try:
|
||||
params = self.routes[route].incoming_matches(req.path)
|
||||
self.routes[route].endpoint(req, resp, **params)
|
||||
# The request is using class-based views.
|
||||
except TypeError:
|
||||
if route.is_graphql:
|
||||
await self.graphql_response(req, resp, schema=route.endpoint)
|
||||
|
||||
elif route.is_function:
|
||||
try:
|
||||
view = self.routes[route].endpoint(**params)
|
||||
except TypeError:
|
||||
view = self.routes[route].endpoint
|
||||
try:
|
||||
# GraphQL Schema.
|
||||
assert hasattr(view, "execute")
|
||||
self.graphql_response(req, resp, schema=view)
|
||||
except AssertionError:
|
||||
# WSGI App.
|
||||
try:
|
||||
req.dispatched = True
|
||||
return view(
|
||||
environ=req._environ, start_response=req._start_response
|
||||
)
|
||||
except TypeError:
|
||||
pass
|
||||
# Run the view.
|
||||
r = route.endpoint(req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "cr_running"):
|
||||
await r
|
||||
except TypeError as e:
|
||||
cont = True
|
||||
except Exception:
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
if route.is_class_based or cont:
|
||||
try:
|
||||
view = route.endpoint(**params)
|
||||
except TypeError:
|
||||
view = route.endpoint
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
getattr(view, "on_request")(req, resp)
|
||||
except AttributeError:
|
||||
pass
|
||||
# Run the view.
|
||||
r = getattr(view, "on_request", self.no_response)(
|
||||
req, resp, **params
|
||||
)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
except Exception as e:
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
# Then on_get.
|
||||
method = req.method
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
getattr(view, f"on_{method}")(req, resp)
|
||||
except AttributeError:
|
||||
pass
|
||||
# Run the view.
|
||||
r = getattr(view, f"on_{method}", self.no_response)(
|
||||
req, resp, **params
|
||||
)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
except Exception as e:
|
||||
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
else:
|
||||
self.default_response(req, resp)
|
||||
self.default_response(req, resp, notfound=True)
|
||||
|
||||
self.default_response(req, resp)
|
||||
|
||||
self._prepare_session(resp)
|
||||
self._prepare_cookies(resp)
|
||||
|
||||
return resp
|
||||
|
||||
def add_route(self, route, endpoint, *, check_existing=True):
|
||||
# TODO: add graphiql
|
||||
def add_route(
|
||||
self, route, endpoint=None, *, default=False, static=False, check_existing=True
|
||||
):
|
||||
"""Add a route to the API.
|
||||
|
||||
:param route: A string representation of the route.
|
||||
:param endpoint: The endpoint for the route -- can be a callable, a class, a WSGI application, or graphene schema (GraphQL).
|
||||
:param endpoint: The endpoint for the route -- can be a callable, a class, or graphene schema (GraphQL).
|
||||
:param default: If ``True``, all unknown requests will route to this view.
|
||||
:param static: If ``True``, and no endpoint was passed, render "static/index.html", and it will become a default route.
|
||||
:param check_existing: If ``True``, an AssertionError will be raised, if the route is already defined.
|
||||
"""
|
||||
if check_existing:
|
||||
assert route not in self.routes
|
||||
|
||||
# TODO: Support grpahiql.
|
||||
self.routes[route] = Route(route, endpoint)
|
||||
if not endpoint and static:
|
||||
endpoint = self.static_response
|
||||
default = True
|
||||
|
||||
def default_response(self, req, resp):
|
||||
resp.status_code = HTTP_404
|
||||
resp.text = "Not found."
|
||||
if default:
|
||||
self.default_endpoint = endpoint
|
||||
|
||||
try:
|
||||
if callable(endpoint):
|
||||
endpoint.is_routed = True
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
self.routes[route] = Route(route, endpoint)
|
||||
# TODO: A better datastructer or sort it once the app is loaded
|
||||
self.routes = dict(
|
||||
sorted(self.routes.items(), key=lambda item: item[1]._weight())
|
||||
)
|
||||
|
||||
def default_response(self, req, resp, notfound=False, error=False):
|
||||
if resp.status_code is None:
|
||||
resp.status_code = 200
|
||||
|
||||
if self.default_endpoint:
|
||||
self.default_endpoint(req, resp)
|
||||
else:
|
||||
if notfound:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
if error:
|
||||
resp.status_code = status_codes.HTTP_500
|
||||
resp.text = "Application error."
|
||||
|
||||
def static_response(self, req, resp):
|
||||
index = (self.static_dir / "index.html").resolve()
|
||||
if os.path.exists(index):
|
||||
with open(index, "r") as f:
|
||||
resp.text = f.read()
|
||||
|
||||
def schema_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_200
|
||||
resp.headers["Content-Type"] = "application/x-yaml"
|
||||
resp.content = self.openapi
|
||||
|
||||
def redirect(
|
||||
self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301
|
||||
@@ -198,29 +361,45 @@ class API:
|
||||
resp.headers.update({"Location": location})
|
||||
|
||||
@staticmethod
|
||||
def _resolve_graphql_query(req):
|
||||
async def _resolve_graphql_query(req):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
|
||||
if "json" in req.mimetype:
|
||||
return req.json()["query"]
|
||||
json_media = await req.media("json")
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in form data.
|
||||
if "query" in req.media("form"):
|
||||
return req.media("form")["query"]
|
||||
if "q" in req.media("form"):
|
||||
return req.media("form")["q"]
|
||||
# Form data is awaiting https://github.com/encode/starlette/pull/102
|
||||
# if "query" in req.media("form"):
|
||||
# return req.media("form")["query"], None, None
|
||||
# if "q" in req.media("form"):
|
||||
# return req.media("form")["q"], None, None
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"]
|
||||
return req.params["query"], None, None
|
||||
if "q" in req.params:
|
||||
return req.params["q"]
|
||||
return req.params["q"], None, None
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text
|
||||
return req.text, None, None
|
||||
|
||||
def graphql_response(self, req, resp, schema):
|
||||
query = self._resolve_graphql_query(req)
|
||||
result = schema.execute(query)
|
||||
async def graphql_response(self, req, resp, schema):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.template_string(GRAPHIQL, endpoint=req.url.path)
|
||||
return
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req)
|
||||
result = schema.execute(
|
||||
query, variables=variables, operation_name=operation_name
|
||||
)
|
||||
result, status_code = encode_execution_results(
|
||||
[result],
|
||||
is_batch=False,
|
||||
@@ -231,13 +410,13 @@ class API:
|
||||
return (query, result, status_code)
|
||||
|
||||
def route(self, route, **options):
|
||||
"""Decorator for creating new routes around function and class defenitions.
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.route("/hello")
|
||||
def hello(req, resp):
|
||||
req.text = "hello, world!"
|
||||
resp.text = "hello, world!"
|
||||
|
||||
"""
|
||||
|
||||
@@ -247,27 +426,25 @@ class API:
|
||||
|
||||
return decorator
|
||||
|
||||
def mount(self, route, wsgi_app):
|
||||
"""Mounts a WSGI application at a given route.
|
||||
def mount(self, route, app):
|
||||
"""Mounts an WSGI / ASGI application at a given route.
|
||||
|
||||
:param route: String representation of the route to be used (shouldn't be parameterized).
|
||||
:param wsgi_app: The other WSGI app (e.g. a Flask app).
|
||||
:param app: The other WSGI / ASGI app.
|
||||
"""
|
||||
self.apps.update({route: wsgi_app})
|
||||
self.apps.update({route: app})
|
||||
|
||||
def session(self, base_url="http://;"):
|
||||
"""Testing HTTP client. Returns a Requests session object, able to send HTTP requests to the WSGI application.
|
||||
"""Testing HTTP client. Returns a Requests session object, able to send HTTP requests to the Responder application.
|
||||
|
||||
:param base_url: The URL to mount the connection adaptor to.
|
||||
"""
|
||||
|
||||
if self._session is None:
|
||||
session = RequestsSession()
|
||||
session.mount(base_url, RequestsWSGIAdapter(self))
|
||||
self._session = session
|
||||
self._session = TestClient(self)
|
||||
return self._session
|
||||
|
||||
def url_for(self, endpoint, absolute_url=False, **params):
|
||||
def url_for(self, endpoint, testing=False, **params):
|
||||
# TODO: Absolute_url
|
||||
"""Given an endpoint, returns a rendered URL for its route.
|
||||
|
||||
@@ -276,83 +453,60 @@ class API:
|
||||
"""
|
||||
for (route, route_object) in self.routes.items():
|
||||
if route_object.endpoint == endpoint:
|
||||
return route_object.url(**params)
|
||||
return route_object.url(testing=testing, **params)
|
||||
elif route_object.endpoint_name == endpoint:
|
||||
return route_object.url(testing=testing, **params)
|
||||
raise ValueError
|
||||
|
||||
def template(self, name, auto_escape=True, **values):
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
def template(self, name_, **values):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
|
||||
Note: The current ``api`` instance is always passed into the view.
|
||||
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
|
||||
:param name: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param auto_escape: If ``True``, HTML and XML will automatically be escaped.
|
||||
:param name_: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param values: Data to pass into the template.
|
||||
"""
|
||||
# Give reference to self.
|
||||
values.update(api=self)
|
||||
# Prepopulate values with base
|
||||
values = {**self.jinja_values_base, **values}
|
||||
|
||||
if auto_escape:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
str(self.templates_dir), followlinks=True
|
||||
),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
)
|
||||
else:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
str(self.templates_dir), followlinks=True
|
||||
),
|
||||
autoescape=jinja2.select_autoescape([]),
|
||||
)
|
||||
|
||||
template = env.get_template(name)
|
||||
template = self.jinja_env.get_template(name_)
|
||||
return template.render(**values)
|
||||
|
||||
def template_string(self, s, auto_escape=True, **values):
|
||||
def template_string(self, s_, **values):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
|
||||
|
||||
Note: The current ``api`` instance is always passed into the view.
|
||||
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
|
||||
:param s: The template to use.
|
||||
:param auto_escape: If ``True``, HTML and XML will automatically be escaped.
|
||||
:param s_: The template to use.
|
||||
:param values: Data to pass into the template.
|
||||
"""
|
||||
# Give reference to self.
|
||||
values.update(api=self)
|
||||
# Prepopulate values with base
|
||||
values = {**self.jinja_values_base, **values}
|
||||
|
||||
if auto_escape:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.BaseLoader,
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
)
|
||||
else:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.BaseLoader, autoescape=jinja2.select_autoescape([])
|
||||
)
|
||||
|
||||
template = env.from_string(s)
|
||||
template = self.jinja_env.from_string(s_)
|
||||
return template.render(**values)
|
||||
|
||||
def run(self, address=None, port=None, **kwargs):
|
||||
"""Runs the application with Waitress. If the ``PORT`` environment
|
||||
def run(self, address=None, port=None, **options):
|
||||
"""Runs the application with uvicorn. If the ``PORT`` environment
|
||||
variable is set, requests will be served on that port automatically to all
|
||||
known hosts.
|
||||
|
||||
:param address: The address to bind to.
|
||||
:param port: The port to bind to. If none is provided, one will be selected at random.
|
||||
:param kwargs: Additional keyword arguments to send to ``waitress.serve()``.
|
||||
:param options: Additional keyword arguments to send to ``uvicorn.run()``.
|
||||
"""
|
||||
if "PORT" in os.environ:
|
||||
if address is None:
|
||||
address = "0.0.0.0"
|
||||
port = os.environ["PORT"]
|
||||
port = int(os.environ["PORT"])
|
||||
|
||||
if address is None:
|
||||
address = "127.0.0.1"
|
||||
if port is None:
|
||||
port = 0
|
||||
port = 5042
|
||||
|
||||
bind_to = f"{address}:{port}"
|
||||
|
||||
waitress.serve(app=self, listen=bind_to, **kwargs)
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import multiprocessing
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
def __init__(self, n=None):
|
||||
if n is None:
|
||||
n = multiprocessing.cpu_count()
|
||||
|
||||
self.n = n
|
||||
self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=n)
|
||||
self.results = []
|
||||
|
||||
def run(self, f, *args, **kwargs):
|
||||
self.pool._max_workers = self.n
|
||||
self.pool._adjust_thread_count()
|
||||
|
||||
f = self.pool.submit(f, *args, **kwargs)
|
||||
self.results.append(f)
|
||||
return f
|
||||
|
||||
def task(self, f):
|
||||
def do_task(*args, **kwargs):
|
||||
result = self.run(f, *args, **kwargs)
|
||||
return result
|
||||
|
||||
return do_task
|
||||
@@ -1 +1,43 @@
|
||||
"""Responder.
|
||||
|
||||
Usage:
|
||||
responder
|
||||
responder run [--build] [--debug] <module>
|
||||
responder build
|
||||
responder --version
|
||||
|
||||
Options:
|
||||
-h --help Show this screen.
|
||||
-v --version Show version.
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import docopt
|
||||
from .__version__ import __version__
|
||||
|
||||
|
||||
def cli():
|
||||
args = docopt.docopt(
|
||||
__doc__, argv=None, help=True, version=__version__, options_first=False
|
||||
)
|
||||
|
||||
module = args["<module>"]
|
||||
build = args["build"] or args["--build"]
|
||||
run = args["run"]
|
||||
|
||||
if build:
|
||||
os.system("npm run build")
|
||||
|
||||
if run:
|
||||
split_module = module.split(":")
|
||||
|
||||
if len(split_module) > 1:
|
||||
module = split_module[0]
|
||||
prop = split_module[1]
|
||||
else:
|
||||
prop = "api"
|
||||
|
||||
app = __import__(module)
|
||||
getattr(app, prop).run()
|
||||
@@ -1,2 +1,3 @@
|
||||
from .api import API
|
||||
from .models import Request, Response
|
||||
from .cli import cli
|
||||
|
||||
+47
-9
@@ -1,27 +1,65 @@
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import yaml
|
||||
import json
|
||||
from parse import findall
|
||||
from .models import QueryDict
|
||||
from requests_toolbelt.multipart import decoder
|
||||
|
||||
|
||||
def format_form(r, encode=False):
|
||||
if not encode:
|
||||
return r._wz.form
|
||||
async def format_form(r, encode=False):
|
||||
if encode:
|
||||
pass
|
||||
else:
|
||||
return QueryDict(await r.text)
|
||||
|
||||
|
||||
def format_yaml(r, encode=False):
|
||||
async def format_yaml(r, encode=False):
|
||||
if encode:
|
||||
r.headers.update({"Content-Type": "application/x-yaml"})
|
||||
return yaml.dump(r.media)
|
||||
return yaml.safe_dump(r.media)
|
||||
else:
|
||||
return yaml.load(r.content)
|
||||
return yaml.safe_load(await r.content)
|
||||
|
||||
|
||||
def format_json(r, encode=False):
|
||||
async def format_json(r, encode=False):
|
||||
if encode:
|
||||
r.headers.update({"Content-Type": "application/json"})
|
||||
return json.dumps(r.media)
|
||||
else:
|
||||
return json.loads(r.content)
|
||||
return json.loads(await r.content)
|
||||
|
||||
|
||||
async def format_files(r, encode=False):
|
||||
if encode:
|
||||
pass
|
||||
else:
|
||||
decoded = decoder.MultipartDecoder(await r.content, r.headers["Content-Type"])
|
||||
dump = {}
|
||||
for part in decoded.parts:
|
||||
header = part.headers[b"Content-Disposition"].decode("utf-8")
|
||||
filename = None
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
if len(split) > 1:
|
||||
key = split[0]
|
||||
value = split[1]
|
||||
|
||||
value = value[1:-1]
|
||||
|
||||
if key == "filename":
|
||||
filename = value
|
||||
|
||||
if filename:
|
||||
dump[filename] = part.content
|
||||
return dump
|
||||
|
||||
|
||||
def get_formats():
|
||||
return {"json": format_json, "yaml": format_yaml, "form": format_form}
|
||||
return {
|
||||
"json": format_json,
|
||||
"yaml": format_yaml,
|
||||
"form": format_form,
|
||||
"files": format_files,
|
||||
}
|
||||
|
||||
+226
-105
@@ -1,168 +1,289 @@
|
||||
import io
|
||||
import json
|
||||
import gzip
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
|
||||
import chardet
|
||||
import rfc3986
|
||||
import graphene
|
||||
import yaml
|
||||
from requests.models import Request as RequestsRequest
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from werkzeug.wrappers import Request as WerkzeugRequest
|
||||
from werkzeug.wrappers import BaseResponse as WerkzeugResponse
|
||||
|
||||
from requests.cookies import RequestsCookieJar
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from .status_codes import HTTP_200
|
||||
|
||||
# @staticmethod
|
||||
# def funcname(parameter_list):
|
||||
# pass
|
||||
from .statics import DEFAULT_ENCODING
|
||||
|
||||
|
||||
def flatten(d):
|
||||
for key, value in d.copy().items():
|
||||
if len(value) == 1:
|
||||
d[key] = value[0]
|
||||
class QueryDict(dict):
|
||||
def __init__(self, query_string):
|
||||
self.update(parse_qs(query_string))
|
||||
|
||||
return d
|
||||
def __getitem__(self, key):
|
||||
"""
|
||||
Return the last data value for this key, or [] if it's an empty list;
|
||||
raise KeyError if not found.
|
||||
"""
|
||||
list_ = super().__getitem__(key)
|
||||
try:
|
||||
return list_[-1]
|
||||
except IndexError:
|
||||
return []
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""
|
||||
Return the last data value for the passed key. If key doesn't exist
|
||||
or value is an empty list, return `default`.
|
||||
"""
|
||||
try:
|
||||
val = self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
if val == []:
|
||||
return default
|
||||
return val
|
||||
|
||||
def _get_list(self, key, default=None, force_list=False):
|
||||
"""
|
||||
Return a list of values for the key.
|
||||
|
||||
Used internally to manipulate values list. If force_list is True,
|
||||
return a new copy of values.
|
||||
"""
|
||||
try:
|
||||
values = super().__getitem__(key)
|
||||
except KeyError:
|
||||
if default is None:
|
||||
return []
|
||||
return default
|
||||
else:
|
||||
if force_list:
|
||||
values = list(values) if values is not None else None
|
||||
return values
|
||||
|
||||
def get_list(self, key, default=None):
|
||||
"""
|
||||
Return the list of values for the key. If key doesn't exist, return a
|
||||
default value.
|
||||
"""
|
||||
return self._get_list(key, default, force_list=True)
|
||||
|
||||
def items(self):
|
||||
"""
|
||||
Yield (key, value) pairs, where value is the last item in the list
|
||||
associated with the key.
|
||||
"""
|
||||
for key in self:
|
||||
yield key, self[key]
|
||||
|
||||
def items_list(self):
|
||||
"""
|
||||
Yield (key, value) pairs, where value is the the list.
|
||||
"""
|
||||
yield from super().items()
|
||||
|
||||
|
||||
# TODO: add slots
|
||||
class Request:
|
||||
def __init__(self, environ, start_response=None):
|
||||
self._wz = WerkzeugRequest(environ)
|
||||
self.start_response = start_response
|
||||
self.headers = CaseInsensitiveDict(
|
||||
self._wz.headers.to_wsgi_list()
|
||||
) #: A case-insensitive dictionary, containg all headers sent in the Request.
|
||||
self.method = (
|
||||
self._wz.method.lower()
|
||||
) #: The incoming HTTP method used for the request, lower-cased.
|
||||
self.full_url = (
|
||||
self._wz.url
|
||||
) #: The full URL of the Request, query parameters and all.
|
||||
self.url = (
|
||||
self._wz.base_url
|
||||
) #: The URL of the Request, without query parameters.
|
||||
self.full_path = (
|
||||
self._wz.full_path
|
||||
) #: The full path portion of the URL of the Request, query parameters and all.
|
||||
self.path = (
|
||||
self._wz.path
|
||||
) #: The path portion of the URL of the Request, without query parameters.
|
||||
self.params = flatten(
|
||||
parse_qs(self._wz.query_string.decode("utf-8"))
|
||||
) #: A dictionary of the parsed query paramaters used for the Request.
|
||||
self.query = self._wz.query_string.decode(
|
||||
"utf-8"
|
||||
) #: A string containing only the query paramaters of the Request.
|
||||
self.raw = self._wz.stream #: A raw file-like stream of the incoming Request.
|
||||
self.content = self._wz.get_data(
|
||||
cache=True, as_text=False
|
||||
) #: The Request body, as bytes.
|
||||
self.mimetype = self._wz.mimetype #: The mimetype of the incoming Request.
|
||||
# TODO: rip that out
|
||||
self.text = self._wz.get_data(
|
||||
cache=False, as_text=True
|
||||
) #: The Request body, as unicode.
|
||||
__slots__ = ["_starlette", "formats", "_headers", "_encoding", "api", "_content"]
|
||||
|
||||
def __init__(self, scope, receive, api=None):
|
||||
self._starlette = StarletteRequest(scope, receive)
|
||||
self.formats = None
|
||||
self._encoding = None
|
||||
self.api = api
|
||||
self._content = None
|
||||
|
||||
headers = CaseInsensitiveDict()
|
||||
for header, value in self._starlette.headers.items():
|
||||
headers[header] = value
|
||||
|
||||
self._headers = headers
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""The session data, in dict form, from the Request."""
|
||||
if "Responder-Session" in self.cookies:
|
||||
data = self.cookies[self.api.session_cookie]
|
||||
data = self.api._signer.unsign(data)
|
||||
return json.loads(data)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
"""A case-insensitive dictionary, containing all headers sent in the Request."""
|
||||
return self._headers
|
||||
|
||||
@property
|
||||
def mimetype(self):
|
||||
return self.headers.get("Content-Type", "")
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
"""The incoming HTTP method used for the request, lower-cased."""
|
||||
return self._starlette.method.lower()
|
||||
|
||||
@property
|
||||
def full_url(self):
|
||||
"""The full URL of the Request, query parameters and all."""
|
||||
return str(self._starlette.url)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""The parsed URL of the Request."""
|
||||
return rfc3986.urlparse(self.full_url)
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
"""The cookies sent in the Request, as a dictionary."""
|
||||
cookies = RequestsCookieJar()
|
||||
cookie_header = self.headers.get("cookie", "")
|
||||
|
||||
bc = SimpleCookie(cookie_header)
|
||||
for k, v in bc.items():
|
||||
cookies[k] = v
|
||||
|
||||
return cookies.get_dict()
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
"""A dictionary of the parsed query parameters used for the Request."""
|
||||
try:
|
||||
return QueryDict(self.url.query)
|
||||
except AttributeError:
|
||||
return QueryDict({})
|
||||
|
||||
@property
|
||||
async def encoding(self):
|
||||
"""The encoding of the Request's body. Can be set, manually. Must be awaited."""
|
||||
# Use the user-set encoding first.
|
||||
if self._encoding:
|
||||
return self._encoding
|
||||
|
||||
# Then try what's defined by the Request.
|
||||
elif await self.declared_encoding:
|
||||
return self.declared_encoding
|
||||
|
||||
# Then, automatically detect the encoding.
|
||||
else:
|
||||
return await self.apparent_encoding
|
||||
|
||||
@encoding.setter
|
||||
def encoding(self, value):
|
||||
self._encoding = value
|
||||
|
||||
@property
|
||||
async def content(self):
|
||||
"""The Request body, as bytes. Must be awaited."""
|
||||
if not self._content:
|
||||
self._content = await self._starlette.body()
|
||||
return self._content
|
||||
|
||||
@property
|
||||
async def text(self):
|
||||
"""The Request body, as unicode. Must be awaited."""
|
||||
return (await self.content).decode(await self.encoding)
|
||||
|
||||
@property
|
||||
async def declared_encoding(self):
|
||||
if "Encoding" in self.headers:
|
||||
return self.headers["Encoding"]
|
||||
|
||||
@property
|
||||
async def apparent_encoding(self):
|
||||
"""The apparent encoding, provided by the chardet library. Must be awaited."""
|
||||
declared_encoding = await self.declared_encoding
|
||||
|
||||
if declared_encoding:
|
||||
return declared_encoding
|
||||
else:
|
||||
return chardet.detect(await self.content)["encoding"]
|
||||
|
||||
@property
|
||||
def is_secure(self):
|
||||
"""Returns ``True`` if the incoming Request was securely made."""
|
||||
return self._wz.is_secure
|
||||
return self.url.scheme == "https"
|
||||
|
||||
def accepts(self, content_type):
|
||||
"""Returns ``True`` if the incoming Request accepts the given ``content_type``."""
|
||||
return content_type in self.headers["Accept"]
|
||||
return content_type in self.headers.get("Accept", [])
|
||||
|
||||
def media(self, format=None):
|
||||
"""Renders incoming json/yaml/form data as Python objects.
|
||||
async def media(self, format=None):
|
||||
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
|
||||
|
||||
:param format: The name of the format being used. Alternatively accepts a custom callable for the format type.
|
||||
"""
|
||||
|
||||
if format is None:
|
||||
format = "yaml" if "yaml" in self.mimetype or "" else "json"
|
||||
format = "form" if "form" in self.mimetype or "" else format
|
||||
|
||||
if format in self.formats:
|
||||
return self.formats[format](self)
|
||||
return await self.formats[format](self)
|
||||
else:
|
||||
return format(self)
|
||||
return await format(self)
|
||||
|
||||
|
||||
class Response:
|
||||
__slots__ = [
|
||||
"req",
|
||||
"status_code",
|
||||
"text",
|
||||
"content",
|
||||
"encoding",
|
||||
"media",
|
||||
"headers",
|
||||
"formats",
|
||||
"cookies",
|
||||
"session",
|
||||
]
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
self.status_code = HTTP_200 #: The HTTP Status Code to use for the Response.
|
||||
self.status_code = None #: The HTTP Status Code to use for the Response.
|
||||
self.text = None #: A unicode representation of the response body.
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.encoding = "utf-8"
|
||||
self.encoding = DEFAULT_ENCODING
|
||||
self.media = (
|
||||
None
|
||||
) #: A Python object that will be content-negotiated and sent back to the client. Typically, in JSON formatting.
|
||||
self.headers = (
|
||||
{}
|
||||
) #: A Python dictionary of {Key: value}, representing the headers of the response.
|
||||
) #: A Python dictionary of ``{key: value}``, representing the headers of the response.
|
||||
self.formats = formats
|
||||
self.cookies = {} #: The cookies set in the Response, as a dictionary
|
||||
self.session = (
|
||||
req.session.copy()
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
|
||||
@property
|
||||
def body(self):
|
||||
async def body(self):
|
||||
if self.content:
|
||||
return (self.content, self.mimetype, {})
|
||||
return (self.content, {})
|
||||
|
||||
if self.text:
|
||||
return (self.text.encode(self.encoding), {"Encoding": self.encoding})
|
||||
|
||||
for format in self.formats:
|
||||
if self.req.accepts(format):
|
||||
return self.formats[format](self, encode=True), {}
|
||||
return (await self.formats[format](self, encode=True)), {}
|
||||
|
||||
# Default to JSON anyway.
|
||||
else:
|
||||
return (json.dumps(self.media), {"Content-Type": "application/json"})
|
||||
return (
|
||||
await self.formats["json"](self, encode=True),
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
@property
|
||||
def gzipped_body(self):
|
||||
|
||||
body, headers = self.body
|
||||
|
||||
if isinstance(body, str):
|
||||
body = body.encode(self.encoding)
|
||||
|
||||
if "gzip" in self.req.headers["Accept-Encoding"].lower():
|
||||
gzip_buffer = io.BytesIO()
|
||||
gzip_file = gzip.GzipFile(mode="wb", fileobj=gzip_buffer)
|
||||
gzip_file.write(body)
|
||||
gzip_file.close()
|
||||
|
||||
new_headers = {
|
||||
"Content-Encoding": "gzip",
|
||||
"Vary": "Accept-Encoding",
|
||||
"Content-Length": str(len(body)),
|
||||
}
|
||||
headers.update(new_headers)
|
||||
|
||||
return (gzip_buffer.getvalue(), headers)
|
||||
else:
|
||||
return (body, headers)
|
||||
|
||||
@property
|
||||
def _wz(self):
|
||||
body, headers = self.body
|
||||
if len(self.body) > 500:
|
||||
body, headers = self.gzipped_body
|
||||
async def __call__(self, receive, send):
|
||||
body, headers = await self.body
|
||||
if self.headers:
|
||||
headers.update(self.headers)
|
||||
|
||||
r = WerkzeugResponse(body, status=self.status_code, direct_passthrough=False)
|
||||
r.headers = headers
|
||||
return r
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
return self._wz(environ, start_response)
|
||||
|
||||
|
||||
class Schema(graphene.Schema):
|
||||
def on_request(self, req, resp):
|
||||
pass
|
||||
response = StarletteResponse(
|
||||
body, status_code=self.status_code, headers=headers
|
||||
)
|
||||
await response(receive, send)
|
||||
|
||||
+51
-6
@@ -1,10 +1,24 @@
|
||||
from parse import parse, search
|
||||
import re
|
||||
from parse import parse
|
||||
|
||||
|
||||
def memoize(f):
|
||||
def helper(self, s):
|
||||
memoize_key = f"{f.__name__}:{s}"
|
||||
if memoize_key not in self._memo:
|
||||
self._memo[memoize_key] = f(self, s)
|
||||
return self._memo[memoize_key]
|
||||
|
||||
return helper
|
||||
|
||||
|
||||
class Route:
|
||||
_param_pattern = re.compile(r"{([^{}]*)}")
|
||||
|
||||
def __init__(self, route, endpoint):
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self._memo = {}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
@@ -18,9 +32,18 @@ class Route:
|
||||
return self.does_match(other)
|
||||
|
||||
@property
|
||||
def has_parameters(self):
|
||||
return all([("{" in self.route), ("}" in self.route)])
|
||||
def endpoint_name(self):
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self.endpoint.__doc__
|
||||
|
||||
@property
|
||||
def has_parameters(self):
|
||||
return bool(self._param_pattern.search(self.route))
|
||||
|
||||
@memoize
|
||||
def does_match(self, s):
|
||||
if s == self.route:
|
||||
return True
|
||||
@@ -28,11 +51,33 @@ class Route:
|
||||
named = self.incoming_matches(s)
|
||||
return bool(len(named))
|
||||
|
||||
@memoize
|
||||
def incoming_matches(self, s):
|
||||
results = parse(self.route, s)
|
||||
return results.named if results else {}
|
||||
|
||||
def url(self, **params):
|
||||
return self.route.format(**params)
|
||||
def url(self, testing=False, **params):
|
||||
url = self.route.format(**params)
|
||||
if testing:
|
||||
url = f"http://;{url}"
|
||||
|
||||
# def is_graphql, is_wsgi
|
||||
return url
|
||||
|
||||
def _weight(self):
|
||||
params = set(self._param_pattern.findall(self.route))
|
||||
params_count = len(params)
|
||||
return params_count != 0, -params_count
|
||||
|
||||
@property
|
||||
def is_graphql(self):
|
||||
return hasattr(self.endpoint, "get_graphql_type")
|
||||
|
||||
@property
|
||||
def is_class_based(self):
|
||||
return hasattr(self.endpoint, "__class__")
|
||||
|
||||
def is_function(self):
|
||||
routed = hasattr(self.endpoint, "is_routed")
|
||||
code = hasattr(self.endpoint, "__code__")
|
||||
kwdefaults = hasattr(self.endpoint, "__kwdefaults__")
|
||||
return all((routed, code, kwdefaults))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DEFAULT_ENCODING = "utf-8"
|
||||
@@ -0,0 +1,145 @@
|
||||
GRAPHIQL = """
|
||||
{% set GRAPHIQL_VERSION = '0.12.0' %}
|
||||
|
||||
<!--
|
||||
* Copyright (c) Facebook, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is licensed under the license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#graphiql {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!--
|
||||
This GraphiQL example depends on Promise and fetch, which are available in
|
||||
modern browsers, but can be "polyfilled" for older browsers.
|
||||
GraphiQL itself depends on React DOM.
|
||||
If you do not want to rely on a CDN, you can host these files locally or
|
||||
include them directly in your favored resource bunder.
|
||||
-->
|
||||
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.css" rel="stylesheet"/>
|
||||
<script src="//cdn.jsdelivr.net/npm/whatwg-fetch@2.0.3/fetch.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/react@16.2.0/umd/react.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/react-dom@16.2.0/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graphiql">Loading...</div>
|
||||
<script>
|
||||
|
||||
/**
|
||||
* This GraphiQL example illustrates how to use some of GraphiQL's props
|
||||
* in order to enable reading and updating the URL parameters, making
|
||||
* link sharing of queries a little bit easier.
|
||||
*
|
||||
* This is only one example of this kind of feature, GraphiQL exposes
|
||||
* various React params to enable interesting integrations.
|
||||
*/
|
||||
|
||||
// Parse the search string to get url parameters.
|
||||
var search = window.location.search;
|
||||
var parameters = {};
|
||||
search.substr(1).split('&').forEach(function (entry) {
|
||||
var eq = entry.indexOf('=');
|
||||
if (eq >= 0) {
|
||||
parameters[decodeURIComponent(entry.slice(0, eq))] =
|
||||
decodeURIComponent(entry.slice(eq + 1));
|
||||
}
|
||||
});
|
||||
|
||||
// if variables was provided, try to format it.
|
||||
if (parameters.variables) {
|
||||
try {
|
||||
parameters.variables =
|
||||
JSON.stringify(JSON.parse(parameters.variables), null, 2);
|
||||
} catch (e) {
|
||||
// Do nothing, we want to display the invalid JSON as a string, rather
|
||||
// than present an error.
|
||||
}
|
||||
}
|
||||
|
||||
// When the query and variables string is edited, update the URL bar so
|
||||
// that it can be easily shared
|
||||
function onEditQuery(newQuery) {
|
||||
parameters.query = newQuery;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function onEditVariables(newVariables) {
|
||||
parameters.variables = newVariables;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function onEditOperationName(newOperationName) {
|
||||
parameters.operationName = newOperationName;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function updateURL() {
|
||||
var newSearch = '?' + Object.keys(parameters).filter(function (key) {
|
||||
return Boolean(parameters[key]);
|
||||
}).map(function (key) {
|
||||
return encodeURIComponent(key) + '=' +
|
||||
encodeURIComponent(parameters[key]);
|
||||
}).join('&');
|
||||
history.replaceState(null, null, newSearch);
|
||||
}
|
||||
|
||||
// Defines a GraphQL fetcher using the fetch API. You're not required to
|
||||
// use fetch, and could instead implement graphQLFetcher however you like,
|
||||
// as long as it returns a Promise or Observable.
|
||||
function graphQLFetcher(graphQLParams) {
|
||||
// This example expects a GraphQL server at the path /graphql.
|
||||
// Change this to point wherever you host your GraphQL server.
|
||||
return fetch('{{ endpoint }}', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(graphQLParams),
|
||||
credentials: 'include',
|
||||
}).then(function (response) {
|
||||
return response.text();
|
||||
}).then(function (responseBody) {
|
||||
try {
|
||||
return JSON.parse(responseBody);
|
||||
} catch (error) {
|
||||
return responseBody;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Render <GraphiQL /> into the body.
|
||||
// See the README in the top level of this module to learn more about
|
||||
// how you can customize GraphiQL by providing different values or
|
||||
// additional child elements.
|
||||
ReactDOM.render(
|
||||
React.createElement(GraphiQL, {
|
||||
fetcher: graphQLFetcher,
|
||||
query: parameters.query,
|
||||
variables: parameters.variables,
|
||||
operationName: parameters.operationName,
|
||||
onEditQuery: onEditQuery,
|
||||
onEditVariables: onEditVariables,
|
||||
onEditOperationName: onEditOperationName
|
||||
}),
|
||||
document.getElementById('graphiql')
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".strip()
|
||||
@@ -22,16 +22,25 @@ if sys.argv[-1] == "publish":
|
||||
sys.exit()
|
||||
|
||||
required = [
|
||||
"waitress",
|
||||
"werkzeug",
|
||||
"starlette",
|
||||
"uvicorn",
|
||||
"aiofiles",
|
||||
"pyyaml",
|
||||
"requests",
|
||||
"requests-wsgi-adapter",
|
||||
"graphene",
|
||||
"graphql-server-core>=1.1",
|
||||
"whitenoise",
|
||||
"jinja2",
|
||||
"parse",
|
||||
"uvloop ; sys_platform != 'win32'",
|
||||
"rfc3986",
|
||||
"python-multipart",
|
||||
"chardet",
|
||||
"apispec>=1.0.0b1",
|
||||
"marshmallow",
|
||||
"asgiref",
|
||||
"docopt",
|
||||
"itsdangerous",
|
||||
"requests-toolbelt",
|
||||
]
|
||||
|
||||
|
||||
@@ -111,9 +120,7 @@ setup(
|
||||
author_email="me@kennethreitz.org",
|
||||
url="https://github.com/kennethreitz/responder",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
# entry_points={
|
||||
# "console_scripts": ["pipenv=pipenv:cli", "pipenv-resolver=pipenv.resolver:main"]
|
||||
# },
|
||||
entry_points={"console_scripts": ["responder=responder.cli:cli"]},
|
||||
package_data={
|
||||
# "": ["LICENSE", "NOTICES"],
|
||||
# "pipenv.vendor.requests": ["*.pem"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
lorem
|
||||
+1
-94
@@ -1,96 +1,3 @@
|
||||
this is a test
|
||||
|
||||
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Maiores in, ea beatae praesentium quis enim exercitationem
|
||||
voluptate repellat possimus laborum provident voluptates numquam id atque tempora. Quidem et repudiandae aliquam?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero reiciendis consequuntur deserunt iure nesciunt autem
|
||||
saepe magnam quas, debitis aliquam molestias possimus necessitatibus cumque enim modi fuga tenetur hic natus?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia magni maxime, optio aliquid tempore dignissimos aperiam
|
||||
voluptatibus, quae sunt vel iste nesciunt. Commodi saepe ipsam architecto omnis neque sequi beatae.
|
||||
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Neque quam sequi quidem corporis repudiandae quo, fugiat
|
||||
ullam inventore, ratione cupiditate maiores nobis autem asperiores earum dolorum praesentium quod consequuntur nostrum!
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, in? Officiis ratione veritatis distinctio quas illo
|
||||
voluptatibus quia velit corrupti. Tempora ipsam perspiciatis ullam sapiente itaque esse doloribus error culpa.this is a
|
||||
test
|
||||
|
||||
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Maiores in, ea beatae praesentium quis enim exercitationem
|
||||
voluptate repellat possimus laborum provident voluptates numquam id atque tempora. Quidem et repudiandae aliquam?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero reiciendis consequuntur deserunt iure nesciunt autem
|
||||
saepe magnam quas, debitis aliquam molestias possimus necessitatibus cumque enim modi fuga tenetur hic natus?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia magni maxime, optio aliquid tempore dignissimos aperiam
|
||||
voluptatibus, quae sunt vel iste nesciunt. Commodi saepe ipsam architecto omnis neque sequi beatae.
|
||||
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Neque quam sequi quidem corporis repudiandae quo, fugiat
|
||||
ullam inventore, ratione cupiditate maiores nobis autem asperiores earum dolorum praesentium quod consequuntur nostrum!
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, in? Officiis ratione veritatis distinctio quas illo
|
||||
voluptatibus quia velit corrupti. Tempora ipsam perspiciatis ullam sapiente itaque esse doloribus error culpa.this is a
|
||||
test
|
||||
|
||||
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Maiores in, ea beatae praesentium quis enim exercitationem
|
||||
voluptate repellat possimus laborum provident voluptates numquam id atque tempora. Quidem et repudiandae aliquam?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero reiciendis consequuntur deserunt iure nesciunt autem
|
||||
saepe magnam quas, debitis aliquam molestias possimus necessitatibus cumque enim modi fuga tenetur hic natus?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia magni maxime, optio aliquid tempore dignissimos aperiam
|
||||
voluptatibus, quae sunt vel iste nesciunt. Commodi saepe ipsam architecto omnis neque sequi beatae.
|
||||
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Neque quam sequi quidem corporis repudiandae quo, fugiat
|
||||
ullam inventore, ratione cupiditate maiores nobis autem asperiores earum dolorum praesentium quod consequuntur nostrum!
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, in? Officiis ratione veritatis distinctio quas illo
|
||||
voluptatibus quia velit corrupti. Tempora ipsam perspiciatis ullam sapiente itaque esse doloribus error culpa.this is a
|
||||
test
|
||||
|
||||
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Maiores in, ea beatae praesentium quis enim exercitationem
|
||||
voluptate repellat possimus laborum provident voluptates numquam id atque tempora. Quidem et repudiandae aliquam?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero reiciendis consequuntur deserunt iure nesciunt autem
|
||||
saepe magnam quas, debitis aliquam molestias possimus necessitatibus cumque enim modi fuga tenetur hic natus?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia magni maxime, optio aliquid tempore dignissimos aperiam
|
||||
voluptatibus, quae sunt vel iste nesciunt. Commodi saepe ipsam architecto omnis neque sequi beatae.
|
||||
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Neque quam sequi quidem corporis repudiandae quo, fugiat
|
||||
ullam inventore, ratione cupiditate maiores nobis autem asperiores earum dolorum praesentium quod consequuntur nostrum!
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, in? Officiis ratione veritatis distinctio quas illo
|
||||
voluptatibus quia velit corrupti. Tempora ipsam perspiciatis ullam sapiente itaque esse doloribus error culpa.this is a
|
||||
test
|
||||
|
||||
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Maiores in, ea beatae praesentium quis enim exercitationem
|
||||
voluptate repellat possimus laborum provident voluptates numquam id atque tempora. Quidem et repudiandae aliquam?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero reiciendis consequuntur deserunt iure nesciunt autem
|
||||
saepe magnam quas, debitis aliquam molestias possimus necessitatibus cumque enim modi fuga tenetur hic natus?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia magni maxime, optio aliquid tempore dignissimos aperiam
|
||||
voluptatibus, quae sunt vel iste nesciunt. Commodi saepe ipsam architecto omnis neque sequi beatae.
|
||||
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Neque quam sequi quidem corporis repudiandae quo, fugiat
|
||||
ullam inventore, ratione cupiditate maiores nobis autem asperiores earum dolorum praesentium quod consequuntur nostrum!
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, in? Officiis ratione veritatis distinctio quas illo
|
||||
voluptatibus quia velit corrupti. Tempora ipsam perspiciatis ullam sapiente itaque esse doloribus error culpa.this is a
|
||||
test
|
||||
|
||||
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Maiores in, ea beatae praesentium quis enim exercitationem
|
||||
voluptate repellat possimus laborum provident voluptates numquam id atque tempora. Quidem et repudiandae aliquam?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero reiciendis consequuntur deserunt iure nesciunt autem
|
||||
saepe magnam quas, debitis aliquam molestias possimus necessitatibus cumque enim modi fuga tenetur hic natus?
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia magni maxime, optio aliquid tempore dignissimos aperiam
|
||||
voluptatibus, quae sunt vel iste nesciunt. Commodi saepe ipsam architecto omnis neque sequi beatae.
|
||||
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Neque quam sequi quidem corporis repudiandae quo, fugiat
|
||||
ullam inventore, ratione cupiditate maiores nobis autem asperiores earum dolorum praesentium quod consequuntur nostrum!
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, in? Officiis ratione veritatis distinctio quas illo
|
||||
voluptatibus quia velit corrupti. Tempora ipsam perspiciatis ullam sapiente itaque esse doloribus error culpa.
|
||||
{{ api.static_url('test') }}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import graphene
|
||||
import pytest
|
||||
import responder
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return responder.API()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask():
|
||||
import flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return "Hello World!"
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema():
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
|
||||
return graphene.Schema(query=Query)
|
||||
|
||||
|
||||
def test_api_basic_route(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_api_basic_route_overlap(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
@api.route("/")
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_alternative(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
api.add_route("/", home2)
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_allowed(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
api.add_route("/", home2, check_existing=False)
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_allowed_alternative(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
@api.route("/", check_existing=False)
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_class_based_view_registration(api):
|
||||
@api.route("/")
|
||||
class ThingsResource:
|
||||
def on_request(req, resp):
|
||||
resp.text = "42"
|
||||
|
||||
|
||||
def test_requests_session(api):
|
||||
assert api.session()
|
||||
|
||||
|
||||
def test_requests_session_works(api):
|
||||
TEXT = "spiral out"
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = TEXT
|
||||
|
||||
assert api.session().get("http://;/").text == TEXT
|
||||
|
||||
|
||||
def test_status_code(api):
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "keep going"
|
||||
resp.status_code = responder.status_codes.HTTP_416
|
||||
|
||||
assert api.session().get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
|
||||
|
||||
def test_json_media(api):
|
||||
dump = {"life": 42}
|
||||
|
||||
@api.route("/")
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/")
|
||||
|
||||
assert "json" in r.headers["Content-Type"]
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_media(api):
|
||||
dump = {"life": 42}
|
||||
|
||||
@api.route("/")
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/", headers={"Accept": "yaml"})
|
||||
|
||||
assert "yaml" in r.headers["Content-Type"]
|
||||
assert yaml.load(r.content) == dump
|
||||
|
||||
|
||||
def test_graphql_schema_query_querying(api, schema):
|
||||
api.add_route("/", schema)
|
||||
|
||||
r = api.session().get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
assert r.json() == {"data": {"hello": None}}
|
||||
|
||||
|
||||
def test_argumented_routing(api):
|
||||
@api.route("/{name}")
|
||||
def hello(req, resp, *, name):
|
||||
resp.text = f"Hello, {name}."
|
||||
|
||||
r = api.session().get("http://;/sean")
|
||||
assert r.text == "Hello, sean."
|
||||
|
||||
|
||||
def test_mote_argumented_routing(api):
|
||||
@api.route("/{greeting}/{name}")
|
||||
def hello(req, resp, *, greeting, name):
|
||||
resp.text = f"{greeting}, {name}."
|
||||
|
||||
r = api.session().get("http://;/hello/lyndsy")
|
||||
assert r.text == "hello, lyndsy."
|
||||
|
||||
|
||||
def test_request_and_get(api):
|
||||
@api.route("/")
|
||||
class ThingsResource:
|
||||
def on_request(self, req, resp):
|
||||
resp.headers.update({"DEATH": "666"})
|
||||
|
||||
def on_get(self, request, resp):
|
||||
resp.headers.update({"LIFE": "42"})
|
||||
|
||||
r = api.session().get("http://;/")
|
||||
assert "DEATH" in r.headers
|
||||
assert "LIFE" in r.headers
|
||||
|
||||
|
||||
def test_query_params(api):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = {"params": req.params}
|
||||
|
||||
r = api.session().get("http://;/?q=q")
|
||||
assert r.json()["params"] == {"q": "q"}
|
||||
|
||||
|
||||
def test_form_data(api):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = {"form": req.media("form")}
|
||||
|
||||
dump = {"q": "q"}
|
||||
r = api.session().get("http://;/", data=dump)
|
||||
assert r.json()["form"] == dump
|
||||
@@ -0,0 +1,56 @@
|
||||
import graphene
|
||||
import responder
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(current_dir):
|
||||
yield current_dir / "data"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def current_dir():
|
||||
yield Path(__file__).parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return responder.API()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(api):
|
||||
return api.session()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def url():
|
||||
def url_for(s):
|
||||
return f"http://;{s}"
|
||||
|
||||
return url_for
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask():
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return "Hello World!"
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema():
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
return graphene.Schema(query=Query)
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
|
||||
|
||||
def test_custom_encoding(api, session):
|
||||
data = "hi alex!"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
req.encoding = "ascii"
|
||||
resp.text = await req.text
|
||||
|
||||
r = session.get(api.url_for(route), data=data)
|
||||
assert r.text == data
|
||||
|
||||
|
||||
def test_bytes_encoding(api, session):
|
||||
data = b"hi lenny!"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.text = (await req.content).decode("utf-8")
|
||||
|
||||
r = session.get(api.url_for(route), data=data)
|
||||
assert r.content == data
|
||||
@@ -0,0 +1,60 @@
|
||||
import inspect
|
||||
import pytest
|
||||
|
||||
from responder import models
|
||||
|
||||
|
||||
_default_query = "q=%7b%20hello%20%7d&name=myname&user_name=test_user"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query, expected",
|
||||
[
|
||||
pytest.param(
|
||||
_default_query,
|
||||
{"q": ["{ hello }"], "name": ["myname"], "user_name": ["test_user"]},
|
||||
id="parse query with unique keys",
|
||||
),
|
||||
pytest.param(
|
||||
"q=1&q=2&q=3", {"q": ["1", "2", "3"]}, id="parse query with the same key"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_query_dict(query, expected):
|
||||
d = models.QueryDict(query)
|
||||
assert d == expected
|
||||
|
||||
|
||||
def test_query_dict_get():
|
||||
d = models.QueryDict(_default_query)
|
||||
|
||||
assert d["user_name"] == "test_user"
|
||||
assert d.get("key_none_exist") is None
|
||||
|
||||
|
||||
def test_query_dict_get_list():
|
||||
d = models.QueryDict(_default_query)
|
||||
|
||||
assert d.get_list("user_name") == ["test_user"]
|
||||
assert d.get_list("key_none_exist") == []
|
||||
assert d.get_list("key_none_exist", ["foo"]) == ["foo"]
|
||||
|
||||
|
||||
def test_query_dict_items_list():
|
||||
d = models.QueryDict(_default_query)
|
||||
|
||||
items_list = d.items_list()
|
||||
assert inspect.isgenerator(items_list)
|
||||
assert dict(items_list) == {
|
||||
"q": ["{ hello }"],
|
||||
"name": ["myname"],
|
||||
"user_name": ["test_user"],
|
||||
}
|
||||
|
||||
|
||||
def test_query_dict_items():
|
||||
d = models.QueryDict(_default_query)
|
||||
|
||||
items = d.items()
|
||||
assert inspect.isgenerator(items)
|
||||
assert dict(items) == {"q": "{ hello }", "name": "myname", "user_name": "test_user"}
|
||||
@@ -0,0 +1,435 @@
|
||||
import pytest
|
||||
import yaml
|
||||
import responder
|
||||
import io
|
||||
|
||||
|
||||
def test_api_basic_route(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_api_basic_route_overlap(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
@api.route("/")
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_alternative(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
api.add_route("/", home2)
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_allowed(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
api.add_route("/", home2, check_existing=False)
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_allowed_alternative(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
@api.route("/", check_existing=False)
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_class_based_view_registration(api):
|
||||
@api.route("/")
|
||||
class ThingsResource:
|
||||
def on_request(req, resp):
|
||||
resp.text = "42"
|
||||
|
||||
|
||||
def test_class_based_view_parameters(api):
|
||||
@api.route("/{greeting}")
|
||||
class Greeting:
|
||||
def on_request(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
assert api.session().get("http://;/Hello").ok
|
||||
|
||||
|
||||
def test_requests_session(api):
|
||||
assert api.session()
|
||||
|
||||
|
||||
def test_requests_session_works(api, session, url):
|
||||
TEXT = "spiral out"
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = TEXT
|
||||
|
||||
assert session.get(url("/")).text == TEXT
|
||||
|
||||
|
||||
def test_status_code(api):
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "keep going"
|
||||
resp.status_code = responder.status_codes.HTTP_416
|
||||
|
||||
assert api.session().get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
|
||||
|
||||
def test_json_media(api):
|
||||
dump = {"life": 42}
|
||||
|
||||
@api.route("/")
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/")
|
||||
|
||||
assert "json" in r.headers["Content-Type"]
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_media(api):
|
||||
dump = {"life": 42}
|
||||
|
||||
@api.route("/")
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/", headers={"Accept": "yaml"})
|
||||
|
||||
assert "yaml" in r.headers["Content-Type"]
|
||||
assert yaml.load(r.content) == dump
|
||||
|
||||
|
||||
def test_graphql_schema_query_querying(api, schema):
|
||||
api.add_route("/", schema)
|
||||
|
||||
r = api.session().get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
assert r.json() == {"data": {"hello": "Hello stranger"}}
|
||||
|
||||
|
||||
def test_argumented_routing(api, session):
|
||||
@api.route("/{name}")
|
||||
def hello(req, resp, *, name):
|
||||
resp.text = f"Hello, {name}."
|
||||
|
||||
r = session.get(api.url_for(hello, name="sean"))
|
||||
assert r.text == "Hello, sean."
|
||||
|
||||
|
||||
def test_mote_argumented_routing(api, session):
|
||||
@api.route("/{greeting}/{name}")
|
||||
def hello(req, resp, *, greeting, name):
|
||||
resp.text = f"{greeting}, {name}."
|
||||
|
||||
r = session.get(api.url_for(hello, greeting="hello", name="lyndsy"))
|
||||
assert r.text == "hello, lyndsy."
|
||||
|
||||
|
||||
def test_request_and_get(api, session):
|
||||
@api.route("/")
|
||||
class ThingsResource:
|
||||
def on_request(self, req, resp):
|
||||
resp.headers.update({"DEATH": "666"})
|
||||
|
||||
def on_get(self, request, resp):
|
||||
resp.headers.update({"LIFE": "42"})
|
||||
|
||||
r = session.get(api.url_for(ThingsResource))
|
||||
assert "DEATH" in r.headers
|
||||
assert "LIFE" in r.headers
|
||||
|
||||
|
||||
def test_class_based_view_status_code(api):
|
||||
@api.route("/")
|
||||
class ThingsResource:
|
||||
def on_request(self, req, resp):
|
||||
resp.status_code = responder.status_codes.HTTP_416
|
||||
|
||||
assert api.session().get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
|
||||
|
||||
def test_query_params(api, url, session):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = {"params": req.params}
|
||||
|
||||
r = session.get(api.url_for(route), params={"q": "q"})
|
||||
assert r.json()["params"] == {"q": "q"}
|
||||
|
||||
r = session.get(url("/?q=1&q=2&q=3"))
|
||||
assert r.json()["params"] == {"q": "3"}
|
||||
|
||||
|
||||
# Requires https://github.com/encode/starlette/pull/102
|
||||
def test_form_data(api, session):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = {"form": await req.media("form")}
|
||||
|
||||
dump = {"q": "q"}
|
||||
r = session.get(api.url_for(route), data=dump)
|
||||
assert r.json()["form"] == dump
|
||||
|
||||
|
||||
def test_async_function(api, session):
|
||||
content = "The Emerald Tablet of Hermes"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.text = content
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
assert r.text == content
|
||||
|
||||
|
||||
def test_media_parsing(api, session):
|
||||
dump = {"hello": "sam"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
assert r.json() == dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Accept": "application/x-yaml"})
|
||||
assert r.text == "{hello: sam}\n"
|
||||
|
||||
|
||||
def test_background(api, session):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
@api.background.task
|
||||
def task():
|
||||
import time
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
task()
|
||||
api.text = "ok"
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_multiple_routes(api, session):
|
||||
@api.route("/1")
|
||||
def route1(req, resp):
|
||||
resp.text = "1"
|
||||
|
||||
@api.route("/2")
|
||||
def route2(req, resp):
|
||||
resp.text = "2"
|
||||
|
||||
r = session.get(api.url_for(route1))
|
||||
assert r.text == "1"
|
||||
|
||||
r = session.get(api.url_for(route2))
|
||||
assert r.text == "2"
|
||||
|
||||
|
||||
def test_graphql_schema_json_query(api, schema):
|
||||
api.add_route("/", schema)
|
||||
|
||||
r = api.session().post("http://;/", json={"query": "{ hello }"})
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_graphiql(api, schema):
|
||||
api.add_route("/", schema)
|
||||
|
||||
r = api.session().get("http://;/", headers={"Accept": "text/html"})
|
||||
assert r.ok
|
||||
assert "GraphiQL" in r.text
|
||||
|
||||
|
||||
def test_json_uploads(api, session):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(api.url_for(route), json=dump)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_uploads(api, session):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(
|
||||
api.url_for(route),
|
||||
data=yaml.dump(dump),
|
||||
headers={"Content-Type": "application/x-yaml"},
|
||||
)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_form_uploads(api, session):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(api.url_for(route), data=dump)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_json_downloads(api, session):
|
||||
dump = {"testing": "123"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Content-Type": "application/json"})
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_downloads(api, session):
|
||||
dump = {"testing": "123"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Content-Type": "application/x-yaml"})
|
||||
assert yaml.safe_load(r.content) == dump
|
||||
|
||||
|
||||
def test_schema_generation():
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
api = responder.API(title="Web Service", openapi="3.0")
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
schema:
|
||||
$ref = "#/components/schemas/Pet"
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
r = api.session().get("http://;/schema.yml")
|
||||
dump = yaml.safe_load(r.content)
|
||||
|
||||
assert dump
|
||||
assert dump["openapi"] == "3.0"
|
||||
|
||||
|
||||
def test_mount_wsgi_app(api, flask, session):
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "hello"
|
||||
|
||||
api.mount("/flask", flask)
|
||||
|
||||
r = session.get("http://;/flask")
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_async_class_based_views(api, session):
|
||||
@api.route("/")
|
||||
class Resource:
|
||||
async def on_post(self, req, resp):
|
||||
resp.text = await req.text
|
||||
|
||||
data = "frame"
|
||||
r = session.post(api.url_for(Resource), data=data)
|
||||
assert r.text == data
|
||||
|
||||
|
||||
def test_cookies(api, session):
|
||||
@api.route("/")
|
||||
def cookies(req, resp):
|
||||
resp.media = {"cookies": req.cookies}
|
||||
resp.cookies["sent"] = "true"
|
||||
|
||||
r = session.get(api.url_for(cookies), cookies={"hello": "universe"})
|
||||
assert r.json() == {"cookies": {"hello": "universe"}}
|
||||
assert "sent" in r.cookies
|
||||
|
||||
r = session.get(api.url_for(cookies))
|
||||
assert r.json() == {"cookies": {"sent": "true"}}
|
||||
|
||||
|
||||
def test_sessions(api, session):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.session["hello"] = "world"
|
||||
resp.media = resp.session
|
||||
|
||||
r = session.get(api.url_for(view))
|
||||
assert "Responder-Session" in r.cookies
|
||||
|
||||
r = session.get(api.url_for(view))
|
||||
assert (
|
||||
r.cookies["Responder-Session"]
|
||||
== '{"hello": "world"}.lJVWJULPqR9kdao_oT4pUglV281bxHfGvcKQ7XF8qNqaiIZlRcMvqKNdA1-d5z7DycAx5eqmzJZoqWPP759-Cw'
|
||||
)
|
||||
assert r.json() == {"hello": "world"}
|
||||
|
||||
|
||||
def test_template_rendering(api, session):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.content = api.template_string("{{ var }}", var="hello")
|
||||
|
||||
r = session.get(api.url_for(view))
|
||||
assert r.text == "hello"
|
||||
|
||||
|
||||
def test_file_uploads(api, session):
|
||||
@api.route("/")
|
||||
async def upload(req, resp):
|
||||
|
||||
files = await req.media("files")
|
||||
files["hello"] = files["hello"].decode("utf-8")
|
||||
resp.media = {"files": files}
|
||||
|
||||
world = io.StringIO("world")
|
||||
data = {"hello": world}
|
||||
r = session.post(api.url_for(upload), files=data)
|
||||
assert r.json() == {"files": {"hello": "world"}}
|
||||
|
||||
|
||||
def test_500(api, session):
|
||||
@api.route("/")
|
||||
def view(rea, resp):
|
||||
raise ValueError
|
||||
|
||||
r = session.get(api.url_for(view))
|
||||
assert not r.ok
|
||||
@@ -0,0 +1,105 @@
|
||||
import pytest
|
||||
from responder import routes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route, expected",
|
||||
[
|
||||
pytest.param("/", False, id="home path without params"),
|
||||
pytest.param("/test_path", False, id="sub path without params"),
|
||||
pytest.param("/{test_path}", True, id="path with params"),
|
||||
],
|
||||
)
|
||||
def test_parameter(route, expected):
|
||||
r = routes.Route(route, "test_endpoint")
|
||||
assert r.has_parameters is expected
|
||||
|
||||
|
||||
def test_url():
|
||||
r = routes.Route("/{my_path}", "test_endpoint")
|
||||
url = r.url(my_path="path")
|
||||
assert url == "/path"
|
||||
|
||||
|
||||
def test_equal():
|
||||
r = routes.Route("/{path_param}", "test_endpoint")
|
||||
r2 = routes.Route("/{path_param}", "test_endpoint")
|
||||
r3 = routes.Route("/test_path", "test_endpoint")
|
||||
|
||||
assert r == r2
|
||||
assert r != r3
|
||||
|
||||
|
||||
def test_incoming_matches():
|
||||
# Test Route with one param
|
||||
r = routes.Route("/{greetings}", "test_endpoint")
|
||||
assert r.incoming_matches("/hello") == {"greetings": "hello"}
|
||||
assert r.incoming_matches("/foo") == {"greetings": "foo"}
|
||||
|
||||
assert r._memo == {
|
||||
"incoming_matches:/hello": {"greetings": "hello"},
|
||||
"incoming_matches:/foo": {"greetings": "foo"},
|
||||
}
|
||||
|
||||
# Test Route with two params
|
||||
r = routes.Route("/{greetings}/{name}", "test_endpoint")
|
||||
assert r.incoming_matches("/hi/john") == {"greetings": "hi", "name": "john"}
|
||||
assert r.incoming_matches("/hello/jane") == {"greetings": "hello", "name": "jane"}
|
||||
|
||||
# Test Route with no param
|
||||
assert r._memo == {
|
||||
"incoming_matches:/hi/john": {"greetings": "hi", "name": "john"},
|
||||
"incoming_matches:/hello/jane": {"greetings": "hello", "name": "jane"},
|
||||
}
|
||||
|
||||
r = routes.Route("/hello", "test_endpoint")
|
||||
assert r.incoming_matches("/hello") == {}
|
||||
assert r.incoming_matches("/bye") == {}
|
||||
assert r._memo == {"incoming_matches:/hello": {}, "incoming_matches:/bye": {}}
|
||||
|
||||
|
||||
def test_incoming_matches_with_concrete_path_no_match():
|
||||
r = routes.Route("/concrete_path", "test_endpoint")
|
||||
assert r.incoming_matches("hello") == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route, match, expected",
|
||||
[
|
||||
pytest.param(
|
||||
"/{path_param}",
|
||||
"/{path_param}",
|
||||
True,
|
||||
id="with both parametrized path match",
|
||||
),
|
||||
pytest.param(
|
||||
"/concrete", "/concrete", True, id="with both concrete path match"
|
||||
),
|
||||
pytest.param("/concrete", "/no_match", False, id="with no match"),
|
||||
],
|
||||
)
|
||||
def test_does_match_with_route(route, match, expected):
|
||||
r = routes.Route(route, "test_endpoint")
|
||||
assert r.does_match(match) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path_param, expected_weight",
|
||||
[
|
||||
pytest.param("/{greetings}", (True, -1), id="with one param"),
|
||||
pytest.param(
|
||||
"/{greetings}.{name}", (True, -2), id="with 2 params and dot in the middle"
|
||||
),
|
||||
pytest.param("/{greetings}/{name}", (True, -2), id="with 2 param and subpath"),
|
||||
pytest.param(
|
||||
"/{greetings}/{name}/{hello}", (True, -3), id="with 3 param and subpath"
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}_{name}", (True, -2), id="with 2 param and underscore"
|
||||
),
|
||||
pytest.param("/hello", (False, 0), id="with 2 param and underscore"),
|
||||
],
|
||||
)
|
||||
def test_weight(path_param, expected_weight):
|
||||
r = routes.Route(path_param, "test_endpoint")
|
||||
assert r._weight() == expected_weight
|
||||
@@ -0,0 +1,67 @@
|
||||
import pytest
|
||||
from responder import status_codes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(101, True, id="Normal 101"),
|
||||
pytest.param(199, True, id="Not actual status code but within 100"),
|
||||
pytest.param(0, False, id="Zero case (below 100)"),
|
||||
pytest.param(200, False, id="Above 100")
|
||||
],
|
||||
)
|
||||
def test_is_100(status_code, expected):
|
||||
assert status_codes.is_100(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(201, True, id="Normal 201"),
|
||||
pytest.param(299, True, id="Not actual status code but within 200"),
|
||||
pytest.param(0, False, id="Zero case (below 200)"),
|
||||
pytest.param(300, False, id="Above 200")
|
||||
],
|
||||
)
|
||||
def test_is_200(status_code, expected):
|
||||
assert status_codes.is_200(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(301, True, id="Normal 301"),
|
||||
pytest.param(399, True, id="Not actual status code but within 300"),
|
||||
pytest.param(0, False, id="Zero case (below 300)"),
|
||||
pytest.param(400, False, id="Above 300")
|
||||
],
|
||||
)
|
||||
def test_is_300(status_code, expected):
|
||||
assert status_codes.is_300(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(401, True, id="Normal 401"),
|
||||
pytest.param(499, True, id="Not actual status code but within 400"),
|
||||
pytest.param(0, False, id="Zero case (below 400)"),
|
||||
pytest.param(500, False, id="Above 400")
|
||||
],
|
||||
)
|
||||
def test_is_400(status_code, expected):
|
||||
assert status_codes.is_400(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(501, True, id="Normal 401"),
|
||||
pytest.param(599, True, id="Not actual status code but within 400"),
|
||||
pytest.param(0, False, id="Zero case (below 400)"),
|
||||
pytest.param(600, False, id="Above 500")
|
||||
],
|
||||
)
|
||||
def test_is_500(status_code, expected):
|
||||
assert status_codes.is_500(status_code) is expected
|
||||
Reference in New Issue
Block a user