mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
New ?_shape=objects/object/lists param for JSON API (#192)
New _shape= parameter replacing old .jsono extension
Now instead of this:
/database/table.jsono
We use the _shape parameter like this:
/database/table.json?_shape=objects
Also introduced a new _shape called 'object' which looks like this:
/database/table.json?_shape=object
Returning an object for the rows key:
...
"rows": {
"pk1": {
...
},
"pk2": {
...
}
}
Refs #122
This commit is contained in:
parent
dd0566ff8e
commit
0abd3abacb
9 changed files with 244 additions and 23 deletions
|
|
@ -73,7 +73,7 @@ http://localhost:8001/History/downloads.json will return that data as JSON:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
http://localhost:8001/History/downloads.jsono will return that data as JSON in a more convenient but less efficient format:
|
http://localhost:8001/History/downloads.json?_shape=objects will return that data as JSON in a more convenient but less efficient format:
|
||||||
|
|
||||||
{
|
{
|
||||||
...
|
...
|
||||||
|
|
|
||||||
|
|
@ -221,8 +221,20 @@ class BaseView(RenderMixin):
|
||||||
if value:
|
if value:
|
||||||
data[key] = value
|
data[key] = value
|
||||||
if as_json:
|
if as_json:
|
||||||
# Special case for .jsono extension
|
# Special case for .jsono extension - redirect to _shape=objects
|
||||||
if as_json == '.jsono':
|
if as_json == '.jsono':
|
||||||
|
return self.redirect(
|
||||||
|
request,
|
||||||
|
path_with_added_args(
|
||||||
|
request,
|
||||||
|
{'_shape': 'objects'},
|
||||||
|
path=request.path.rsplit('.jsono', 1)[0] + '.json'
|
||||||
|
),
|
||||||
|
forward_querystring=False
|
||||||
|
)
|
||||||
|
# Deal with the _shape option
|
||||||
|
shape = request.args.get('_shape', 'lists')
|
||||||
|
if shape in ('objects', 'object'):
|
||||||
columns = data.get('columns')
|
columns = data.get('columns')
|
||||||
rows = data.get('rows')
|
rows = data.get('rows')
|
||||||
if rows and columns:
|
if rows and columns:
|
||||||
|
|
@ -230,6 +242,28 @@ class BaseView(RenderMixin):
|
||||||
dict(zip(columns, row))
|
dict(zip(columns, row))
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
if shape == 'object':
|
||||||
|
error = None
|
||||||
|
if 'primary_keys' not in data:
|
||||||
|
error = '_shape=object is only available on tables'
|
||||||
|
else:
|
||||||
|
pks = data['primary_keys']
|
||||||
|
if not pks:
|
||||||
|
error = '_shape=object not available for tables with no primary keys'
|
||||||
|
else:
|
||||||
|
object_rows = {}
|
||||||
|
for row in data['rows']:
|
||||||
|
pk_string = path_from_row_pks(row, pks, not pks)
|
||||||
|
object_rows[pk_string] = row
|
||||||
|
data['rows'] = object_rows
|
||||||
|
if error:
|
||||||
|
data = {
|
||||||
|
'ok': False,
|
||||||
|
'error': error,
|
||||||
|
'database': name,
|
||||||
|
'database_hash': hash,
|
||||||
|
}
|
||||||
|
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
headers['Access-Control-Allow-Origin'] = '*'
|
headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
|
@ -278,6 +312,8 @@ class BaseView(RenderMixin):
|
||||||
params = request.raw_args
|
params = request.raw_args
|
||||||
if 'sql' in params:
|
if 'sql' in params:
|
||||||
params.pop('sql')
|
params.pop('sql')
|
||||||
|
if '_shape' in params:
|
||||||
|
params.pop('_shape')
|
||||||
# Extract any :named parameters
|
# Extract any :named parameters
|
||||||
named_parameters = self.re_named_parameter.findall(sql)
|
named_parameters = self.re_named_parameter.findall(sql)
|
||||||
named_parameter_values = {
|
named_parameter_values = {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<p>This data as <a href="{{ url_json }}">.json</a>, <a href="{{ url_jsono }}">.jsono</a></p>
|
<p>This data as <a href="{{ url_json }}">.json</a></p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,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 <a href="{{ url_json }}">.json</a>, <a href="{{ url_jsono }}">.jsono</a></p>
|
<p>This data as <a href="{{ url_json }}">.json</a></p>
|
||||||
|
|
||||||
{% include custom_rows_and_columns_templates %}
|
{% include custom_rows_and_columns_templates %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
<p><a class="not-underlined" title="{{ query.sql }}" href="/{{ database }}-{{ database_hash }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
<p><a class="not-underlined" title="{{ query.sql }}" href="/{{ database }}-{{ database_hash }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>This data as <a href="{{ url_json }}">.json</a>, <a href="{{ url_jsono }}">.jsono</a></p>
|
<p>This data as <a href="{{ url_json }}">.json</a></p>
|
||||||
|
|
||||||
{% include custom_rows_and_columns_templates %}
|
{% include custom_rows_and_columns_templates %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,8 @@ def validate_sql_select(sql):
|
||||||
raise InvalidSql(msg)
|
raise InvalidSql(msg)
|
||||||
|
|
||||||
|
|
||||||
def path_with_added_args(request, args):
|
def path_with_added_args(request, args, path=None):
|
||||||
|
path = path or request.path
|
||||||
if isinstance(args, dict):
|
if isinstance(args, dict):
|
||||||
args = args.items()
|
args = args.items()
|
||||||
arg_keys = set(a[0] for a in args)
|
arg_keys = set(a[0] for a in args)
|
||||||
|
|
@ -151,7 +152,7 @@ def path_with_added_args(request, args):
|
||||||
query_string = urllib.parse.urlencode(sorted(current))
|
query_string = urllib.parse.urlencode(sorted(current))
|
||||||
if query_string:
|
if query_string:
|
||||||
query_string = '?{}'.format(query_string)
|
query_string = '?{}'.format(query_string)
|
||||||
return request.path + query_string
|
return path + query_string
|
||||||
|
|
||||||
|
|
||||||
def path_with_ext(request, ext):
|
def path_with_ext(request, ext):
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ JSON:
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
http://localhost:8001/History/downloads.jsono will return that data as
|
http://localhost:8001/History/downloads.json?_shape=objects will return that data as
|
||||||
JSON in a more convenient but less efficient format:
|
JSON in a more convenient but less efficient format:
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
|
||||||
105
docs/json_api.rst
Normal file
105
docs/json_api.rst
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
The Datasette JSON API
|
||||||
|
======================
|
||||||
|
|
||||||
|
Datasette provides a JSON API for your SQLite databases. Anything you can do
|
||||||
|
through the Datasette user interface can also be accessed as JSON via the API.
|
||||||
|
|
||||||
|
To access the API for a page, either click on the ``.json`` link on that page or
|
||||||
|
edit the URL and add a ``.json`` extension to it.
|
||||||
|
|
||||||
|
If you started Datasette with the ``--cors`` option, each JSON endpoint will be
|
||||||
|
served with the following additional HTTP header::
|
||||||
|
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
|
||||||
|
This means JavaScript running on any domain will be able to make cross-origin
|
||||||
|
requests to fetch the data.
|
||||||
|
|
||||||
|
If you start Datasette without the ``--cors`` option only JavaScript running on
|
||||||
|
the same domain as Datasette will be able to access the API.
|
||||||
|
|
||||||
|
Different shapes
|
||||||
|
----------------
|
||||||
|
|
||||||
|
The default JSON representation of data from a SQLite table or custom query
|
||||||
|
looks like this::
|
||||||
|
|
||||||
|
{
|
||||||
|
"database": "sf-trees",
|
||||||
|
"table": "qSpecies",
|
||||||
|
"columns": [
|
||||||
|
"id",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
"Myoporum laetum :: Myoporum"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
"Metrosideros excelsa :: New Zealand Xmas Tree"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
"Pinus radiata :: Monterey Pine"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"truncated": false,
|
||||||
|
"next": "100",
|
||||||
|
"next_url": "http://127.0.0.1:8001/sf-trees-02c8ef1/qSpecies.json?_next=100",
|
||||||
|
"query_ms": 1.9571781158447266
|
||||||
|
}
|
||||||
|
|
||||||
|
The ``columns`` key lists the columns that are being returned, and the ``rows``
|
||||||
|
key then returns a list of lists, each one representing a row. The order of the
|
||||||
|
values in each row corresponds to the columns.
|
||||||
|
|
||||||
|
The ``_shape`` parameter can be used to access alternative formats for the
|
||||||
|
``rows`` key which may be more convenient for your application. There are three
|
||||||
|
options:
|
||||||
|
|
||||||
|
* ``?_shape=lists`` - the default option, shown above
|
||||||
|
* ``?_shape=objects`` - a list of JSON key/value objects
|
||||||
|
* ``?_shape=object`` - a JSON object keyed using the primary keys of the rows
|
||||||
|
|
||||||
|
``objects`` looks like this::
|
||||||
|
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"value": "Myoporum laetum :: Myoporum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"value": "Metrosideros excelsa :: New Zealand Xmas Tree"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"value": "Pinus radiata :: Monterey Pine"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
``object`` looks like this::
|
||||||
|
|
||||||
|
"rows": {
|
||||||
|
"1": {
|
||||||
|
"id": 1,
|
||||||
|
"value": "Myoporum laetum :: Myoporum"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"id": 2,
|
||||||
|
"value": "Metrosideros excelsa :: New Zealand Xmas Tree"
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"id": 3,
|
||||||
|
"value": "Pinus radiata :: Monterey Pine"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
The ``object`` shape is only available for queries against tables - custom SQL
|
||||||
|
queries and views do not have an obvious primary key so cannot be returned using
|
||||||
|
this format.
|
||||||
|
|
||||||
|
The ``object`` keys are always strings. If your table has a compound primary
|
||||||
|
key, the ``object`` keys will be a comma-separated string.
|
||||||
|
|
@ -118,7 +118,7 @@ def test_database_page(app_client):
|
||||||
|
|
||||||
def test_custom_sql(app_client):
|
def test_custom_sql(app_client):
|
||||||
response = app_client.get(
|
response = app_client.get(
|
||||||
'/test_tables.jsono?sql=select+content+from+simple_primary_key',
|
'/test_tables.json?sql=select+content+from+simple_primary_key&_shape=objects',
|
||||||
gather_request=False
|
gather_request=False
|
||||||
)
|
)
|
||||||
data = response.json
|
data = response.json
|
||||||
|
|
@ -138,7 +138,7 @@ def test_custom_sql(app_client):
|
||||||
|
|
||||||
def test_sql_time_limit(app_client):
|
def test_sql_time_limit(app_client):
|
||||||
response = app_client.get(
|
response = app_client.get(
|
||||||
'/test_tables.jsono?sql=select+sleep(0.5)',
|
'/test_tables.json?sql=select+sleep(0.5)',
|
||||||
gather_request=False
|
gather_request=False
|
||||||
)
|
)
|
||||||
assert 400 == response.status
|
assert 400 == response.status
|
||||||
|
|
@ -147,12 +147,12 @@ def test_sql_time_limit(app_client):
|
||||||
|
|
||||||
def test_custom_sql_time_limit(app_client):
|
def test_custom_sql_time_limit(app_client):
|
||||||
response = app_client.get(
|
response = app_client.get(
|
||||||
'/test_tables.jsono?sql=select+sleep(0.01)',
|
'/test_tables.json?sql=select+sleep(0.01)',
|
||||||
gather_request=False
|
gather_request=False
|
||||||
)
|
)
|
||||||
assert 200 == response.status
|
assert 200 == response.status
|
||||||
response = app_client.get(
|
response = app_client.get(
|
||||||
'/test_tables.jsono?sql=select+sleep(0.01)&_sql_time_limit_ms=5',
|
'/test_tables.json?sql=select+sleep(0.01)&_sql_time_limit_ms=5',
|
||||||
gather_request=False
|
gather_request=False
|
||||||
)
|
)
|
||||||
assert 400 == response.status
|
assert 400 == response.status
|
||||||
|
|
@ -170,7 +170,7 @@ def test_invalid_custom_sql(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_table_json(app_client):
|
def test_table_json(app_client):
|
||||||
response = app_client.get('/test_tables/simple_primary_key.jsono', gather_request=False)
|
response = app_client.get('/test_tables/simple_primary_key.json?_shape=objects', gather_request=False)
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
data = response.json
|
data = response.json
|
||||||
assert data['query']['sql'] == 'select * from simple_primary_key order by pk limit 51'
|
assert data['query']['sql'] == 'select * from simple_primary_key order by pk limit 51'
|
||||||
|
|
@ -187,8 +187,87 @@ def test_table_json(app_client):
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jsono_redirects_to_shape_objects(app_client):
|
||||||
|
response_1 = app_client.get(
|
||||||
|
'/test_tables/simple_primary_key.jsono',
|
||||||
|
allow_redirects=False,
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
response = app_client.get(
|
||||||
|
response_1.headers['Location'],
|
||||||
|
allow_redirects=False,
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
assert response.status == 302
|
||||||
|
assert response.headers['Location'].endswith('?_shape=objects')
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_shape_lists(app_client):
|
||||||
|
response = app_client.get(
|
||||||
|
'/test_tables/simple_primary_key.json?_shape=lists',
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
assert [
|
||||||
|
['1', 'hello'],
|
||||||
|
['2', 'world'],
|
||||||
|
['3', ''],
|
||||||
|
] == response.json['rows']
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_shape_objects(app_client):
|
||||||
|
response = app_client.get(
|
||||||
|
'/test_tables/simple_primary_key.json?_shape=objects',
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
assert [{
|
||||||
|
'pk': '1',
|
||||||
|
'content': 'hello',
|
||||||
|
}, {
|
||||||
|
'pk': '2',
|
||||||
|
'content': 'world',
|
||||||
|
}, {
|
||||||
|
'pk': '3',
|
||||||
|
'content': '',
|
||||||
|
}] == response.json['rows']
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_shape_object(app_client):
|
||||||
|
response = app_client.get(
|
||||||
|
'/test_tables/simple_primary_key.json?_shape=object',
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
assert {
|
||||||
|
'1': {
|
||||||
|
'pk': '1',
|
||||||
|
'content': 'hello',
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
'pk': '2',
|
||||||
|
'content': 'world',
|
||||||
|
},
|
||||||
|
'3': {
|
||||||
|
'pk': '3',
|
||||||
|
'content': '',
|
||||||
|
}
|
||||||
|
} == response.json['rows']
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_shape_object_compound_primary_Key(app_client):
|
||||||
|
response = app_client.get(
|
||||||
|
'/test_tables/compound_primary_key.json?_shape=object',
|
||||||
|
gather_request=False
|
||||||
|
)
|
||||||
|
assert {
|
||||||
|
'a,b': {
|
||||||
|
'pk1': 'a',
|
||||||
|
'pk2': 'b',
|
||||||
|
'content': 'c',
|
||||||
|
}
|
||||||
|
} == response.json['rows']
|
||||||
|
|
||||||
|
|
||||||
def test_table_with_slashes_in_name(app_client):
|
def test_table_with_slashes_in_name(app_client):
|
||||||
response = app_client.get('/test_tables/table%2Fwith%2Fslashes.csv.jsono', gather_request=False)
|
response = app_client.get('/test_tables/table%2Fwith%2Fslashes.csv.json?_shape=objects', gather_request=False)
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
data = response.json
|
data = response.json
|
||||||
assert data['rows'] == [{
|
assert data['rows'] == [{
|
||||||
|
|
@ -198,7 +277,7 @@ def test_table_with_slashes_in_name(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_table_with_reserved_word_name(app_client):
|
def test_table_with_reserved_word_name(app_client):
|
||||||
response = app_client.get('/test_tables/select.jsono', gather_request=False)
|
response = app_client.get('/test_tables/select.json?_shape=objects', gather_request=False)
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
data = response.json
|
data = response.json
|
||||||
assert data['rows'] == [{
|
assert data['rows'] == [{
|
||||||
|
|
@ -210,9 +289,9 @@ def test_table_with_reserved_word_name(app_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('path,expected_rows,expected_pages', [
|
@pytest.mark.parametrize('path,expected_rows,expected_pages', [
|
||||||
('/test_tables/no_primary_key.jsono', 201, 5),
|
('/test_tables/no_primary_key.json', 201, 5),
|
||||||
('/test_tables/paginated_view.jsono', 201, 5),
|
('/test_tables/paginated_view.json', 201, 5),
|
||||||
('/test_tables/123_starts_with_digits.jsono', 0, 1),
|
('/test_tables/123_starts_with_digits.json', 0, 1),
|
||||||
])
|
])
|
||||||
def test_paginate_tables_and_views(app_client, path, expected_rows, expected_pages):
|
def test_paginate_tables_and_views(app_client, path, expected_rows, expected_pages):
|
||||||
fetched = []
|
fetched = []
|
||||||
|
|
@ -232,7 +311,7 @@ def test_paginate_tables_and_views(app_client, path, expected_rows, expected_pag
|
||||||
|
|
||||||
def test_paginate_compound_keys(app_client):
|
def test_paginate_compound_keys(app_client):
|
||||||
fetched = []
|
fetched = []
|
||||||
path = '/test_tables/compound_three_primary_keys.jsono'
|
path = '/test_tables/compound_three_primary_keys.json?_shape=objects'
|
||||||
page = 0
|
page = 0
|
||||||
while path:
|
while path:
|
||||||
page += 1
|
page += 1
|
||||||
|
|
@ -250,7 +329,7 @@ def test_paginate_compound_keys(app_client):
|
||||||
|
|
||||||
def test_paginate_compound_keys_with_extra_filters(app_client):
|
def test_paginate_compound_keys_with_extra_filters(app_client):
|
||||||
fetched = []
|
fetched = []
|
||||||
path = '/test_tables/compound_three_primary_keys.jsono?content__contains=d'
|
path = '/test_tables/compound_three_primary_keys.json?content__contains=d&_shape=objects'
|
||||||
page = 0
|
page = 0
|
||||||
while path:
|
while path:
|
||||||
page += 1
|
page += 1
|
||||||
|
|
@ -289,7 +368,7 @@ def test_table_filter_queries(app_client, path, expected_rows):
|
||||||
|
|
||||||
def test_max_returned_rows(app_client):
|
def test_max_returned_rows(app_client):
|
||||||
response = app_client.get(
|
response = app_client.get(
|
||||||
'/test_tables.jsono?sql=select+content+from+no_primary_key',
|
'/test_tables.json?sql=select+content+from+no_primary_key',
|
||||||
gather_request=False
|
gather_request=False
|
||||||
)
|
)
|
||||||
data = response.json
|
data = response.json
|
||||||
|
|
@ -302,7 +381,7 @@ def test_max_returned_rows(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_view(app_client):
|
def test_view(app_client):
|
||||||
response = app_client.get('/test_tables/simple_view.jsono', gather_request=False)
|
response = app_client.get('/test_tables/simple_view.json?_shape=objects', gather_request=False)
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
data = response.json
|
data = response.json
|
||||||
assert data['rows'] == [{
|
assert data['rows'] == [{
|
||||||
|
|
@ -318,7 +397,7 @@ def test_view(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_row(app_client):
|
def test_row(app_client):
|
||||||
response = app_client.get('/test_tables/simple_primary_key/1.jsono', gather_request=False)
|
response = app_client.get('/test_tables/simple_primary_key/1.json?_shape=objects', gather_request=False)
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert [{'pk': '1', 'content': 'hello'}] == response.json['rows']
|
assert [{'pk': '1', 'content': 'hello'}] == response.json['rows']
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue