Link rel=alternate header for tables and rows

Also added Access-Control-Expose-Headers: Link to --cors mode.

Closes #1533

Refs https://github.com/simonw/datasette-notebook/issues/2

LL#	metadata.json.1
This commit is contained in:
Simon Willison 2021-11-27 12:08:42 -08:00
commit 3ef47a0896
9 changed files with 86 additions and 6 deletions

View file

@ -10,7 +10,7 @@
{% for url in extra_js_urls %} {% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script> <script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
{% endfor %} {% endfor %}
{% block extra_head %}{% endblock %} {%- block extra_head %}{% endblock -%}
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
<div class="not-footer"> <div class="not-footer">

View file

@ -3,7 +3,8 @@
{% block title %}{{ database }}: {{ table }}{% endblock %} {% block title %}{{ database }}: {{ table }}{% endblock %}
{% block extra_head %} {% block extra_head %}
{{ super() }} {{- super() -}}
<link rel="alternate" type="application/json+datasette" href="{{ alternate_url_json }}">
<style> <style>
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {
{% for column in columns %} {% for column in columns %}

View file

@ -3,7 +3,8 @@
{% block title %}{{ database }}: {{ table }}: {% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} row{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %} {% block title %}{{ database }}: {{ table }}: {% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} row{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block extra_head %} {% block extra_head %}
{{ super() }} {{- super() -}}
<link rel="alternate" type="application/json+datasette" href="{{ alternate_url_json }}">
<script src="{{ urls.static('table.js') }}" defer></script> <script src="{{ urls.static('table.js') }}" defer></script>
<style> <style>
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {

View file

@ -1094,3 +1094,4 @@ async def derive_named_parameters(db, sql):
def add_cors_headers(headers): def add_cors_headers(headers):
headers["Access-Control-Allow-Origin"] = "*" headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Headers"] = "Authorization" headers["Access-Control-Allow-Headers"] = "Authorization"
headers["Access-Control-Expose-Headers"] = "Link"

View file

@ -137,10 +137,18 @@ class BaseView:
], ],
}, },
} }
# Hacky cheat to add extra headers
headers = {}
if "_extra_headers" in context:
headers.update(context["_extra_headers"])
return Response.html( return Response.html(
await self.ds.render_template( await self.ds.render_template(
template, template_context, request=request, view_name=self.name template,
) template_context,
request=request,
view_name=self.name,
),
headers=headers,
) )
@classmethod @classmethod

View file

@ -17,6 +17,7 @@ from datasette.utils import (
is_url, is_url,
path_from_row_pks, path_from_row_pks,
path_with_added_args, path_with_added_args,
path_with_format,
path_with_removed_args, path_with_removed_args,
path_with_replaced_args, path_with_replaced_args,
to_css_class, to_css_class,
@ -850,7 +851,12 @@ class TableView(RowTableShared):
for table_column in table_columns for table_column in table_columns
if table_column not in columns if table_column not in columns
] ]
alternate_url_json = self.ds.absolute_url(
request,
self.ds.urls.path(path_with_format(request=request, format="json")),
)
d = { d = {
"alternate_url_json": alternate_url_json,
"table_actions": table_actions, "table_actions": table_actions,
"use_rowid": use_rowid, "use_rowid": use_rowid,
"filters": filters, "filters": filters,
@ -881,6 +887,11 @@ class TableView(RowTableShared):
"metadata": metadata, "metadata": metadata,
"view_definition": await db.get_view_definition(table), "view_definition": await db.get_view_definition(table),
"table_definition": await db.get_table_definition(table), "table_definition": await db.get_table_definition(table),
"_extra_headers": {
"Link": '{}; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
},
} }
d.update(extra_context_from_filters) d.update(extra_context_from_filters)
return d return d
@ -964,8 +975,12 @@ class RowView(RowTableShared):
) )
for column in display_columns: for column in display_columns:
column["sortable"] = False column["sortable"] = False
alternate_url_json = self.ds.absolute_url(
request,
self.ds.urls.path(path_with_format(request=request, format="json")),
)
return { return {
"alternate_url_json": alternate_url_json,
"foreign_key_tables": await self.foreign_key_tables( "foreign_key_tables": await self.foreign_key_tables(
database, table, pk_values database, table, pk_values
), ),
@ -980,6 +995,11 @@ class RowView(RowTableShared):
.get(database, {}) .get(database, {})
.get("tables", {}) .get("tables", {})
.get(table, {}), .get(table, {}),
"_extra_headers": {
"Link": '{}; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
},
} }
data = { data = {

View file

@ -14,6 +14,7 @@ served with the following additional HTTP headers::
Access-Control-Allow-Origin: * Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Authorization Access-Control-Allow-Headers: Authorization
Access-Control-Expose-Headers: Link
This means JavaScript running on any domain will be able to make cross-origin This means JavaScript running on any domain will be able to make cross-origin
requests to fetch the data. requests to fetch the data.
@ -435,3 +436,22 @@ looks like::
The column in the foreign key table that is used for the label can be specified The column in the foreign key table that is used for the label can be specified
in ``metadata.json`` - see :ref:`label_columns`. in ``metadata.json`` - see :ref:`label_columns`.
.. _json_api_discover_alternate:
Discovering the JSON for a page
-------------------------------
The :ref:`table <TableView>` and :ref:`row <RowView>` HTML pages both provide a mechanism for discovering their JSON equivalents using the HTML ``link`` mechanism.
You can find this near the top of those pages, looking like this:
.. code-block:: python
<link rel="alternate"
type="application/json+datasette"
href="https://latest.datasette.io/fixtures/sortable.json">
The JSON URL is also made available in a ``Link`` HTTP header for the page::
Link: https://latest.datasette.io/fixtures/sortable.json; rel="alternate"; type="application/json+datasette"

View file

@ -977,6 +977,7 @@ def test_cors(app_client_with_cors, path, status_code):
assert response.status == status_code assert response.status == status_code
assert response.headers["Access-Control-Allow-Origin"] == "*" assert response.headers["Access-Control-Allow-Origin"] == "*"
assert response.headers["Access-Control-Allow-Headers"] == "Authorization" assert response.headers["Access-Control-Allow-Headers"] == "Authorization"
assert response.headers["Access-Control-Expose-Headers"] == "Link"
@pytest.mark.parametrize( @pytest.mark.parametrize(

View file

@ -1069,3 +1069,31 @@ def test_table_page_title(app_client, path, expected):
response = app_client.get(path) response = app_client.get(path)
title = Soup(response.text, "html.parser").find("title").text title = Soup(response.text, "html.parser").find("title").text
assert title == expected assert title == expected
@pytest.mark.parametrize(
"path,expected",
(
(
"/fixtures/table%2Fwith%2Fslashes.csv",
"http://localhost/fixtures/table%2Fwith%2Fslashes.csv?_format=json",
),
("/fixtures/facetable", "http://localhost/fixtures/facetable.json"),
(
"/fixtures/no_primary_key/1",
"http://localhost/fixtures/no_primary_key/1.json",
),
),
)
def test_alternate_url_json(app_client, path, expected):
response = app_client.get(path)
link = response.headers["link"]
assert link == '{}; rel="alternate"; type="application/json+datasette"'.format(
expected
)
assert (
'<link rel="alternate" type="application/json+datasette" href="{}">'.format(
expected
)
in response.text
)