Compare commits

..

28 Commits

Author SHA1 Message Date
kennethreitz eed5365fe0 file upload support 2018-10-20 09:56:35 -04:00
kennethreitz f5905568c4 files support 2018-10-20 09:54:53 -04:00
kennethreitz 096099470e yay tests pass 2018-10-20 08:50:36 -04:00
kennethreitz e7ed7aca3c tests still pass 2018-10-20 08:23:10 -04:00
kennethreitz 6725b275b8 cleanup 2018-10-20 07:59:39 -04:00
kennethreitz 3447a7ef41 v0.1.5 2018-10-20 07:59:12 -04:00
kennethreitz 99f35fbea4 use querydict for form parsing 2018-10-20 07:57:27 -04:00
kennethreitz 5c9a3912a9 cached _content 2018-10-20 07:38:53 -04:00
kennethreitz 5bb9f96701 cleanup 2018-10-19 05:11:07 -07:00
kennethreitz 750e9dfaa7 cleanup 2018-10-19 04:54:49 -07:00
kennethreitz f34f3c1661 v0.1.4 2018-10-19 04:17:06 -07:00
kennethreitz d4f83c978c improvements 2018-10-19 04:16:19 -07:00
kennethreitz 212f280c19 models 2018-10-19 03:10:39 -07:00
kennethreitz f3e2450636 models 2018-10-19 03:09:53 -07:00
kennethreitz d6d496018d fix 2018-10-19 03:08:15 -07:00
kennethreitz 78be7fc772 api 2018-10-19 03:00:41 -07:00
kennethreitz 6ebadd8469 new files 2018-10-19 02:19:38 -07:00
kennethreitz 557750c8d4 customizable cookie 2018-10-18 17:02:10 -07:00
kennethreitz e85ef27e6c Merge pull request #98 from pbsds/master
Store Jinja enviroment in between template render calls
2018-10-18 16:46:31 -07:00
kennethreitz 4ca961a1b4 Merge pull request #104 from metakermit/cli-build
CLI build command
2018-10-18 16:42:17 -07:00
kennethreitz 6a9110e9c1 Merge branch 'master' into cli-build 2018-10-18 16:40:20 -07:00
Dražen Lučanin 51ffce09ae fix cli setup 2018-10-18 23:48:09 +02:00
Peder Bergebakken Sundt 1c4e96b365 Add api.jinja_values_base:dict
This allows the user to add functions and values for use in all
templates, without needing to pass them on each render call.

As a side effect: The reference to `api` is still passed into the template view,
but this now yield to the values passed into api.template(), like one
would normally expect.
2018-10-18 20:47:59 +02:00
Peder Bergebakken Sundt 0db70e8edd Store jinja enviroment in between the template render calls
This allows the user to modify the jinja
enviroment, adding custom filters and such
2018-10-18 20:47:34 +02:00
Peder Bergebakken Sundt e46b3a5e19 Rename s to s_ in api.template_string()
Issue described in #76 applied here as well, however less propable.
Same fix as in a8fc78fcda
2018-10-18 20:47:14 +02:00
kennethreitz fdd3d4d85a sessions 2018-10-18 10:25:19 -07:00
kennethreitz a1bfbda05b Merge branch 'master' into cli 2018-10-17 03:15:49 -07:00
kennethreitz f309ad7746 cli 2018-10-16 05:14:48 -07:00
12 changed files with 254 additions and 89 deletions
+7
View File
@@ -1,3 +1,10 @@
# v0.1.5
- Improvements to sequential media reading.
- File upload support.
# v0.1.4
- Stability.
# v0.1.3
- Sessions support.
Generated
+21 -6
View File
@@ -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": [
+5
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "0.1.3"
__version__ = "0.1.5"
+79 -68
View File
@@ -32,6 +32,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.
"""
@@ -47,6 +48,7 @@ class API:
static_dir="static",
static_route="/static",
templates_dir="templates",
auto_escape=True,
secret_key="NOTASECRET",
enable_hsts=False,
):
@@ -62,6 +64,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))
@@ -86,6 +89,16 @@ class API:
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.
@property
def _apispec(self):
spec = APISpec(
@@ -191,57 +204,60 @@ 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")
@staticmethod
def no_response(req, resp, **params):
pass
async def _dispatch_request(self, req):
# Set formats on Request object.
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)
# Create the response object.
resp = models.Response(req=req, formats=self.formats)
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 route.is_graphql:
await self.graphql_response(req, resp, schema=route.endpoint)
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
elif route.is_function:
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
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)
if hasattr(r, "send"):
await r
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
# Then on_get.
method = req.method
try:
r = getattr(view, f"on_{method}")(req, resp, **params)
if hasattr(r, "send"):
await r
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
else:
self.default_response(req, resp)
@@ -270,6 +286,13 @@ 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)
# TODO: A better datastructer or sort it once the app is loaded
self.routes = dict(
@@ -318,7 +341,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 +372,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,
@@ -400,58 +429,40 @@ class API:
for (route, route_object) in self.routes.items():
if route_object.endpoint == endpoint:
return route_object.url(testing=testing, **params)
elif route_object.endpoint_name == endpoint:
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):
+43
View File
@@ -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
View File
@@ -1,2 +1,3 @@
from .api import API
from .models import Request, Response
from .cli import cli
+42 -3
View File
@@ -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,37 @@ 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.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
content = part.text
if filename:
dump[filename] = 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,
}
+11 -7
View File
@@ -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):
@@ -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):
+15
View File
@@ -31,6 +31,11 @@ class Route:
# Strings.
return self.does_match(other)
@property
def endpoint_name(self):
print(self.endpoint.__name__)
return self.endpoint.__name__
@property
def description(self):
return self.endpoint.__doc__
@@ -67,3 +72,13 @@ class Route:
@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))
+3 -3
View File
@@ -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"],
+26 -1
View File
@@ -1,6 +1,7 @@
import pytest
import yaml
import responder
import io
def test_api_basic_route(api):
@@ -395,5 +396,29 @@ def test_sessions(api, session):
assert "Responder-Session" in r.cookies
r = session.get(api.url_for(view))
assert r.cookies['Responder-Session'] == '{"hello": "world"}.r3EB04hEEyLYIJaAXCEq3d4YEbs'
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):
resp.media = {"files": await req.media("files")}
world = io.StringIO("world")
data = {"hello": world}
r = session.get(api.url_for(upload), files=data)
assert r.json() == {"files": {"hello": "world"}}