diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4246a4f..f8abe67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,20 @@
+# v0.3.3
+- Improved exceptions.
+- CORS support.
+
+# v0.3.2
+- Subtle improvements.
+
+# v0.3.1
+- Packaging fix.
+
+# v0.3.0
+- Interactive Documentation endpoint.
+- Minor improvements.
+
+# v0.2.3
+- Overall improvements.
+
# v0.2.2
- Show traceback info when background tasks raise exceptions.
diff --git a/Pipfile.lock b/Pipfile.lock
index 1573d49..2c5d527 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -37,6 +37,12 @@
],
"version": "==1.0.0b3"
},
+ "apistar": {
+ "hashes": [
+ "sha256:4338b24468b49526ceac4a8f84046056081ee747f373ca8d0647bd6b2344c895"
+ ],
+ "version": "==0.6.0"
+ },
"asgiref": {
"hashes": [
"sha256:9b05dcd41a6a89ca8c6e7f7e4089c3f3e76b5af60aebb81ae6d455ad81989c97",
@@ -215,9 +221,9 @@
},
"starlette": {
"hashes": [
- "sha256:ce5c684fad4edb2967cd491518cd3c2724e420508202c2d48f519ea68dcec9d6"
+ "sha256:eac0f6cab6b48846a0c1af16615430ae0e7a95f669ee0841a7e2f242d51d8935"
],
- "version": "==0.5.4"
+ "version": "==0.5.5"
},
"urllib3": {
"hashes": [
@@ -228,9 +234,9 @@
},
"uvicorn": {
"hashes": [
- "sha256:7c4550c7e6f7c8727fa5ccd5200baf62c9e055895e058933ee88f5d0c246ca0c"
+ "sha256:e2b742fdaa0b52f4aac92fd2c078e7f1f17d11322bb3efb09d341d5c6998b4b5"
],
- "version": "==0.3.14"
+ "version": "==0.3.16"
},
"websockets": {
"hashes": [
@@ -257,6 +263,13 @@
"sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"
],
"version": "==6.0"
+ },
+ "whitenoise": {
+ "hashes": [
+ "sha256:133a92ff0ab8fb9509f77d4f7d0de493eca19c6fea973f4195d4184f888f2e02",
+ "sha256:32b57d193478908a48acb66bf73e7a3c18679263e3e64bfebcfac1144a430039"
+ ],
+ "version": "==4.1"
}
},
"develop": {
@@ -317,43 +330,6 @@
],
"version": "==2018.10.15"
},
- "cffi": {
- "hashes": [
- "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
- "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
- "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
- "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
- "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30",
- "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
- "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
- "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b",
- "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
- "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e",
- "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
- "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
- "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
- "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
- "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
- "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
- "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
- "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
- "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5",
- "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
- "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
- "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
- "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
- "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
- "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2",
- "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
- "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
- "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
- "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
- "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
- "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
- "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
- ],
- "version": "==1.11.5"
- },
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
@@ -368,39 +344,6 @@
],
"version": "==7.0"
},
- "cmarkgfm": {
- "hashes": [
- "sha256:0186dccca79483e3405217993b83b914ba4559fe9a8396efc4eea56561b74061",
- "sha256:1a625afc6f62da428df96ec325dc30866cc5781520cbd904ff4ec44cf018171c",
- "sha256:207b7673ff4e177374c572feeae0e4ef33be620ec9171c08fd22e2b796e03e3d",
- "sha256:275905bb371a99285c74931700db3f0c078e7603bed383e8cf1a09f3ee05a3de",
- "sha256:50098f1c4950722521f0671e54139e0edc1837d63c990cf0f3d2c49607bb51a2",
- "sha256:50ed116d0b60a07df0dc7b180c28569064b9d37d1578d4c9021cff04d725cb63",
- "sha256:61a72def110eed903cd1848245897bcb80d295cd9d13944d4f9f30cba5b76655",
- "sha256:64186fb75d973a06df0e6ea12879533b71f6e7ba1ab01ffee7fc3e7534758889",
- "sha256:665303d34d7f14f10d7b0651082f25ebf7107f29ef3d699490cac16cdc0fc8ce",
- "sha256:70b18f843aec58e4e64aadce48a897fe7c50426718b7753aaee399e72df64190",
- "sha256:761ee7b04d1caee2931344ac6bfebf37102ffb203b136b676b0a71a3f0ea3c87",
- "sha256:811527e9b7280b136734ed6cb6845e5fbccaeaa132ddf45f0246cbe544016957",
- "sha256:987b0e157f70c72a84f3c2f9ef2d7ab0f26c08f2bf326c12c087ff9eebcb3ff5",
- "sha256:9fc6a2183d0a9b0974ec7cdcdad42bd78a3be674cc3e65f87dd694419b3b0ab7",
- "sha256:a3d17ee4ae739fe16f7501a52255c2e287ac817cfd88565b9859f70520afffea",
- "sha256:ba5b5488719c0f2ced0aa1986376f7baff1a1653a8eb5fdfcf3f84c7ce46ef8d",
- "sha256:c573ea89dd95d41b6d8cf36799c34b6d5b1eac4aed0212dee0f0a11fb7b01e8f",
- "sha256:c5f1b9e8592d2c448c44e6bc0d91224b16ea5f8293908b1561de1f6d2d0658b1",
- "sha256:cbe581456357d8f0674d6a590b1aaf46c11d01dd0a23af147a51a798c3818034",
- "sha256:cf219bec69e601fe27e3974b7307d2f06082ab385d42752738ad2eb630a47d65",
- "sha256:cf5014eb214d814a83a7a47407272d5db10b719dbeaf4d3cfe5969309d0fcf4b",
- "sha256:d08bad67fa18f7e8ff738c090628ee0cbf0505d74a991c848d6d04abfe67b697",
- "sha256:d6f716d7b1182bf35862b5065112f933f43dd1aa4f8097c9bcfb246f71528a34",
- "sha256:e08e479102627641c7cb4ece421c6ed4124820b1758765db32201136762282d9",
- "sha256:e20ac21418af0298437d29599f7851915497ce9f2866bc8e86b084d8911ee061",
- "sha256:e25f53c37e319241b9a412382140dffac98ca756ba8f360ac7ab5e30cad9670a",
- "sha256:e8932bddf159064f04e946fbb64693753488de21586f20e840b3be51745c8c09",
- "sha256:f20900f16377f2109783ae9348d34bc80530808439591c3d3df73d5c7ef1a00c"
- ],
- "version": "==0.4.2"
- },
"colorama": {
"hashes": [
"sha256:a3d89af5db9e9806a779a50296b5fdb466e281147c2c235e8225ecc6dbf7bbf3",
@@ -451,11 +394,11 @@
},
"flake8": {
"hashes": [
- "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
- "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
+ "sha256:6a35f5b8761f45c5513e3405f110a86bea57982c3b75b766ce7b65217abe1670",
+ "sha256:c01f8a3963b3571a8e6bd7a4063359aff90749e160778e03817cd9b71c9e07d2"
],
"index": "pypi",
- "version": "==3.5.0"
+ "version": "==3.6.0"
},
"flask": {
"hashes": [
@@ -557,23 +500,17 @@
},
"pycodestyle": {
"hashes": [
- "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
- "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
+ "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83",
+ "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a"
],
- "version": "==2.3.1"
- },
- "pycparser": {
- "hashes": [
- "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
- ],
- "version": "==2.19"
+ "version": "==2.4.0"
},
"pyflakes": {
"hashes": [
- "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
- "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
+ "sha256:9a7662ec724d0120012f6e29d6248ae3727d821bba522a0e6b356eff19126a49",
+ "sha256:f661252913bc1dbe7fcfcbf0af0db3f42ab65aabd1a6ca68fe5d466bace94dae"
],
- "version": "==1.6.0"
+ "version": "==2.0.0"
},
"pygments": {
"hashes": [
@@ -591,11 +528,11 @@
},
"pytest": {
"hashes": [
- "sha256:10e59f84267370ab20cec9305bafe7505ba4d6b93ecbf66a1cce86193ed511d5",
- "sha256:8c827e7d4816dfe13e9329c8226aef8e6e75d65b939bc74fda894143b6d1df59"
+ "sha256:212be78a6fa5352c392738a49b18f74ae9aeec1040f47c81cadbfd8d1233c310",
+ "sha256:6f6c1efc8d0ccc21f8f6c34d8330baca883cf109b66b3df954b0a117e5528fb4"
],
"index": "pypi",
- "version": "==3.9.1"
+ "version": "==3.9.2"
},
"pytest-cov": {
"hashes": [
@@ -607,17 +544,17 @@
},
"pytz": {
"hashes": [
- "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
- "sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
+ "sha256:642253af8eae734d1509fc6ac9c1aee5e5b69d76392660889979b9870610a46b",
+ "sha256:91e3ccf2c344ffaa6defba1ce7f38f97026943f675b7703f44789768e4cb0ece"
],
- "version": "==2018.5"
+ "version": "==2018.6"
},
"readme-renderer": {
"hashes": [
- "sha256:237ca8705ffea849870de41101dba41543561da05c0ae45b2f1c547efa9843d2",
- "sha256:f75049a3a7afa57165551e030dd8f9882ebf688b9600535a3f7e23596651875d"
+ "sha256:219a02f5359b6631f5ab952f6906c6c105bdd8bc4bf19c1ec5ee8bd9ea2dc1eb",
+ "sha256:f8f122ad9fd6d138337531379575a01a0b6ca70aedca78f094cb833da38c8c0c"
],
- "version": "==22.0"
+ "version": "==23.0"
},
"requests": {
"hashes": [
@@ -671,10 +608,10 @@
},
"tqdm": {
"hashes": [
- "sha256:a0be569511161220ff709a5b60d0890d47921f746f1c737a11d965e1b29e7b2e",
- "sha256:e293e6d7a7f41a529a27f8d6624ab11544ccbfe82a205af6fad102545099fc21"
+ "sha256:3c4d4a5a41ef162dd61f1edb86b0e1c7859054ab656b2e7c7b77e7fbf6d9f392",
+ "sha256:5b4d5549984503050883bc126280b386f5f4ca87e6c023c5d015655ad75bdebb"
],
- "version": "==4.27.0"
+ "version": "==4.28.1"
},
"twine": {
"hashes": [
diff --git a/README.md b/README.md
index e1525b4..a9b3c75 100644
--- a/README.md
+++ b/README.md
@@ -41,81 +41,7 @@ This gets you a ASGI app, with a production static files server pre-installed, j
## More Examples
-Class-based views (and setting some headers and stuff):
-
-```python
-@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
-```
-
-Render a template, with arguments:
-
-```python
-@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.
-
-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
-
-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))
-```
-
-We can then send a query to our service:
-
-```pycon
->>> requests = api.session()
->>> r = requests.get("http://;/graph", params={"query": "{ hello }"})
->>> r.json()
-{'data': {'hello': 'Hello stranger'}}
-```
-
-Or, request YAML back:
-
-```pycon
->>> 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.
+See [the documentation's feature tour](http://python-responder.org/en/latest/tour.html) for more details on features available in Responder.
# Installing Responder
@@ -154,15 +80,7 @@ The primary concept here is to bring the niceties that are brought forth from bo
- 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.
-
-## Future Ideas
-
-- 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.
+- Provide an official way to run webpack.
----------
diff --git a/docs/source/deployment.rst b/docs/source/deployment.rst
index ab6a9c2..e5fbde4 100644
--- a/docs/source/deployment.rst
+++ b/docs/source/deployment.rst
@@ -30,7 +30,7 @@ The basics::
Install Responder::
- $ pipenv install responder
+ $ pipenv install responder --pre
...
Write out an ``api.py``::
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 18c03fc..9a5390b 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -39,7 +39,7 @@ spread some `Hacktoberfest `_ spirit ar
That ``async`` declaration is optional.
-This gets you a ASGI app, with a production static files server
+This gets you a ASGI app, with a production static files server (WhiteNoise)
pre-installed, jinja2 templating (without additional imports), and a
production webserver based on uvloop, serving up requests with gzip
compression automatically.
@@ -55,7 +55,7 @@ Features
- 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.
+- OpenAPI schema generation, with interactive documentation!
- Single-page webapp support!
Testimonials
@@ -144,14 +144,6 @@ Ideas
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
-Future Ideas
-------------
-
-- 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.
-
-
-
Indices and tables
==================
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
index fc12ab7..83768a7 100644
--- a/docs/source/quickstart.rst
+++ b/docs/source/quickstart.rst
@@ -34,7 +34,7 @@ Next, we can run our web service easily, with ``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.
+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 and will set the listening address to ``0.0.0.0`` automatically (also configurable through the ``address`` keyword argument).
Accept Route Arguments
diff --git a/docs/source/tour.rst b/docs/source/tour.rst
index 2f94d20..1e2632b 100644
--- a/docs/source/tour.rst
+++ b/docs/source/tour.rst
@@ -9,7 +9,7 @@ Class-based views (and setting some headers and stuff)::
@api.route("/{greeting}")
class GreetingResource:
- def on_request(req, resp, *, greeting): # or on_get...
+ def on_request(self, 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
@@ -104,6 +104,15 @@ Responder comes with built-in support for OpenAPI / marshmallow::
tags: []
+Interactive Documentation
+-------------------------
+
+Responder can automatically supply API Documentation for you. Using the example above::
+
+ api = responder.API(title="Web Service", version="1.0", openapi="3.0", docs_route="/docs")
+
+This will make ``/docs`` render interactive documentation for your API.
+
Mount a WSGI App (e.g. Flask)
-----------------------------
@@ -159,7 +168,7 @@ You can easily read a Request's session data, that can be trusted to have origin
**Note**: if you are using this in production, you should pass the ``secret_key`` argument to ``API(...)``::
- api = responder.API(secret_key=os.environ['SECRET_KEY']
+ api = responder.API(secret_key=os.environ['SECRET_KEY'])
Using Requests Test Client
--------------------------
@@ -182,7 +191,7 @@ Here's an example of a test (written with pytest)::
resp.text = hello
r = api.requests.get(url=api.url_for(some_view))
- assert r.text = hello
+ assert r.text == hello
HSTS (Redirect to HTTPS)
------------------------
@@ -195,3 +204,26 @@ Want HSTS (to redirect all traffic to HTTPS)?
Boom.
+
+CORS
+----
+
+Want `CORS `_ ?
+
+::
+
+ api = responder.API(cors=True)
+
+
+The default parameters used by **Responder** are restrictive by default, so you'll need to explicitly enable particular origins, methods, or headers, in order for browsers to be permitted to use them in a Cross-Domain context.
+
+In order to set custom parameters, you need to pass the ``cors_params`` argument, a dictionnary containing the following entries :
+
+* ``allow_origins`` - A list of origins that should be permitted to make cross-origin requests. eg. ``['https://example.org', 'https://www.example.org']``. You can use ``['*']`` to allow any origin.
+* ``allow_origin_regex`` - A regex string to match against origins that should be permitted to make cross-origin requests. eg. ``'https://.*\.example\.org'``.
+* ``allow_methods`` - A list of HTTP methods that should be allowed for cross-origin requests. Defaults to `['GET']`. You can use ``['*']`` to allow all standard methods.
+* ``allow_headers`` - A list of HTTP request headers that should be supported for cross-origin requests. Defaults to ``[]``. You can use ``['*']`` to allow all headers. The ``Accept``, ``Accept-Language``, ``Content-Language`` and ``Content-Type`` headers are always allowed for CORS requests.
+* ``allow_credentials`` - Indicate that cookies should be supported for cross-origin requests. Defaults to ``False``.
+* ``expose_headers`` - Indicate any response headers that should be made accessible to the browser. Defaults to ``[]``.
+* ``max_age`` - Sets a maximum time in seconds for browsers to cache CORS responses. Defaults to ``60``.
+
diff --git a/responder/__version__.py b/responder/__version__.py
index b5fdc75..e19434e 100644
--- a/responder/__version__.py
+++ b/responder/__version__.py
@@ -1 +1 @@
-__version__ = "0.2.2"
+__version__ = "0.3.3"
diff --git a/responder/api.py b/responder/api.py
index d964863..e6f35d1 100644
--- a/responder/api.py
+++ b/responder/api.py
@@ -4,8 +4,8 @@ from functools import partial
from pathlib import Path
import uvicorn
-
-import asyncio
+import apistar
+import yaml
import jinja2
import itsdangerous
from graphql_server import encode_execution_results, json_encode, default_format_error
@@ -17,10 +17,13 @@ from starlette.staticfiles import StaticFiles
from starlette.testclient import TestClient
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
+from starlette.middleware.cors import CORSMiddleware
+from starlette.exceptions import ExceptionMiddleware
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec import yaml_utils
from asgiref.wsgi import WsgiToAsgi
+from whitenoise import WhiteNoise
from . import models
from . import status_codes
@@ -28,6 +31,10 @@ from .routes import Route
from .formats import get_formats
from .background import BackgroundQueue
from .templates import GRAPHIQL
+from .statics import (
+ DEFAULT_API_THEME, DEFAULT_SESSION_COOKIE, DEFAULT_SECRET_KEY, DEFAULT_CORS_PARAMS
+)
+
# TODO: consider moving status codes here
class API:
@@ -53,8 +60,11 @@ class API:
static_route="/static",
templates_dir="templates",
auto_escape=True,
- secret_key="NOTASECRET",
+ secret_key=DEFAULT_SECRET_KEY,
enable_hsts=False,
+ docs_route=None,
+ cors=False,
+ cors_params=DEFAULT_CORS_PARAMS
):
self.secret_key = secret_key
self.title = title
@@ -67,19 +77,34 @@ class API:
os.path.abspath(os.path.dirname(__file__) + "/templates")
)
self.routes = {}
+ self.docs_theme = DEFAULT_API_THEME
+ self.docs_route = docs_route
self.schemas = {}
- self.session_cookie = "Responder-Session"
+ self.session_cookie = DEFAULT_SESSION_COOKIE
self.hsts_enabled = enable_hsts
- self.static_files = StaticFiles(directory=str(self.static_dir))
- self.apps = {self.static_route: self.static_files}
-
- self.formats = get_formats()
-
+ self.cors = cors
+ self.cors_params = cors_params
# 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)
+ self.whitenoise = WhiteNoise(
+ application=self._default_wsgi_app, index_file=True
+ )
+ self.whitenoise.add_files(str(self.static_dir))
+
+ self.whitenoise.add_files(
+ (
+ Path(apistar.__file__).parent / "themes" / self.docs_theme / "static"
+ ).resolve()
+ )
+
+ self.apps = {}
+ self.mount(self.static_route, self.whitenoise)
+
+ self.formats = get_formats()
+
# Cached requests session.
self._session = None
self.background = BackgroundQueue()
@@ -87,15 +112,23 @@ class API:
if self.openapi_version:
self.add_route(openapi_route, self.schema_response)
+ if self.docs_route:
+ self.add_route(self.docs_route, self.docs_response)
+
self.default_endpoint = None
self.app = self.dispatch
self.add_middleware(GZipMiddleware)
if debug:
self.add_middleware(DebugMiddleware)
+
if self.hsts_enabled:
self.add_middleware(HTTPSRedirectMiddleware)
self.lifespan_handler = LifespanHandler()
+ if self.cors:
+ self.add_middleware(CORSMiddleware, **self.cors_params)
+ self.add_middleware(ExceptionMiddleware, debug=debug)
+
# Jinja enviroment
self.jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(
@@ -109,6 +142,10 @@ class API:
self.session()
) #: A Requests session that is connected to the ASGI app.
+ @staticmethod
+ def _default_wsgi_app(*args, **kwargs):
+ pass
+
@property
def _apispec(self):
spec = APISpec(
@@ -206,7 +243,6 @@ class API:
return route
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
@@ -235,13 +271,13 @@ class API:
# Create the response object.
cont = False
-
if route:
- if not route.uses_websocket:
- resp = models.Response(req=req, formats=self.formats)
- else:
+ if route.uses_websocket:
resp = WebSocket(**options)
+ else:
+ resp = models.Response(req=req, formats=self.formats)
+
params = route.incoming_matches(req.url.path)
if route.is_graphql:
@@ -259,12 +295,13 @@ class API:
cont = True
except Exception:
self.default_response(req, resp, error=True)
+ raise
- if route.is_class_based or cont:
+ elif route.is_class_based or cont:
try:
view = route.endpoint(**params)
except TypeError:
- view = route.endpoint
+ view = route.endpoint()
# Run on_request first.
try:
@@ -275,8 +312,9 @@ class API:
# If it's async, await it.
if hasattr(r, "send"):
await r
- except Exception as e:
+ except Exception:
self.default_response(req, resp, error=True)
+ raise
# Then on_get.
method = req.method
@@ -297,7 +335,6 @@ class API:
else:
resp = models.Response(req=req, formats=self.formats)
self.default_response(req, resp, notfound=True)
-
self.default_response(req, resp)
self._prepare_session(resp)
@@ -357,14 +394,8 @@ class API:
if default:
self.default_endpoint = endpoint
- try:
- if callable(endpoint):
- endpoint.is_routed = True
- except AttributeError:
- pass
-
self.routes[route] = Route(route, endpoint, websocket=websocket)
- # TODO: A better datastructer or sort it once the app is loaded
+ # TODO: A better data structure or sort it once the app is loaded
self.routes = dict(
sorted(self.routes.items(), key=lambda item: item[1]._weight())
)
@@ -373,7 +404,7 @@ class API:
if resp.status_code is None:
resp.status_code = 200
- if self.default_endpoint:
+ if self.default_endpoint and notfound:
self.default_endpoint(req, resp)
else:
if notfound:
@@ -383,8 +414,12 @@ class API:
resp.status_code = status_codes.HTTP_500
resp.text = "Application error."
+ def docs_response(self, req, resp):
+ resp.text = self.docs
+
def static_response(self, req, resp):
index = (self.static_dir / "index.html").resolve()
+ resp.content = ""
if os.path.exists(index):
with open(index, "r") as f:
resp.text = f.read()
@@ -545,6 +580,34 @@ class API:
"""Given a static asset, return its URL path."""
return f"{self.static_route}/{str(asset)}"
+ @property
+ def docs(self):
+
+ loader = jinja2.PrefixLoader(
+ {
+ self.docs_theme: jinja2.PackageLoader(
+ "apistar", os.path.join("themes", self.docs_theme, "templates")
+ )
+ }
+ )
+ env = jinja2.Environment(autoescape=True, loader=loader)
+ document = apistar.document.Document()
+ document.content = yaml.safe_load(self.openapi)
+
+ template = env.get_template("/".join([self.docs_theme, "index.html"]))
+
+ def static_url(asset):
+ return f"{self.static_route}/{asset}"
+ # return asset
+
+ return template.render(
+ document=document,
+ langs=["javascript", "python"],
+ code_style=None,
+ static_url=static_url,
+ schema_url="/schema.yml",
+ )
+
def template(self, name_, **values):
"""Renders the given `jinja2 `_ template, with provided values supplied.
diff --git a/responder/models.py b/responder/models.py
index ebce351..15af75a 100644
--- a/responder/models.py
+++ b/responder/models.py
@@ -100,8 +100,8 @@ class Request:
self._content = None
headers = CaseInsensitiveDict()
- for header, value in self._starlette.headers.items():
- headers[header] = value
+ for key, value in self._starlette.headers.items():
+ headers[key] = value
self._headers = headers
diff --git a/responder/routes.py b/responder/routes.py
index a4c99cc..2011555 100644
--- a/responder/routes.py
+++ b/responder/routes.py
@@ -15,7 +15,7 @@ def memoize(f):
class Route:
_param_pattern = re.compile(r"{([^{}]*)}")
- def __init__(self, route, endpoint, websocket=False):
+ def __init__(self, route, endpoint, *, websocket=False):
self.route = route
self.endpoint = endpoint
self.uses_websocket = websocket
@@ -73,8 +73,8 @@ class Route:
def is_class_based(self):
return hasattr(self.endpoint, "__class__")
+ @property
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))
+ return all((callable(self.endpoint), code, kwdefaults))
diff --git a/responder/statics.py b/responder/statics.py
index 662d001..ff5c461 100644
--- a/responder/statics.py
+++ b/responder/statics.py
@@ -1 +1,14 @@
DEFAULT_ENCODING = "utf-8"
+DEFAULT_API_THEME = "swaggerui"
+DEFAULT_SESSION_COOKIE = "Responder-Session"
+DEFAULT_SECRET_KEY = "NOTASECRET"
+
+DEFAULT_CORS_PARAMS = {
+ "allow_origins": (),
+ "allow_methods": ("GET",),
+ "allow_headers": (),
+ "allow_credentials": False,
+ "allow_origin_regex": None,
+ "expose_headers": (),
+ "max_age": 600,
+}
diff --git a/setup.py b/setup.py
index e92701b..3216f58 100644
--- a/setup.py
+++ b/setup.py
@@ -38,9 +38,11 @@ required = [
"apispec>=1.0.0b1",
"marshmallow",
"asgiref",
+ "whitenoise",
"docopt",
"itsdangerous",
"requests-toolbelt",
+ "apistar",
]
@@ -143,7 +145,7 @@ setup(
include_package_data=True,
license="Apache 2.0",
classifiers=[
- "License :: OSI Approved :: MIT License",
+ "License :: OSI Approved :: Apache Software License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
diff --git a/static/index.html b/static/index.html
deleted file mode 100644
index 3e9ffe0..0000000
--- a/static/index.html
+++ /dev/null
@@ -1 +0,0 @@
-lorem
diff --git a/templates/test.html b/templates/test.html
deleted file mode 100644
index ae024f8..0000000
--- a/templates/test.html
+++ /dev/null
@@ -1,3 +0,0 @@
-this is a test
-
-{{ api.static_url('test') }}
diff --git a/tests/test_responder.py b/tests/test_responder.py
index 92de409..1524dd9 100644
--- a/tests/test_responder.py
+++ b/tests/test_responder.py
@@ -3,6 +3,8 @@ import yaml
import responder
import io
+from starlette.responses import PlainTextResponse
+
def test_api_basic_route(api):
@api.route("/")
@@ -65,7 +67,7 @@ def test_class_based_view_registration(api):
def test_class_based_view_parameters(api):
@api.route("/{greeting}")
class Greeting:
- def on_request(req, resp, *, greeting):
+ def on_request(self, req, resp, *, greeting):
resp.text = f"{greeting}, world!"
assert api.session().get("http://;/Hello").ok
@@ -355,6 +357,34 @@ def test_schema_generation():
assert dump["openapi"] == "3.0"
+def test_documentation():
+ import responder
+ from marshmallow import Schema, fields
+
+ api = responder.API(title="Web Service", openapi="3.0", docs_route="/docs")
+
+ @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.requests.get("/docs")
+ assert "html" in r.text
+
+
def test_mount_wsgi_app(api, flask):
@api.route("/")
def hello(req, resp):
@@ -432,12 +462,18 @@ def test_file_uploads(api):
def test_500(api):
+ def catcher(request, exc):
+ return PlainTextResponse("Suppressed error", 500)
+
+ api.app.add_exception_handler(ValueError, catcher)
+
@api.route("/")
def view(req, resp):
raise ValueError
r = api.requests.get(api.url_for(view))
assert not r.ok
+ assert r.content == b'Suppressed error'
def test_404(api):