mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a08b275463 | |||
| 9717208dd4 | |||
| c9a233f5e5 | |||
| 7389350ff9 | |||
| 296d5e7974 | |||
| fe0bea686c | |||
| 838d172512 | |||
| 2c02c51c37 | |||
| 67a4cbca2c | |||
| a2f97e727f | |||
| 462506113e | |||
| 5f2a72203f | |||
| d6febe2d02 | |||
| c2bd1e989a | |||
| f886c2c050 | |||
| ae770e603a | |||
| 6b93125ff2 | |||
| 43faef4569 |
@@ -1,3 +1,9 @@
|
||||
# v1.0.1
|
||||
- Improve cors configuration settings.
|
||||
|
||||
# v1.0.0
|
||||
- Move GraphQL support into a built-in plugin.
|
||||
|
||||
# v0.3.3
|
||||
- Improved exceptions.
|
||||
- CORS support.
|
||||
|
||||
Generated
+12
-14
@@ -126,10 +126,9 @@
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:a7de3201740a857380421ef286166134e10fe58846bcefbc9d6424a69a0b99ec",
|
||||
"sha256:aca4fc561b7671115a2156f625f2eaa5e0e3527e0adf2870340e7968c0a81f85"
|
||||
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
|
||||
],
|
||||
"version": "==1.0.0"
|
||||
"version": "==0.24"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
@@ -146,10 +145,10 @@
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:82b201ad767eb54de371c08cb1db6ca4ad2a728fa41b831e3781bf944815eb38",
|
||||
"sha256:c250f37ac0e249a8287394a60d91f6240b674642ad999e66cd09463dbccd1d4f"
|
||||
"sha256:5e0053c86e3abaa72a03bbe0021ec97270c13fd6400b682eb1aeaf24b871bc8a",
|
||||
"sha256:81884e930c1db72d8b8e3d8d2d090f2f43427e5c11c37f703b29879980491ab6"
|
||||
],
|
||||
"version": "==3.0.0b18"
|
||||
"version": "==3.0.0b19"
|
||||
},
|
||||
"parse": {
|
||||
"hashes": [
|
||||
@@ -410,9 +409,9 @@
|
||||
},
|
||||
"future": {
|
||||
"hashes": [
|
||||
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
|
||||
"sha256:eb6d4df04f1fb538c99f69c9a28b255d1ee4e825d479b9c62fc38c0cf38065a4"
|
||||
],
|
||||
"version": "==0.16.0"
|
||||
"version": "==0.17.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
@@ -430,10 +429,9 @@
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:a7de3201740a857380421ef286166134e10fe58846bcefbc9d6424a69a0b99ec",
|
||||
"sha256:aca4fc561b7671115a2156f625f2eaa5e0e3527e0adf2870340e7968c0a81f85"
|
||||
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
|
||||
],
|
||||
"version": "==1.0.0"
|
||||
"version": "==0.24"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
@@ -450,10 +448,10 @@
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:82b201ad767eb54de371c08cb1db6ca4ad2a728fa41b831e3781bf944815eb38",
|
||||
"sha256:c250f37ac0e249a8287394a60d91f6240b674642ad999e66cd09463dbccd1d4f"
|
||||
"sha256:5e0053c86e3abaa72a03bbe0021ec97270c13fd6400b682eb1aeaf24b871bc8a",
|
||||
"sha256:81884e930c1db72d8b8e3d8d2d090f2f43427e5c11c37f703b29879980491ab6"
|
||||
],
|
||||
"version": "==3.0.0b18"
|
||||
"version": "==3.0.0b19"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
[](http://python-responder.org/)
|
||||
|
||||
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
|
||||
|
||||
@@ -24,7 +22,7 @@ if __name__ == '__main__':
|
||||
api.run()
|
||||
```
|
||||
|
||||
That `async` declaration is optional. [View documentation](http://python-responder.org).
|
||||
Powered by [Starlette](https://www.starlette.io/). 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.
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ A familiar HTTP Service Framework
|
||||
.. |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.
|
||||
|
||||
.. code:: python
|
||||
|
||||
import responder
|
||||
@@ -37,7 +34,7 @@ spread some `Hacktoberfest <https://hacktoberfest.digitalocean.com/>`_ spirit ar
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
That ``async`` declaration is optional.
|
||||
Powered by `Starlette <https://www.starlette.io/>`_. That ``async`` declaration is optional.
|
||||
|
||||
This gets you a ASGI app, with a production static files server (WhiteNoise)
|
||||
pre-installed, jinja2 templating (without additional imports), and a
|
||||
|
||||
@@ -45,7 +45,10 @@ Serve a GraphQL API::
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
schema = graphene.Schema(query=Query)
|
||||
view = responder.ext.GraphQLView(query=query, api=api)
|
||||
|
||||
api.add_route("/graph", view)
|
||||
|
||||
Visiting the endpoint will render a *GraphiQL* instance, in the browser.
|
||||
|
||||
@@ -217,7 +220,7 @@ Want `CORS <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/>`_ ?
|
||||
|
||||
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 :
|
||||
In order to set custom parameters, you need to set the ``cors_params`` argument of ``api``, 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'``.
|
||||
@@ -226,4 +229,3 @@ In order to set custom parameters, you need to pass the ``cors_params`` argument
|
||||
* ``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``.
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .core import *
|
||||
from . import ext
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.3"
|
||||
__version__ = "1.0.1"
|
||||
|
||||
+66
-70
@@ -1,36 +1,39 @@
|
||||
import os
|
||||
import json
|
||||
from functools import partial
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
import apistar
|
||||
import yaml
|
||||
import jinja2
|
||||
import itsdangerous
|
||||
from graphql_server import encode_execution_results, json_encode, default_format_error
|
||||
from starlette.websockets import WebSocket
|
||||
import jinja2
|
||||
import uvicorn
|
||||
import yaml
|
||||
from apispec import APISpec, yaml_utils
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
from starlette.debug import DebugMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.exceptions import ExceptionMiddleware
|
||||
from starlette.lifespan import LifespanHandler
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
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 starlette.routing import Router
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocket
|
||||
from whitenoise import WhiteNoise
|
||||
|
||||
from . import models
|
||||
from . import status_codes
|
||||
from .routes import Route
|
||||
from .formats import get_formats
|
||||
from . import models, status_codes
|
||||
from .background import BackgroundQueue
|
||||
from .templates import GRAPHIQL
|
||||
from .formats import get_formats
|
||||
from .routes import Route
|
||||
from .statics import (
|
||||
DEFAULT_API_THEME, DEFAULT_SESSION_COOKIE, DEFAULT_SECRET_KEY, DEFAULT_CORS_PARAMS
|
||||
DEFAULT_API_THEME,
|
||||
DEFAULT_CORS_PARAMS,
|
||||
DEFAULT_SECRET_KEY,
|
||||
DEFAULT_SESSION_COOKIE,
|
||||
)
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
|
||||
# TODO: consider moving status codes here
|
||||
@@ -61,8 +64,8 @@ class API:
|
||||
enable_hsts=False,
|
||||
docs_route=None,
|
||||
cors=False,
|
||||
cors_params=DEFAULT_CORS_PARAMS
|
||||
):
|
||||
|
||||
self.secret_key = secret_key
|
||||
self.title = title
|
||||
self.version = version
|
||||
@@ -81,7 +84,7 @@ class API:
|
||||
|
||||
self.hsts_enabled = enable_hsts
|
||||
self.cors = cors
|
||||
self.cors_params = cors_params
|
||||
self.cors_params = DEFAULT_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)
|
||||
@@ -120,6 +123,7 @@ class API:
|
||||
|
||||
if self.hsts_enabled:
|
||||
self.add_middleware(HTTPSRedirectMiddleware)
|
||||
self.lifespan_handler = LifespanHandler()
|
||||
|
||||
if self.cors:
|
||||
self.add_middleware(CORSMiddleware, **self.cors_params)
|
||||
@@ -171,6 +175,9 @@ class API:
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def __call__(self, scope):
|
||||
if scope["type"] == "lifespan":
|
||||
return self.lifespan_handler(scope)
|
||||
|
||||
path = scope["path"]
|
||||
root_path = scope.get("root_path", "")
|
||||
|
||||
@@ -273,10 +280,7 @@ class API:
|
||||
|
||||
params = route.incoming_matches(req.url.path)
|
||||
|
||||
if route.is_graphql:
|
||||
await self.graphql_response(req, resp, schema=route.endpoint)
|
||||
|
||||
elif route.is_function:
|
||||
if route.is_function:
|
||||
try:
|
||||
try:
|
||||
# Run the view.
|
||||
@@ -294,7 +298,10 @@ class API:
|
||||
try:
|
||||
view = route.endpoint(**params)
|
||||
except TypeError:
|
||||
view = route.endpoint()
|
||||
try:
|
||||
view = route.endpoint()
|
||||
except TypeError:
|
||||
view = route.endpoint
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
@@ -335,6 +342,15 @@ class API:
|
||||
|
||||
return resp
|
||||
|
||||
def add_event_handler(self, event_type, handler):
|
||||
"""Add a event handler to the API.
|
||||
|
||||
:param event_type: A string in ("startup", "shutdown")
|
||||
:param handler: The function to run. Can be either a function or a coroutine.
|
||||
"""
|
||||
|
||||
self.lifespan_handler.add_event_handler(event_type, handler)
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
route,
|
||||
@@ -348,7 +364,7 @@ class API:
|
||||
"""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, or graphene schema (GraphQL).
|
||||
:param endpoint: The endpoint for the route -- can be a callable, or a class.
|
||||
: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.
|
||||
@@ -416,54 +432,31 @@ class API:
|
||||
resp.text = f"Redirecting to: {location}"
|
||||
resp.headers.update({"Location": location})
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
def on_event(self, event_type: str, **args):
|
||||
"""Decorator for registering functions or coroutines to run at certain events
|
||||
Supported events: startup, cleanup, shutdown, tick
|
||||
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
Usage::
|
||||
|
||||
# Support query/q in form data.
|
||||
# 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
|
||||
@api.on_event('startup')
|
||||
async def open_database_connection_pool():
|
||||
...
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"], None, None
|
||||
if "q" in req.params:
|
||||
return req.params["q"], None, None
|
||||
@api.on_event('tick', seconds=10)
|
||||
async def do_stuff():
|
||||
...
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text, None, None
|
||||
@api.on_event('cleanup')
|
||||
async def close_database_connection_pool():
|
||||
...
|
||||
|
||||
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
|
||||
def decorator(func):
|
||||
self.add_event_handler(event_type, func, **args)
|
||||
return func
|
||||
|
||||
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,
|
||||
format_error=default_format_error,
|
||||
encode=partial(json_encode, pretty=False),
|
||||
)
|
||||
resp.media = json.loads(result)
|
||||
return (query, result, status_code)
|
||||
return decorator
|
||||
|
||||
def route(self, route, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
@@ -600,4 +593,7 @@ class API:
|
||||
if port is None:
|
||||
port = 5042
|
||||
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
def spawn():
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
|
||||
spawn()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .graphql import GraphQLView
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
from functools import partial
|
||||
|
||||
from graphql_server import default_format_error, encode_execution_results, json_encode
|
||||
|
||||
from ..templates import GRAPHIQL
|
||||
|
||||
|
||||
class GraphQLView:
|
||||
def __init__(self, *, api, schema):
|
||||
self.api = api
|
||||
self.schema = schema
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in form data.
|
||||
# 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"], None, None
|
||||
if "q" in req.params:
|
||||
return req.params["q"], None, None
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp, schema):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.api.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,
|
||||
format_error=default_format_error,
|
||||
encode=partial(json_encode, pretty=False),
|
||||
)
|
||||
resp.media = json.loads(result)
|
||||
return (query, result, status_code)
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp, self.schema)
|
||||
@@ -65,10 +65,6 @@ class 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__")
|
||||
|
||||
+2
-1
@@ -2,6 +2,8 @@ import graphene
|
||||
import responder
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import multiprocessing
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -44,7 +46,6 @@ def flask():
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema():
|
||||
class Query(graphene.ObjectType):
|
||||
|
||||
+31
-4
@@ -1,6 +1,9 @@
|
||||
import concurrent
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
import responder
|
||||
import requests
|
||||
import io
|
||||
|
||||
from starlette.responses import PlainTextResponse
|
||||
@@ -124,7 +127,7 @@ def test_yaml_media(api):
|
||||
|
||||
|
||||
def test_graphql_schema_query_querying(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
assert r.json() == {"data": {"hello": "Hello stranger"}}
|
||||
@@ -252,14 +255,14 @@ def test_multiple_routes(api):
|
||||
|
||||
|
||||
def test_graphql_schema_json_query(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.requests.post("http://;/", json={"query": "{ hello }"})
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_graphiql(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.requests.get("http://;/", headers={"Accept": "text/html"})
|
||||
assert r.ok
|
||||
@@ -473,7 +476,7 @@ def test_500(api):
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert not r.ok
|
||||
assert r.content == b'Suppressed error'
|
||||
assert r.content == b"Suppressed error"
|
||||
|
||||
|
||||
def test_404(api):
|
||||
@@ -488,3 +491,27 @@ def test_kinda_websockets(api):
|
||||
await ws.accept()
|
||||
await ws.send_text("Hello via websocket!")
|
||||
await ws.close()
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_startup(api, session):
|
||||
who = [None]
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, {who[0]}!"
|
||||
|
||||
@api.on_event("startup")
|
||||
async def asd():
|
||||
who[0] = "world"
|
||||
print("startup")
|
||||
|
||||
@api.on_event("cleanup")
|
||||
async def asd():
|
||||
print("cleanup")
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=2)
|
||||
f = pool.submit(api.run)
|
||||
|
||||
r = requests.get(f"http://localhost:5042/hello")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
Reference in New Issue
Block a user