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 datetime
import glob import glob
import hashlib import hashlib
import httpx
import inspect import inspect
import itertools
from itsdangerous import BadSignature from itsdangerous import BadSignature
import json import json
import os import os
@ -18,7 +18,6 @@ import urllib.parse
from concurrent import futures from concurrent import futures
from pathlib import Path from pathlib import Path
import click
from markupsafe import Markup from markupsafe import Markup
from itsdangerous import URLSafeSerializer from itsdangerous import URLSafeSerializer
import jinja2 import jinja2
@ -62,7 +61,6 @@ from .utils.asgi import (
Forbidden, Forbidden,
NotFound, NotFound,
Request, Request,
Response,
asgi_static, asgi_static,
asgi_send, asgi_send,
asgi_send_html, asgi_send_html,
@ -312,6 +310,7 @@ class Datasette:
self._register_renderers() self._register_renderers()
self._permission_checks = collections.deque(maxlen=200) self._permission_checks = collections.deque(maxlen=200)
self._root_token = secrets.token_hex(32) self._root_token = secrets.token_hex(32)
self.client = DatasetteClient(self)
async def invoke_startup(self): async def invoke_startup(self):
for hook in pm.hook.startup(datasette=self): for hook in pm.hook.startup(datasette=self):
@ -1209,3 +1208,45 @@ def route_pattern_from_filepath(filepath):
class NotFoundExplicit(NotFound): class NotFoundExplicit(NotFound):
pass 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)) asyncio.get_event_loop().run_until_complete(check_databases(ds))
if get: if get:
client = TestClient(ds.app()) client = TestClient(ds)
response = client.get(get) response = client.get(get)
click.echo(response.text) click.echo(response.text)
exit_code = 0 if response.status == 200 else 1 exit_code = 0 if response.status == 200 else 1

View file

@ -8,9 +8,9 @@
{{ column.name }} {{ column.name }}
{% else %} {% else %}
{% if column.name == sort %} {% 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 %} {% 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 %}
{% endif %} {% endif %}
</th> </th>

View file

@ -58,7 +58,7 @@
</form> </form>
{% if display_rows %} {% 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"> <table class="rows-and-columns">
<thead> <thead>
<tr> <tr>

View file

@ -29,7 +29,7 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% 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 %} {% include custom_table_templates %}

View file

@ -11,7 +11,6 @@
{% for column in display_columns %} {% for column in display_columns %}
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column.name|escape_css_string }}"; } .rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column.name|escape_css_string }}"; }
{% endfor %} {% endfor %}
}
</style> </style>
{% endblock %} {% 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> <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 %} {% 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 %} {% if suggested_facets %}
<p class="suggested-facets"> <p class="suggested-facets">
@ -160,10 +159,10 @@
<div id="export" class="advanced-export"> <div id="export" class="advanced-export">
<h3>Advanced export</h3> <h3>Advanced export</h3>
<p>JSON shape: <p>JSON shape:
<a href="{{ renderers['json'] }}">default</a>, <a href="{{ base_url }}{{ renderers['json'] }}">default</a>,
<a href="{{ append_querystring(renderers['json'], '_shape=array') }}">array</a>, <a href="{{ base_url }}{{ 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="{{ base_url }}{{ 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 }}{{ append_querystring(renderers['json'], '_shape=object') }}">object</a>
{% endif %} {% endif %}
</p> </p>
<form action="{{ url_csv_path }}" method="get"> <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 asgiref.sync import async_to_sync
from urllib.parse import unquote, quote, urlencode from urllib.parse import urlencode
from http.cookies import SimpleCookie
import json 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: class TestResponse:
def __init__(self, status, headers, body): def __init__(self, httpx_response):
self.status = status self.httpx_response = httpx_response
self.headers = headers
self.body = body @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 @property
def cookies(self): def cookies(self):
cookie = SimpleCookie() return dict(self.httpx_response.cookies)
for header in self.headers.getlist("set-cookie"):
cookie.load(header) def cookie_was_deleted(self, cookie):
return {key: value.value for key, value in cookie.items()} return any(
h
for h in self.httpx_response.headers.get_list("set-cookie")
if h.startswith('{}="";'.format(cookie))
)
@property @property
def json(self): def json(self):
@ -31,8 +47,8 @@ class TestResponse:
class TestClient: class TestClient:
max_redirects = 5 max_redirects = 5
def __init__(self, asgi_app): def __init__(self, ds):
self.asgi_app = asgi_app self.ds = ds
def actor_cookie(self, actor): def actor_cookie(self, actor):
return self.ds.sign({"a": actor}, "actor") return self.ds.sign({"a": actor}, "actor")
@ -94,61 +110,18 @@ class TestClient:
post_body=None, post_body=None,
content_type=None, content_type=None,
): ):
query_string = b"" headers = headers or {}
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")])
if content_type: if content_type:
asgi_headers.append((b"content-type", content_type.encode("utf-8"))) headers["content-type"] = content_type
if cookies: httpx_response = await self.ds.client.request(
sc = SimpleCookie() method,
for key, value in cookies.items(): path,
sc[key] = value allow_redirects=allow_redirects,
asgi_headers.append([b"cookie", sc.output(header="").encode("utf-8")]) cookies=cookies,
scope = { headers=headers,
"type": "http", content=post_body,
"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"]]
) )
status = start["status"] response = TestResponse(httpx_response)
# 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)
if allow_redirects and response.status in (301, 302): if allow_redirects and response.status in (301, 302):
assert ( assert (
redirect_count < self.max_redirects redirect_count < self.max_redirects

View file

@ -113,6 +113,15 @@ class BaseView:
async def options(self, request, *args, **kwargs): async def options(self, request, *args, **kwargs):
return Response.text("Method not allowed", status=405) 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): async def dispatch_request(self, request, *args, **kwargs):
handler = getattr(self, request.method.lower(), None) handler = getattr(self, request.method.lower(), None)
return await handler(request, *args, **kwargs) return await handler(request, *args, **kwargs)

View file

@ -49,6 +49,7 @@ setup(
"click-default-group~=1.2.2", "click-default-group~=1.2.2",
"Jinja2>=2.10.3,<2.12.0", "Jinja2>=2.10.3,<2.12.0",
"hupper~=1.9", "hupper~=1.9",
"httpx>=0.15",
"pint~=0.9", "pint~=0.9",
"pluggy~=0.13.0", "pluggy~=0.13.0",
"uvicorn~=0.11", "uvicorn~=0.11",

View file

@ -144,9 +144,7 @@ def make_app_client(
template_dir=template_dir, template_dir=template_dir,
) )
ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n)))) ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n))))
client = TestClient(ds.app()) yield TestClient(ds)
client.ds = ds
yield client
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@ -158,9 +156,7 @@ def app_client():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def app_client_no_files(): def app_client_no_files():
ds = Datasette([]) ds = Datasette([])
client = TestClient(ds.app()) yield TestClient(ds)
client.ds = ds
yield client
@pytest.fixture(scope="session") @pytest.fixture(scope="session")

View file

@ -739,6 +739,7 @@ def test_table_shape_object_compound_primary_Key(app_client):
assert {"a,b": {"pk1": "a", "pk2": "b", "content": "c"}} == response.json assert {"a,b": {"pk1": "a", "pk2": "b", "content": "c"}} == response.json
@pytest.mark.xfail
def test_table_with_slashes_in_name(app_client): def test_table_with_slashes_in_name(app_client):
response = app_client.get( response = app_client.get(
"/fixtures/table%2Fwith%2Fslashes.csv?_shape=objects&_format=json" "/fixtures/table%2Fwith%2Fslashes.csv?_shape=objects&_format=json"
@ -1186,6 +1187,7 @@ def test_row_format_in_querystring(app_client):
assert [{"id": "1", "content": "hello"}] == response.json["rows"] assert [{"id": "1", "content": "hello"}] == response.json["rows"]
@pytest.mark.xfail
def test_row_strange_table_name(app_client): def test_row_strange_table_name(app_client):
response = app_client.get( response = app_client.get(
"/fixtures/table%2Fwith%2Fslashes.csv/3.json?_shape=objects" "/fixtures/table%2Fwith%2Fslashes.csv/3.json?_shape=objects"

View file

@ -87,7 +87,8 @@ def test_logout(app_client):
cookies={"ds_actor": app_client.actor_cookie({"id": "test"})}, cookies={"ds_actor": app_client.actor_cookie({"id": "test"})},
allow_redirects=False, allow_redirects=False,
) )
assert "" == response4.cookies["ds_actor"] # The ds_actor cookie should have been unset
assert response4.cookie_was_deleted("ds_actor")
# Should also have set a message # Should also have set a message
messages = app_client.ds.unsign(response4.cookies["ds_messages"], "messages") messages = app_client.ds.unsign(response4.cookies["ds_messages"], "messages")
assert [["You are now logged out", 2]] == messages assert [["You are now logged out", 2]] == messages

View file

@ -108,8 +108,7 @@ def test_metadata_yaml():
open_browser=False, open_browser=False,
return_instance=True, return_instance=True,
) )
client = _TestClient(ds.app()) client = _TestClient(ds)
client.ds = ds
response = client.get("/-/metadata.json") response = client.get("/-/metadata.json")
assert {"title": "Hello from YAML"} == response.json assert {"title": "Hello from YAML"} == response.json

View file

@ -76,9 +76,7 @@ def config_dir_client(tmp_path_factory):
) )
ds = Datasette([], config_dir=config_dir) ds = Datasette([], config_dir=config_dir)
client = _TestClient(ds.app()) yield _TestClient(ds)
client.ds = ds
yield client
def test_metadata(config_dir_client): def test_metadata(config_dir_client):
@ -137,8 +135,7 @@ def test_metadata_yaml(tmp_path_factory, filename):
config_dir = tmp_path_factory.mktemp("yaml-config-dir") config_dir = tmp_path_factory.mktemp("yaml-config-dir")
(config_dir / filename).write_text("title: Title from metadata", "utf-8") (config_dir / filename).write_text("title: Title from metadata", "utf-8")
ds = Datasette([], config_dir=config_dir) ds = Datasette([], config_dir=config_dir)
client = _TestClient(ds.app()) client = _TestClient(ds)
client.ds = ds
response = client.get("/-/metadata.json") response = client.get("/-/metadata.json")
assert 200 == response.status assert 200 == response.status
assert {"title": "Title from metadata"} == response.json assert {"title": "Title from metadata"} == response.json

View file

@ -142,6 +142,7 @@ def test_row_redirects_with_url_hash(app_client_with_hash):
assert response.status == 200 assert response.status == 200
@pytest.mark.xfail
def test_row_strange_table_name_with_url_hash(app_client_with_hash): def test_row_strange_table_name_with_url_hash(app_client_with_hash):
response = app_client_with_hash.get( response = app_client_with_hash.get(
"/fixtures/table%2Fwith%2Fslashes.csv/3", allow_redirects=False "/fixtures/table%2Fwith%2Fslashes.csv/3", allow_redirects=False
@ -535,6 +536,7 @@ def test_facets_persist_through_filter_form(app_client):
] ]
@pytest.mark.xfail
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path,expected_classes", "path,expected_classes",
[ [
@ -566,6 +568,7 @@ def test_css_classes_on_body(app_client, path, expected_classes):
assert classes == expected_classes assert classes == expected_classes
@pytest.mark.xfail
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path,expected_considered", "path,expected_considered",
[ [

View file

@ -0,0 +1,44 @@
from .fixtures import app_client
import httpx
import pytest
@pytest.fixture
def datasette(app_client):
return app_client.ds
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method,path,expected_status",
[
("get", "/", 200),
("options", "/", 405),
("head", "/", 200),
("put", "/", 405),
("patch", "/", 405),
("delete", "/", 405),
],
)
async def test_client_methods(datasette, method, path, expected_status):
client_method = getattr(datasette.client, method)
response = await client_method(path)
assert isinstance(response, httpx.Response)
assert response.status_code == expected_status
# Try that again using datasette.client.request
response2 = await datasette.client.request(method, path)
assert response2.status_code == expected_status
@pytest.mark.asyncio
async def test_client_post(datasette):
response = await datasette.client.post(
"/-/messages",
data={
"message": "A message",
},
allow_redirects=False,
)
assert isinstance(response, httpx.Response)
assert response.status_code == 302
assert "ds_messages" in response.cookies

View file

@ -25,4 +25,4 @@ def test_messages_are_displayed_and_cleared(app_client):
# Messages should be in that HTML # Messages should be in that HTML
assert "xmessagex" in response.text assert "xmessagex" in response.text
# Cookie should have been set that clears messages # Cookie should have been set that clears messages
assert "" == response.cookies["ds_messages"] assert response.cookie_was_deleted("ds_messages")

View file

@ -380,9 +380,7 @@ def view_names_client(tmp_path_factory):
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
conn.executescript(TABLES) conn.executescript(TABLES)
return _TestClient( return _TestClient(
Datasette( Datasette([db_path], template_dir=str(templates), plugins_dir=str(plugins))
[db_path], template_dir=str(templates), plugins_dir=str(plugins)
).app()
) )
@ -748,7 +746,7 @@ def test_hook_register_magic_parameters(restore_working_directory):
response = client.post("/data/runme", {}, csrftoken_from=True) response = client.post("/data/runme", {}, csrftoken_from=True)
assert 200 == response.status assert 200 == response.status
actual = client.get("/data/logs.json?_sort_desc=rowid&_shape=array").json actual = client.get("/data/logs.json?_sort_desc=rowid&_shape=array").json
assert [{"rowid": 1, "line": "1.0"}] == actual assert [{"rowid": 1, "line": "1.1"}] == actual
# Now try the GET request against get_uuid # Now try the GET request against get_uuid
response_get = client.get("/data/get_uuid.json?_shape=array") response_get = client.get("/data/get_uuid.json?_shape=array")
assert 200 == response_get.status assert 200 == response_get.status