mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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:
parent
7249ac5ca0
commit
8f97b9b58e
18 changed files with 163 additions and 100 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }} ▼</a>
|
||||
<a href="{{ base_url }}{{ path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None}) }}" rel="nofollow">{{ column.name }} ▼</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 %} ▲{% 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 %} ▲{% endif %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</th>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue