mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f982954e8f | |||
| 3ba20e69ba | |||
| aea01fd893 | |||
| 950be14eca | |||
| 446deffc17 | |||
| e0863115ee | |||
| e34cb539d2 | |||
| d8ade8638a | |||
| 886cc0f214 | |||
| 071d34b016 | |||
| a1564ca003 | |||
| 60f0e765c2 | |||
| 3f0ecea4bf | |||
| 2c9e6572c5 | |||
| 371a83f20f | |||
| b8cff1655a | |||
| 232856ca3a | |||
| 3f168ac6fd | |||
| c59cb1d0d3 | |||
| ec13df75d0 | |||
| 6fc02964ba | |||
| ed79e45680 | |||
| 1be983bf89 | |||
| b09d6a9d04 | |||
| db143d845d | |||
| 2e23501f9d | |||
| bd6addcd3a | |||
| 631e1fb604 | |||
| 30ee6726a8 | |||
| 1c397db9d8 | |||
| cc23ca80f4 | |||
| 449379a0ed | |||
| b3208b1c5b | |||
| 4df60b55a6 | |||
| 379553a1a5 | |||
| 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 | |||
| a1bfbda05b | |||
| f309ad7746 |
+18
-2
@@ -1,3 +1,19 @@
|
||||
# v0.2.1
|
||||
- api.requests.
|
||||
|
||||
# v0.2.0
|
||||
- WebSocket support.
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -11,7 +27,7 @@
|
||||
- Prototype of static application support.
|
||||
|
||||
# v0.0.10
|
||||
- Bufgix for async class-based views.
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.9
|
||||
- Bugfix for async class-based views.
|
||||
@@ -32,7 +48,7 @@
|
||||
- Safe load/dump yaml.
|
||||
|
||||
# v0.0.4:
|
||||
- Asyncronous support for data uploads.
|
||||
- Asynchronous support for data uploads.
|
||||
- Bug fixes.
|
||||
|
||||
# v0.0.3:
|
||||
|
||||
Generated
+21
-6
@@ -72,6 +72,12 @@
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"docopt": {
|
||||
"hashes": [
|
||||
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
|
||||
],
|
||||
"version": "==0.6.2"
|
||||
},
|
||||
"graphene": {
|
||||
"hashes": [
|
||||
"sha256:b8ec446d17fa68721636eaad3d6adc1a378cb6323e219814c8f98c9928fc9642",
|
||||
@@ -114,9 +120,10 @@
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
|
||||
"sha256:a7de3201740a857380421ef286166134e10fe58846bcefbc9d6424a69a0b99ec",
|
||||
"sha256:aca4fc561b7671115a2156f625f2eaa5e0e3527e0adf2870340e7968c0a81f85"
|
||||
],
|
||||
"version": "==0.24"
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
@@ -174,6 +181,13 @@
|
||||
],
|
||||
"version": "==2.20.0"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237",
|
||||
"sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5"
|
||||
],
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"responder": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
@@ -214,9 +228,9 @@
|
||||
},
|
||||
"uvicorn": {
|
||||
"hashes": [
|
||||
"sha256:30096b58325cdca8e547a6f5f4300040d0b8763f573cb1843abfa96f81a49cf8"
|
||||
"sha256:7c4550c7e6f7c8727fa5ccd5200baf62c9e055895e058933ee88f5d0c246ca0c"
|
||||
],
|
||||
"version": "==0.3.13"
|
||||
"version": "==0.3.14"
|
||||
},
|
||||
"websockets": {
|
||||
"hashes": [
|
||||
@@ -473,9 +487,10 @@
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
|
||||
"sha256:a7de3201740a857380421ef286166134e10fe58846bcefbc9d6424a69a0b99ec",
|
||||
"sha256:aca4fc561b7671115a2156f625f2eaa5e0e3527e0adf2870340e7968c0a81f85"
|
||||
],
|
||||
"version": "==0.24"
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
|
||||
@@ -86,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))
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ Serve a GraphQL API::
|
||||
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))
|
||||
|
||||
@@ -169,6 +169,11 @@ Responder has built-in support for cookie-based sessions. To enable cookie-based
|
||||
|
||||
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(...)``.
|
||||
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.3"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
+142
-76
@@ -9,6 +9,8 @@ import asyncio
|
||||
import jinja2
|
||||
import itsdangerous
|
||||
from graphql_server import encode_execution_results, json_encode, default_format_error
|
||||
from starlette.websockets import WebSocket
|
||||
from starlette.debug import DebugMiddleware
|
||||
from starlette.routing import Router
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.testclient import TestClient
|
||||
@@ -32,6 +34,7 @@ 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.
|
||||
"""
|
||||
|
||||
@@ -40,6 +43,7 @@ class API:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
debug=False,
|
||||
title=None,
|
||||
version=None,
|
||||
openapi=None,
|
||||
@@ -47,6 +51,7 @@ class API:
|
||||
static_dir="static",
|
||||
static_route="/static",
|
||||
templates_dir="templates",
|
||||
auto_escape=True,
|
||||
secret_key="NOTASECRET",
|
||||
enable_hsts=False,
|
||||
):
|
||||
@@ -62,6 +67,7 @@ class API:
|
||||
)
|
||||
self.routes = {}
|
||||
self.schemas = {}
|
||||
self.session_cookie = "Responder-Session"
|
||||
|
||||
self.hsts_enabled = enable_hsts
|
||||
self.static_files = StaticFiles(directory=str(self.static_dir))
|
||||
@@ -83,9 +89,24 @@ class API:
|
||||
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)
|
||||
|
||||
# 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.
|
||||
self.requests = (
|
||||
self.session()
|
||||
) #: A Requests session that is connected to the ASGI app.
|
||||
|
||||
@property
|
||||
def _apispec(self):
|
||||
spec = APISpec(
|
||||
@@ -137,7 +158,9 @@ class API:
|
||||
nonlocal scope, self
|
||||
|
||||
req = models.Request(scope, receive=receive, api=self)
|
||||
resp = await self._dispatch_request(req)
|
||||
resp = await self._dispatch_request(
|
||||
req, scope=scope, send=send, receive=receive
|
||||
)
|
||||
await resp(receive, send)
|
||||
|
||||
return asgi
|
||||
@@ -191,59 +214,86 @@ class API:
|
||||
|
||||
if resp.session:
|
||||
data = self._signer.sign(json.dumps(resp.session).encode("utf-8"))
|
||||
resp.cookies["Responder-Session"] = data.decode("utf-8")
|
||||
resp.cookies[self.session_cookie] = data.decode("utf-8")
|
||||
|
||||
async def _dispatch_request(self, req):
|
||||
@staticmethod
|
||||
def no_response(req, resp, **params):
|
||||
pass
|
||||
|
||||
async def _dispatch_request(self, req, **options):
|
||||
# Set formats on Request object.
|
||||
req.formats = self.formats
|
||||
|
||||
# Get the route.
|
||||
route = self.path_matches_route(req.url.path)
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
route = self.routes.get(route)
|
||||
|
||||
# Create the response object.
|
||||
cont = False
|
||||
|
||||
if route:
|
||||
try:
|
||||
params = self.routes[route].incoming_matches(req.url.path)
|
||||
result = self.routes[route].endpoint(req, resp, **params)
|
||||
if hasattr(result, "cr_running"):
|
||||
await result
|
||||
# The request is using class-based views.
|
||||
except TypeError as e:
|
||||
try:
|
||||
view = self.routes[route].endpoint(**params)
|
||||
except TypeError:
|
||||
view = self.routes[route].endpoint
|
||||
if not route.uses_websocket:
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
else:
|
||||
resp = WebSocket(**options)
|
||||
|
||||
if self.routes[route].is_graphql:
|
||||
await self.graphql_response(req, resp, schema=view)
|
||||
else:
|
||||
# WSGI App.
|
||||
# try:
|
||||
# return view(
|
||||
# environ=req._environ, start_response=req._start_response
|
||||
# )
|
||||
# except TypeError:
|
||||
# pass
|
||||
pass
|
||||
params = route.incoming_matches(req.url.path)
|
||||
|
||||
if route.is_graphql:
|
||||
await self.graphql_response(req, resp, schema=route.endpoint)
|
||||
|
||||
elif route.is_function:
|
||||
try:
|
||||
try:
|
||||
# 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:
|
||||
r = getattr(view, "on_request")(req, resp, **params)
|
||||
# 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 AttributeError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
# Then on_get.
|
||||
method = req.method
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
r = getattr(view, f"on_{method}")(req, resp, **params)
|
||||
# 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 AttributeError:
|
||||
pass
|
||||
except Exception as e:
|
||||
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
else:
|
||||
self.default_response(req, resp)
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
self.default_response(req, resp, notfound=True)
|
||||
|
||||
self.default_response(req, resp)
|
||||
|
||||
self._prepare_session(resp)
|
||||
self._prepare_cookies(resp)
|
||||
@@ -251,7 +301,14 @@ class API:
|
||||
return resp
|
||||
|
||||
def add_route(
|
||||
self, route, endpoint=None, *, default=False, static=False, check_existing=True
|
||||
self,
|
||||
route,
|
||||
endpoint=None,
|
||||
*,
|
||||
default=False,
|
||||
static=False,
|
||||
check_existing=True,
|
||||
websocket=False,
|
||||
):
|
||||
"""Add a route to the API.
|
||||
|
||||
@@ -270,18 +327,32 @@ class API:
|
||||
|
||||
if default:
|
||||
self.default_endpoint = endpoint
|
||||
self.routes[route] = Route(route, 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
|
||||
self.routes = dict(
|
||||
sorted(self.routes.items(), key=lambda item: item[1]._weight())
|
||||
)
|
||||
|
||||
def default_response(self, req, resp):
|
||||
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:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
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()
|
||||
@@ -318,7 +389,11 @@ class API:
|
||||
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
return json_media["query"], json_media.get("variables"), json_media.get("operationName")
|
||||
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
|
||||
@@ -345,7 +420,9 @@ class API:
|
||||
return
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req)
|
||||
result = schema.execute(query, variables=variables, operation_name=operation_name)
|
||||
result = schema.execute(
|
||||
query, variables=variables, operation_name=operation_name
|
||||
)
|
||||
result, status_code = encode_execution_results(
|
||||
[result],
|
||||
is_batch=False,
|
||||
@@ -390,6 +467,13 @@ class API:
|
||||
self._session = TestClient(self)
|
||||
return self._session
|
||||
|
||||
def _route_for(self, endpoint):
|
||||
for (route, route_object) in self.routes.items():
|
||||
if route_object.endpoint == endpoint:
|
||||
return route_object
|
||||
elif route_object.endpoint_name == endpoint:
|
||||
return route_object
|
||||
|
||||
def url_for(self, endpoint, testing=False, **params):
|
||||
# TODO: Absolute_url
|
||||
"""Given an endpoint, returns a rendered URL for its route.
|
||||
@@ -397,72 +481,54 @@ class API:
|
||||
:param view: The route endpoint you're searching for.
|
||||
:param params: Data to pass into the URL generator (for parameterized URLs).
|
||||
"""
|
||||
for (route, route_object) in self.routes.items():
|
||||
if route_object.endpoint == endpoint:
|
||||
return route_object.url(testing=testing, **params)
|
||||
route_object = self._route_for(endpoint)
|
||||
if route_object:
|
||||
return route_object.url(testing=testing, **params)
|
||||
raise ValueError
|
||||
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
def template(self, name_, auto_escape=True, **values):
|
||||
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 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}
|
||||
|
||||
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 []),
|
||||
)
|
||||
|
||||
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, **options):
|
||||
def run(self, address=None, port=None, debug=False, **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 debug: Run uvicorn server in debug mode.
|
||||
:param options: Additional keyword arguments to send to ``uvicorn.run()``.
|
||||
"""
|
||||
|
||||
if "PORT" in os.environ:
|
||||
if address is None:
|
||||
address = "0.0.0.0"
|
||||
@@ -473,4 +539,4 @@ class API:
|
||||
if port is None:
|
||||
port = 5042
|
||||
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
|
||||
@@ -0,0 +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
|
||||
|
||||
+41
-3
@@ -1,10 +1,17 @@
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import yaml
|
||||
import json
|
||||
from parse import findall
|
||||
from .models import QueryDict
|
||||
from requests_toolbelt.multipart import decoder
|
||||
|
||||
|
||||
async def format_form(r, encode=False):
|
||||
if not encode:
|
||||
return await r._starlette.form()
|
||||
if encode:
|
||||
pass
|
||||
else:
|
||||
return QueryDict(await r.text)
|
||||
|
||||
|
||||
async def format_yaml(r, encode=False):
|
||||
@@ -23,5 +30,36 @@ async def format_json(r, encode=False):
|
||||
return json.loads(await r.content)
|
||||
|
||||
|
||||
async def format_files(r, encode=False):
|
||||
if encode:
|
||||
pass
|
||||
else:
|
||||
decoded = decoder.MultipartDecoder(await r.content, r.mimetype)
|
||||
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,
|
||||
}
|
||||
|
||||
+12
-8
@@ -90,13 +90,14 @@ class QueryDict(dict):
|
||||
|
||||
# TODO: add slots
|
||||
class Request:
|
||||
__slots__ = ["_starlette", "formats", "_headers", "_encoding", "api"]
|
||||
__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():
|
||||
@@ -108,7 +109,7 @@ class Request:
|
||||
def session(self):
|
||||
"""The session data, in dict form, from the Request."""
|
||||
if "Responder-Session" in self.cookies:
|
||||
data = self.cookies["Responder-Session"]
|
||||
data = self.cookies[self.api.session_cookie]
|
||||
data = self.api._signer.unsign(data)
|
||||
return json.loads(data)
|
||||
return {}
|
||||
@@ -143,7 +144,6 @@ class Request:
|
||||
cookies = RequestsCookieJar()
|
||||
cookie_header = self.headers.get("cookie", "")
|
||||
|
||||
# if cookie_header:
|
||||
bc = SimpleCookie(cookie_header)
|
||||
for k, v in bc.items():
|
||||
cookies[k] = v
|
||||
@@ -180,12 +180,14 @@ class Request:
|
||||
@property
|
||||
async def content(self):
|
||||
"""The Request body, as bytes. Must be awaited."""
|
||||
return await self._starlette.body()
|
||||
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._starlette.body()).decode(await self.encoding)
|
||||
return (await self.content).decode(await self.encoding)
|
||||
|
||||
@property
|
||||
async def declared_encoding(self):
|
||||
@@ -242,7 +244,7 @@ class Response:
|
||||
|
||||
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 = DEFAULT_ENCODING
|
||||
@@ -251,10 +253,12 @@ class Response:
|
||||
) #: 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."""
|
||||
self.session = (
|
||||
req.session.copy()
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
|
||||
@property
|
||||
async def body(self):
|
||||
|
||||
+18
-3
@@ -15,9 +15,10 @@ def memoize(f):
|
||||
class Route:
|
||||
_param_pattern = re.compile(r"{([^{}]*)}")
|
||||
|
||||
def __init__(self, route, endpoint):
|
||||
def __init__(self, route, endpoint, websocket=False):
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.uses_websocket = websocket
|
||||
self._memo = {}
|
||||
|
||||
def __repr__(self):
|
||||
@@ -31,6 +32,10 @@ class Route:
|
||||
# Strings.
|
||||
return self.does_match(other)
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self.endpoint.__doc__
|
||||
@@ -61,9 +66,19 @@ class Route:
|
||||
|
||||
def _weight(self):
|
||||
params = set(self._param_pattern.findall(self.route))
|
||||
params_count = -len(params) or 0
|
||||
return params_count != 0, params_count
|
||||
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))
|
||||
|
||||
@@ -38,7 +38,9 @@ required = [
|
||||
"apispec>=1.0.0b1",
|
||||
"marshmallow",
|
||||
"asgiref",
|
||||
"docopt",
|
||||
"itsdangerous",
|
||||
"requests-toolbelt",
|
||||
]
|
||||
|
||||
|
||||
@@ -118,9 +120,7 @@ setup(
|
||||
author_email="me@kennethreitz.org",
|
||||
url="https://github.com/kennethreitz/responder",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
# entry_points={
|
||||
# "console_scripts": ["responder=responder:cli"]
|
||||
# },
|
||||
entry_points={"console_scripts": ["responder=responder.cli:cli"]},
|
||||
package_data={
|
||||
# "": ["LICENSE", "NOTICES"],
|
||||
# "pipenv.vendor.requests": ["*.pem"],
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ def api():
|
||||
|
||||
@pytest.fixture
|
||||
def session(api):
|
||||
return api.session()
|
||||
return api.requests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -22,15 +22,3 @@ def test_bytes_encoding(api, session):
|
||||
|
||||
r = session.get(api.url_for(route), data=data)
|
||||
assert r.content == data
|
||||
|
||||
|
||||
def test_false_encoding_raises(api, session):
|
||||
data = "hi mom!"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
req.encoding = "non-existient"
|
||||
resp.text = await req.text
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
session.get(api.url_for(route), data=data)
|
||||
|
||||
+108
-53
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
import yaml
|
||||
import responder
|
||||
import io
|
||||
|
||||
|
||||
def test_api_basic_route(api):
|
||||
@@ -72,16 +73,17 @@ def test_class_based_view_parameters(api):
|
||||
|
||||
def test_requests_session(api):
|
||||
assert api.session()
|
||||
assert api.requests
|
||||
|
||||
|
||||
def test_requests_session_works(api, session, url):
|
||||
def test_requests_session_works(api, url):
|
||||
TEXT = "spiral out"
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = TEXT
|
||||
|
||||
assert session.get(url("/")).text == TEXT
|
||||
assert api.requests.get(url("/")).text == TEXT
|
||||
|
||||
|
||||
def test_status_code(api):
|
||||
@@ -90,7 +92,7 @@ def test_status_code(api):
|
||||
resp.text = "keep going"
|
||||
resp.status_code = responder.status_codes.HTTP_416
|
||||
|
||||
assert api.session().get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
assert api.requests.get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
|
||||
|
||||
def test_json_media(api):
|
||||
@@ -100,7 +102,7 @@ def test_json_media(api):
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/")
|
||||
r = api.requests.get("http://;/")
|
||||
|
||||
assert "json" in r.headers["Content-Type"]
|
||||
assert r.json() == dump
|
||||
@@ -113,7 +115,7 @@ def test_yaml_media(api):
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/", headers={"Accept": "yaml"})
|
||||
r = api.requests.get("http://;/", headers={"Accept": "yaml"})
|
||||
|
||||
assert "yaml" in r.headers["Content-Type"]
|
||||
assert yaml.load(r.content) == dump
|
||||
@@ -122,38 +124,38 @@ def test_yaml_media(api):
|
||||
def test_graphql_schema_query_querying(api, schema):
|
||||
api.add_route("/", schema)
|
||||
|
||||
r = api.session().get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
assert r.json() == {"data": {"hello": "Hello stranger"}}
|
||||
|
||||
|
||||
def test_argumented_routing(api, session):
|
||||
def test_argumented_routing(api):
|
||||
@api.route("/{name}")
|
||||
def hello(req, resp, *, name):
|
||||
resp.text = f"Hello, {name}."
|
||||
|
||||
r = session.get(api.url_for(hello, name="sean"))
|
||||
r = api.requests.get(api.url_for(hello, name="sean"))
|
||||
assert r.text == "Hello, sean."
|
||||
|
||||
|
||||
def test_mote_argumented_routing(api, session):
|
||||
def test_mote_argumented_routing(api):
|
||||
@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"))
|
||||
r = api.requests.get(api.url_for(hello, greeting="hello", name="lyndsy"))
|
||||
assert r.text == "hello, lyndsy."
|
||||
|
||||
|
||||
def test_request_and_get(api, session):
|
||||
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):
|
||||
def on_get(self, req, resp):
|
||||
resp.headers.update({"LIFE": "42"})
|
||||
|
||||
r = session.get(api.url_for(ThingsResource))
|
||||
r = api.requests.get(api.url_for(ThingsResource))
|
||||
assert "DEATH" in r.headers
|
||||
assert "LIFE" in r.headers
|
||||
|
||||
@@ -164,58 +166,58 @@ def test_class_based_view_status_code(api):
|
||||
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
|
||||
assert api.requests.get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
|
||||
|
||||
def test_query_params(api, url, session):
|
||||
def test_query_params(api, url):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = {"params": req.params}
|
||||
|
||||
r = session.get(api.url_for(route), params={"q": "q"})
|
||||
r = api.requests.get(api.url_for(route), params={"q": "q"})
|
||||
assert r.json()["params"] == {"q": "q"}
|
||||
|
||||
r = session.get(url("/?q=1&q=2&q=3"))
|
||||
r = api.requests.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):
|
||||
def test_form_data(api):
|
||||
@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)
|
||||
r = api.requests.get(api.url_for(route), data=dump)
|
||||
assert r.json()["form"] == dump
|
||||
|
||||
|
||||
def test_async_function(api, session):
|
||||
def test_async_function(api):
|
||||
content = "The Emerald Tablet of Hermes"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.text = content
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
r = api.requests.get(api.url_for(route))
|
||||
assert r.text == content
|
||||
|
||||
|
||||
def test_media_parsing(api, session):
|
||||
def test_media_parsing(api):
|
||||
dump = {"hello": "sam"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
r = api.requests.get(api.url_for(route))
|
||||
assert r.json() == dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Accept": "application/x-yaml"})
|
||||
r = api.requests.get(api.url_for(route), headers={"Accept": "application/x-yaml"})
|
||||
assert r.text == "{hello: sam}\n"
|
||||
|
||||
|
||||
def test_background(api, session):
|
||||
def test_background(api):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
@api.background.task
|
||||
@@ -227,11 +229,11 @@ def test_background(api, session):
|
||||
task()
|
||||
api.text = "ok"
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
r = api.requests.get(api.url_for(route))
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_multiple_routes(api, session):
|
||||
def test_multiple_routes(api):
|
||||
@api.route("/1")
|
||||
def route1(req, resp):
|
||||
resp.text = "1"
|
||||
@@ -240,45 +242,45 @@ def test_multiple_routes(api, session):
|
||||
def route2(req, resp):
|
||||
resp.text = "2"
|
||||
|
||||
r = session.get(api.url_for(route1))
|
||||
r = api.requests.get(api.url_for(route1))
|
||||
assert r.text == "1"
|
||||
|
||||
r = session.get(api.url_for(route2))
|
||||
r = api.requests.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 }"})
|
||||
r = api.requests.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"})
|
||||
r = api.requests.get("http://;/", headers={"Accept": "text/html"})
|
||||
assert r.ok
|
||||
assert "GraphiQL" in r.text
|
||||
|
||||
|
||||
def test_json_uploads(api, session):
|
||||
def test_json_uploads(api):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(api.url_for(route), json=dump)
|
||||
r = api.requests.post(api.url_for(route), json=dump)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_uploads(api, session):
|
||||
def test_yaml_uploads(api):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(
|
||||
r = api.requests.post(
|
||||
api.url_for(route),
|
||||
data=yaml.dump(dump),
|
||||
headers={"Content-Type": "application/x-yaml"},
|
||||
@@ -286,35 +288,39 @@ def test_yaml_uploads(api, session):
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_form_uploads(api, session):
|
||||
def test_form_uploads(api):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(api.url_for(route), data=dump)
|
||||
r = api.requests.post(api.url_for(route), data=dump)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_json_downloads(api, session):
|
||||
def test_json_downloads(api):
|
||||
dump = {"testing": "123"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Content-Type": "application/json"})
|
||||
r = api.requests.get(
|
||||
api.url_for(route), headers={"Content-Type": "application/json"}
|
||||
)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_downloads(api, session):
|
||||
def test_yaml_downloads(api):
|
||||
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"})
|
||||
r = api.requests.get(
|
||||
api.url_for(route), headers={"Content-Type": "application/x-yaml"}
|
||||
)
|
||||
assert yaml.safe_load(r.content) == dump
|
||||
|
||||
|
||||
@@ -342,58 +348,107 @@ def test_schema_generation():
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
r = api.session().get("http://;/schema.yml")
|
||||
r = api.requests.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):
|
||||
def test_mount_wsgi_app(api, flask):
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "hello"
|
||||
|
||||
api.mount("/flask", flask)
|
||||
|
||||
r = session.get("http://;/flask")
|
||||
r = api.requests.get("http://;/flask")
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_async_class_based_views(api, session):
|
||||
def test_async_class_based_views(api):
|
||||
@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)
|
||||
r = api.requests.post(api.url_for(Resource), data=data)
|
||||
assert r.text == data
|
||||
|
||||
|
||||
def test_cookies(api, session):
|
||||
def test_cookies(api):
|
||||
@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"})
|
||||
r = api.requests.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))
|
||||
r = api.requests.get(api.url_for(cookies))
|
||||
assert r.json() == {"cookies": {"sent": "true"}}
|
||||
|
||||
|
||||
def test_sessions(api, session):
|
||||
def test_sessions(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.session["hello"] = "world"
|
||||
resp.media = resp.session
|
||||
|
||||
r = session.get(api.url_for(view))
|
||||
r = api.requests.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"}.r3EB04hEEyLYIJaAXCEq3d4YEbs'
|
||||
r = api.requests.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):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.content = api.template_string("{{ var }}", var="hello")
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.text == "hello"
|
||||
|
||||
|
||||
def test_file_uploads(api):
|
||||
@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 = api.requests.post(api.url_for(upload), files=data)
|
||||
assert r.json() == {"files": {"hello": "world"}}
|
||||
|
||||
|
||||
def test_500(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
raise ValueError
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert not r.ok
|
||||
|
||||
|
||||
def test_404(api):
|
||||
r = api.requests.get("/foo")
|
||||
|
||||
assert r.status_code == responder.status_codes.HTTP_404
|
||||
|
||||
|
||||
def test_kinda_websockets(api):
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
await ws.send_text("Hello via websocket!")
|
||||
await ws.close()
|
||||
|
||||
Reference in New Issue
Block a user