diff --git a/README.md b/README.md
index 06ddfaa2..568561a8 100644
--- a/README.md
+++ b/README.md
@@ -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:
{
...
diff --git a/datasette/app.py b/datasette/app.py
index 8e7cfa07..64aabe0c 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -221,8 +221,20 @@ class BaseView(RenderMixin):
if value:
data[key] = value
if as_json:
- # Special case for .jsono extension
+ # Special case for .jsono extension - redirect to _shape=objects
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')
rows = data.get('rows')
if rows and columns:
@@ -230,6 +242,28 @@ class BaseView(RenderMixin):
dict(zip(columns, row))
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 = {}
if self.ds.cors:
headers['Access-Control-Allow-Origin'] = '*'
@@ -278,6 +312,8 @@ class BaseView(RenderMixin):
params = request.raw_args
if 'sql' in params:
params.pop('sql')
+ if '_shape' in params:
+ params.pop('_shape')
# Extract any :named parameters
named_parameters = self.re_named_parameter.findall(sql)
named_parameter_values = {
diff --git a/datasette/templates/query.html b/datasette/templates/query.html
index fa850736..62bf41da 100644
--- a/datasette/templates/query.html
+++ b/datasette/templates/query.html
@@ -40,7 +40,7 @@
{% if rows %}
-
This data as .json, .jsono
+This data as .json
diff --git a/datasette/templates/row.html b/datasette/templates/row.html
index 53cbd15d..2217a4dc 100644
--- a/datasette/templates/row.html
+++ b/datasette/templates/row.html
@@ -22,7 +22,7 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
-This data as .json, .jsono
+This data as .json
{% include custom_rows_and_columns_templates %}
diff --git a/datasette/templates/table.html b/datasette/templates/table.html
index 70262bc7..546e852f 100644
--- a/datasette/templates/table.html
+++ b/datasette/templates/table.html
@@ -74,7 +74,7 @@
✎ View and edit SQL
{% endif %}
-This data as .json, .jsono
+This data as .json
{% include custom_rows_and_columns_templates %}
diff --git a/datasette/utils.py b/datasette/utils.py
index 8a185957..c674349b 100644
--- a/datasette/utils.py
+++ b/datasette/utils.py
@@ -134,7 +134,8 @@ def validate_sql_select(sql):
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):
args = args.items()
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))
if query_string:
query_string = '?{}'.format(query_string)
- return request.path + query_string
+ return path + query_string
def path_with_ext(request, ext):
diff --git a/docs/getting_started.rst b/docs/getting_started.rst
index 5b719b0a..f8f3fa52 100644
--- a/docs/getting_started.rst
+++ b/docs/getting_started.rst
@@ -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:
::
diff --git a/docs/json_api.rst b/docs/json_api.rst
new file mode 100644
index 00000000..6e18f01a
--- /dev/null
+++ b/docs/json_api.rst
@@ -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.
diff --git a/tests/test_api.py b/tests/test_api.py
index de3cc0c6..733727c6 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -118,7 +118,7 @@ def test_database_page(app_client):
def test_custom_sql(app_client):
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
)
data = response.json
@@ -138,7 +138,7 @@ def test_custom_sql(app_client):
def test_sql_time_limit(app_client):
response = app_client.get(
- '/test_tables.jsono?sql=select+sleep(0.5)',
+ '/test_tables.json?sql=select+sleep(0.5)',
gather_request=False
)
assert 400 == response.status
@@ -147,12 +147,12 @@ def test_sql_time_limit(app_client):
def test_custom_sql_time_limit(app_client):
response = app_client.get(
- '/test_tables.jsono?sql=select+sleep(0.01)',
+ '/test_tables.json?sql=select+sleep(0.01)',
gather_request=False
)
assert 200 == response.status
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
)
assert 400 == response.status
@@ -170,7 +170,7 @@ def test_invalid_custom_sql(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
data = response.json
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):
- 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
data = response.json
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):
- 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
data = response.json
assert data['rows'] == [{
@@ -210,9 +289,9 @@ def test_table_with_reserved_word_name(app_client):
@pytest.mark.parametrize('path,expected_rows,expected_pages', [
- ('/test_tables/no_primary_key.jsono', 201, 5),
- ('/test_tables/paginated_view.jsono', 201, 5),
- ('/test_tables/123_starts_with_digits.jsono', 0, 1),
+ ('/test_tables/no_primary_key.json', 201, 5),
+ ('/test_tables/paginated_view.json', 201, 5),
+ ('/test_tables/123_starts_with_digits.json', 0, 1),
])
def test_paginate_tables_and_views(app_client, path, expected_rows, expected_pages):
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):
fetched = []
- path = '/test_tables/compound_three_primary_keys.jsono'
+ path = '/test_tables/compound_three_primary_keys.json?_shape=objects'
page = 0
while path:
page += 1
@@ -250,7 +329,7 @@ def test_paginate_compound_keys(app_client):
def test_paginate_compound_keys_with_extra_filters(app_client):
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
while path:
page += 1
@@ -289,7 +368,7 @@ def test_table_filter_queries(app_client, path, expected_rows):
def test_max_returned_rows(app_client):
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
)
data = response.json
@@ -302,7 +381,7 @@ def test_max_returned_rows(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
data = response.json
assert data['rows'] == [{
@@ -318,7 +397,7 @@ def test_view(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 [{'pk': '1', 'content': 'hello'}] == response.json['rows']