diff --git a/datasette/views/table.py b/datasette/views/table.py index ed3b1276..69d5a075 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -2318,6 +2318,7 @@ async def table_view_data( data = { "ok": True, "next": next_value and str(next_value) or None, + "next_url": next_url, } data.update( await resolve_table_extras( diff --git a/docs/json_api.rst b/docs/json_api.rst index 0069055b..dc611a39 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -48,7 +48,7 @@ The ``"rows"`` key is a list of objects, each one representing a row. The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting). -For table pages, an additional key ``"next"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``. +For table pages, two additional keys are present: ``"next"``, an opaque token that can be used to retrieve the next page using ``?_next=TOKEN``, and ``"next_url"``, the full URL of that next page. Both are ``null`` on the final page. See :ref:`json_api_pagination`. .. _json_api_errors: @@ -128,6 +128,7 @@ options: { "ok": true, "next": null, + "next_url": null, "rows": [ [3, "Detroit"], [2, "Los Angeles"], diff --git a/existing-api.md b/existing-api.md index cf456921..f7f161f3 100644 --- a/existing-api.md +++ b/existing-api.md @@ -647,6 +647,7 @@ dispatched to `QueryView` (views/table.py:1703-1712). |---|---| | `ok` | `true` when data was retrieved without error | | `next` | pagination token string, or `null` on the last page | +| `next_url` | absolute URL of the next page, or `null` on the last page | | `rows` | list of row objects `{column: value}` (default `_shape=objects`) | | `truncated` | always present; `false` for table pages | diff --git a/stable-api-recommendations.md b/stable-api-recommendations.md index 1bc2412e..d3d5b6a6 100644 --- a/stable-api-recommendations.md +++ b/stable-api-recommendations.md @@ -196,7 +196,14 @@ number. --- -## 3. Pagination: three mechanisms, two contracts (P2) +## 3. Pagination: three mechanisms, two contracts (P2) — partially implemented + +> **Status:** `next_url` now accompanies `next` in the default table JSON +> keys (previously it required `?_extra=next_url`), so every response with +> a `next` token also carries the ready-to-follow URL. Pagination tokens +> are deliberately left undocumented as to their internal structure. The +> `_size`/`page_size` naming and `has_more`/`total` differences remain +> open. | Endpoint | Mechanism | Token | Extras | |---|---|---|---| diff --git a/tests/test_table_api.py b/tests/test_table_api.py index ff413be8..0a593ff8 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1491,6 +1491,7 @@ async def test_col_nocol_errors(ds_client, path, expected_error): { "ok": True, "next": None, + "next_url": None, "columns": ["id", "content", "content2"], "rows": [{"id": "1", "content": "hey", "content2": "world"}], "truncated": False, @@ -1501,6 +1502,7 @@ async def test_col_nocol_errors(ds_client, path, expected_error): { "ok": True, "next": None, + "next_url": None, "rows": [{"id": "1", "content": "hey", "content2": "world"}], "truncated": False, "count": 1, @@ -1627,3 +1629,22 @@ async def test_count_truncated_included_with_count_extra(tmp_path_factory): assert response.json()["count_truncated"] is True finally: ds.close() + + +@pytest.mark.asyncio +async def test_next_url_included_by_default(ds_client): + response = await ds_client.get("/fixtures/compound_three_primary_keys.json") + data = response.json() + assert data["next"] is not None + assert data["next_url"].endswith( + "/fixtures/compound_three_primary_keys.json?_next=" + + urllib.parse.quote(data["next"], safe="") + ) + # Follow to the last page - next and next_url are both null there + while data["next"]: + response = await ds_client.get( + "/fixtures/compound_three_primary_keys.json?_next=" + data["next"] + ) + data = response.json() + assert data["next"] is None + assert data["next_url"] is None