datasette.client internal requests mechanism

Closes #943

* Datasette now requires httpx>=0.15
* Support OPTIONS without 500, closes #1001
* Added internals tests for datasette.client methods
* Datasette's own test mechanism now uses httpx to simulate requests
* Tests simulate HTTP 1.1 now
* Added base_url in a bunch more places
* Mark some tests as xfail - will remove that when new httpx release ships: #1005
This commit is contained in:
Simon Willison 2020-10-09 09:11:24 -07:00 committed by GitHub
commit 8f97b9b58e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 163 additions and 100 deletions

View file

@ -4,8 +4,8 @@ import collections
import datetime
import glob
import hashlib
import httpx
import inspect
import itertools
from itsdangerous import BadSignature
import json
import os
@ -18,7 +18,6 @@ import urllib.parse
from concurrent import futures
from pathlib import Path
import click
from markupsafe import Markup
from itsdangerous import URLSafeSerializer
import jinja2
@ -62,7 +61,6 @@ from .utils.asgi import (
Forbidden,
NotFound,
Request,
Response,
asgi_static,
asgi_send,
asgi_send_html,
@ -312,6 +310,7 @@ class Datasette:
self._register_renderers()
self._permission_checks = collections.deque(maxlen=200)
self._root_token = secrets.token_hex(32)
self.client = DatasetteClient(self)
async def invoke_startup(self):
for hook in pm.hook.startup(datasette=self):
@ -1209,3 +1208,45 @@ def route_pattern_from_filepath(filepath):
class NotFoundExplicit(NotFound):
pass
class DatasetteClient:
def __init__(self, ds):
self.app = ds.app()
def _fix(self, path):
if path.startswith("/"):
path = "http://localhost{}".format(path)
return path
async def get(self, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.get(self._fix(path), **kwargs)
async def options(self, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.options(self._fix(path), **kwargs)
async def head(self, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.head(self._fix(path), **kwargs)
async def post(self, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.post(self._fix(path), **kwargs)
async def put(self, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.put(self._fix(path), **kwargs)
async def patch(self, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.patch(self._fix(path), **kwargs)
async def delete(self, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.delete(self._fix(path), **kwargs)
async def request(self, method, path, **kwargs):
async with httpx.AsyncClient(app=self.app) as client:
return await client.request(method, self._fix(path), **kwargs)

View file

@ -450,7 +450,7 @@ def serve(
asyncio.get_event_loop().run_until_complete(check_databases(ds))
if get:
client = TestClient(ds.app())
client = TestClient(ds)
response = client.get(get)
click.echo(response.text)
exit_code = 0 if response.status == 200 else 1

View file

@ -8,9 +8,9 @@
{{ column.name }}
{% else %}
{% if column.name == sort %}
<a href="{{ path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None}) }}" rel="nofollow">{{ column.name }}&nbsp;</a>
<a href="{{ base_url }}{{ path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None}) }}" rel="nofollow">{{ column.name }}&nbsp;</a>
{% else %}
<a href="{{ path_with_replaced_args(request, {'_sort': column.name, '_sort_desc': None, '_next': None}) }}" rel="nofollow">{{ column.name }}{% if column.name == sort_desc %}&nbsp;▲{% endif %}</a>
<a href="{{ base_url }}{{ path_with_replaced_args(request, {'_sort': column.name, '_sort_desc': None, '_next': None}) }}" rel="nofollow">{{ column.name }}{% if column.name == sort_desc %}&nbsp;▲{% endif %}</a>
{% endif %}
{% endif %}
</th>

View file

@ -58,7 +58,7 @@
</form>
{% if display_rows %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ base_url }}{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ base_url }}{{ url_csv }}">CSV</a></p>
<table class="rows-and-columns">
<thead>
<tr>

View file

@ -29,7 +29,7 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<p>This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}</p>
<p>This data as {% for name, url in renderers.items() %}<a href="{{ base_url }}{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}</p>
{% include custom_table_templates %}

View file

@ -11,7 +11,6 @@
{% for column in display_columns %}
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column.name|escape_css_string }}"; }
{% endfor %}
}
</style>
{% endblock %}
@ -111,7 +110,7 @@
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ database_url(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
{% endif %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ base_url }}{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ base_url }}{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
{% if suggested_facets %}
<p class="suggested-facets">
@ -160,10 +159,10 @@
<div id="export" class="advanced-export">
<h3>Advanced export</h3>
<p>JSON shape:
<a href="{{ renderers['json'] }}">default</a>,
<a href="{{ append_querystring(renderers['json'], '_shape=array') }}">array</a>,
<a href="{{ append_querystring(renderers['json'], '_shape=array&_nl=on') }}">newline-delimited</a>{% if primary_keys %},
<a href="{{ append_querystring(renderers['json'], '_shape=object') }}">object</a>
<a href="{{ base_url }}{{ renderers['json'] }}">default</a>,
<a href="{{ base_url }}{{ append_querystring(renderers['json'], '_shape=array') }}">array</a>,
<a href="{{ base_url }}{{ append_querystring(renderers['json'], '_shape=array&_nl=on') }}">newline-delimited</a>{% if primary_keys %},
<a href="{{ base_url }}{{ append_querystring(renderers['json'], '_shape=object') }}">object</a>
{% endif %}
</p>
<form action="{{ url_csv_path }}" method="get">

View file

@ -1,23 +1,39 @@
from datasette.utils import MultiParams
from asgiref.testing import ApplicationCommunicator
from asgiref.sync import async_to_sync
from urllib.parse import unquote, quote, urlencode
from http.cookies import SimpleCookie
from urllib.parse import urlencode
import json
# These wrapper classes pre-date the introduction of
# datasette.client and httpx to Datasette. They could
# be removed if the Datasette tests are modified to
# call datasette.client directly.
class TestResponse:
def __init__(self, status, headers, body):
self.status = status
self.headers = headers
self.body = body
def __init__(self, httpx_response):
self.httpx_response = httpx_response
@property
def status(self):
return self.httpx_response.status_code
@property
def headers(self):
return self.httpx_response.headers
@property
def body(self):
return self.httpx_response.content
@property
def cookies(self):
cookie = SimpleCookie()
for header in self.headers.getlist("set-cookie"):
cookie.load(header)
return {key: value.value for key, value in cookie.items()}
return dict(self.httpx_response.cookies)
def cookie_was_deleted(self, cookie):
return any(
h
for h in self.httpx_response.headers.get_list("set-cookie")
if h.startswith('{}="";'.format(cookie))
)
@property
def json(self):
@ -31,8 +47,8 @@ class TestResponse:
class TestClient:
max_redirects = 5
def __init__(self, asgi_app):
self.asgi_app = asgi_app
def __init__(self, ds):
self.ds = ds
def actor_cookie(self, actor):
return self.ds.sign({"a": actor}, "actor")
@ -94,61 +110,18 @@ class TestClient:
post_body=None,
content_type=None,
):
query_string = b""
if "?" in path:
path, _, query_string = path.partition("?")
query_string = query_string.encode("utf8")
if "%" in path:
raw_path = path.encode("latin-1")
else:
raw_path = quote(path, safe="/:,").encode("latin-1")
asgi_headers = [[b"host", b"localhost"]]
if headers:
for key, value in headers.items():
asgi_headers.append([key.encode("utf-8"), value.encode("utf-8")])
headers = headers or {}
if content_type:
asgi_headers.append((b"content-type", content_type.encode("utf-8")))
if cookies:
sc = SimpleCookie()
for key, value in cookies.items():
sc[key] = value
asgi_headers.append([b"cookie", sc.output(header="").encode("utf-8")])
scope = {
"type": "http",
"http_version": "1.0",
"method": method,
"path": unquote(path),
"raw_path": raw_path,
"query_string": query_string,
"headers": asgi_headers,
}
instance = ApplicationCommunicator(self.asgi_app, scope)
if post_body:
body = post_body.encode("utf-8")
await instance.send_input({"type": "http.request", "body": body})
else:
await instance.send_input({"type": "http.request"})
# First message back should be response.start with headers and status
messages = []
start = await instance.receive_output(2)
messages.append(start)
assert start["type"] == "http.response.start"
response_headers = MultiParams(
[(k.decode("utf8"), v.decode("utf8")) for k, v in start["headers"]]
headers["content-type"] = content_type
httpx_response = await self.ds.client.request(
method,
path,
allow_redirects=allow_redirects,
cookies=cookies,
headers=headers,
content=post_body,
)
status = start["status"]
# Now loop until we run out of response.body
body = b""
while True:
message = await instance.receive_output(2)
messages.append(message)
assert message["type"] == "http.response.body"
body += message["body"]
if not message.get("more_body"):
break
response = TestResponse(status, response_headers, body)
response = TestResponse(httpx_response)
if allow_redirects and response.status in (301, 302):
assert (
redirect_count < self.max_redirects

View file

@ -113,6 +113,15 @@ class BaseView:
async def options(self, request, *args, **kwargs):
return Response.text("Method not allowed", status=405)
async def put(self, request, *args, **kwargs):
return Response.text("Method not allowed", status=405)
async def patch(self, request, *args, **kwargs):
return Response.text("Method not allowed", status=405)
async def delete(self, request, *args, **kwargs):
return Response.text("Method not allowed", status=405)
async def dispatch_request(self, request, *args, **kwargs):
handler = getattr(self, request.method.lower(), None)
return await handler(request, *args, **kwargs)