From 78af1adfa4e6c703a4ade5ee226efa4e444ca1e5 Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Mon, 25 Nov 2024 09:47:35 -0800 Subject: [PATCH] Move LinkData and rename it to PaginatedLinkData Move `LinkData` to the `safir.database` module, since at present it's only useful for extracting pagination information in the form used there, and rename it to `PaginatedLinkData`. --- changelog.d/20241122_150037_rra_DM_47769a.md | 2 +- docs/user-guide/database/pagination.rst | 8 +-- safir/src/safir/database/__init__.py | 2 + safir/src/safir/database/_pagination.py | 48 +++++++++++++++++ safir/src/safir/models/__init__.py | 2 - safir/src/safir/models/_link.py | 55 -------------------- safir/tests/database_test.py | 42 +++++++++++++++ safir/tests/models_test.py | 43 +-------------- 8 files changed, 98 insertions(+), 104 deletions(-) delete mode 100644 safir/src/safir/models/_link.py diff --git a/changelog.d/20241122_150037_rra_DM_47769a.md b/changelog.d/20241122_150037_rra_DM_47769a.md index c47fe783..c07f42f0 100644 --- a/changelog.d/20241122_150037_rra_DM_47769a.md +++ b/changelog.d/20241122_150037_rra_DM_47769a.md @@ -1,3 +1,3 @@ ### New features -- Add new `safir.models.LinkData` model that parses the contents of an HTTP `Link` header and extracts pagination information. +- Add new `safir.database.PaginatedLinkData` model that parses the contents of an HTTP `Link` header and extracts pagination information. diff --git a/docs/user-guide/database/pagination.rst b/docs/user-guide/database/pagination.rst index ebb32629..aac47dc8 100644 --- a/docs/user-guide/database/pagination.rst +++ b/docs/user-guide/database/pagination.rst @@ -248,19 +248,19 @@ Those links can then be embedded in the response model wherever is appropriate f Parsing paginated query responses ================================= -Safir provides `~safir.models.LinkData` to parse the contents of an :rfc:`8288` ``Link`` header and extract pagination links from it. +Safir provides `~safir.database.PaginatedLinkData` to parse the contents of an :rfc:`8288` ``Link`` header and extract pagination links from it. This may be useful in clients of paginated query results, including tests of services that use the above approach to paginated queries. .. code-block:: python - from safir.models import LinkData + from safir.database import PaginatedLinkData r = client.get("/some/url", query={"limit": 100}) - links = LinkData.from_header(r.headers["Link"]) + links = PaginatedLinkData.from_header(r.headers["Link"]) next_url = links.next_url prev_url = links.prev_url first_url = links.first_url Currently, only the first, next, and previous URLs are extracted from the ``Link`` header. -If any of these URLs are not present, the corresponding attribute of `~safir.models.LinkData` will be `None`. +If any of these URLs are not present, the corresponding attribute of `~safir.database.PaginatedLinkData` will be `None`. diff --git a/safir/src/safir/database/__init__.py b/safir/src/safir/database/__init__.py index 564d0766..7a95b7f6 100644 --- a/safir/src/safir/database/__init__.py +++ b/safir/src/safir/database/__init__.py @@ -18,6 +18,7 @@ ) from ._pagination import ( DatetimeIdCursor, + PaginatedLinkData, PaginatedList, PaginatedQueryRunner, PaginationCursor, @@ -29,6 +30,7 @@ "DatabaseInitializationError", "DatetimeIdCursor", "PaginationCursor", + "PaginatedLinkData", "PaginatedList", "PaginatedQueryRunner", "create_async_session", diff --git a/safir/src/safir/database/_pagination.py b/safir/src/safir/database/_pagination.py index 12ef3e67..27272746 100644 --- a/safir/src/safir/database/_pagination.py +++ b/safir/src/safir/database/_pagination.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re from abc import ABCMeta, abstractmethod from dataclasses import dataclass from datetime import UTC, datetime @@ -20,6 +21,9 @@ from ._datetime import datetime_to_db +_LINK_REGEX = re.compile(r'\s*<(?P[^>]+)>;\s*rel="(?P[^"]+)"') +"""Matches a component of a valid ``Link`` header.""" + C = TypeVar("C", bound="PaginationCursor") """Type of a cursor for a paginated list.""" @@ -29,11 +33,55 @@ __all__ = [ "DatetimeIdCursor", "PaginationCursor", + "PaginatedLinkData", "PaginatedList", "PaginatedQueryRunner", ] +@dataclass +class PaginatedLinkData: + """Holds the data returned in an :rfc:`8288` ``Link`` header.""" + + prev_url: str | None + """URL of the previous page, or `None` for the first page.""" + + next_url: str | None + """URL of the next page, or `None` for the last page.""" + + first_url: str | None + """URL of the first page.""" + + @classmethod + def from_header(cls, header: str | None) -> Self: + """Parse an :rfc:`8288` ``Link`` with pagination URLs. + + Parameters + ---------- + header + Contents of an RFC 8288 ``Link`` header. + + Returns + ------- + PaginatedLinkData + Parsed form of that header. + """ + links = {} + if header: + for element in header.split(","): + if m := re.match(_LINK_REGEX, element): + if m.group("type") in ("prev", "next", "first"): + links[m.group("type")] = m.group("target") + elif m.group("type") == "previous": + links["prev"] = m.group("target") + + return cls( + prev_url=links.get("prev"), + next_url=links.get("next"), + first_url=links.get("first"), + ) + + @dataclass class PaginationCursor(Generic[E], metaclass=ABCMeta): """Generic pagnination cursor for keyset pagination. diff --git a/safir/src/safir/models/__init__.py b/safir/src/safir/models/__init__.py index 168fd6f4..50a8991a 100644 --- a/safir/src/safir/models/__init__.py +++ b/safir/src/safir/models/__init__.py @@ -8,11 +8,9 @@ """ from ._errors import ErrorDetail, ErrorLocation, ErrorModel -from ._link import LinkData __all__ = [ "ErrorDetail", "ErrorLocation", "ErrorModel", - "LinkData", ] diff --git a/safir/src/safir/models/_link.py b/safir/src/safir/models/_link.py deleted file mode 100644 index 528abd1a..00000000 --- a/safir/src/safir/models/_link.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Representation for a ``Link`` HTTP header.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from typing import Self - -__all__ = ["LinkData"] - -_LINK_REGEX = re.compile(r'\s*<(?P[^>]+)>;\s*rel="(?P[^"]+)"') -"""Matches a component of a valid ``Link`` header.""" - - -@dataclass -class LinkData: - """Holds the data returned in an :rfc:`8288` ``Link`` header.""" - - prev_url: str | None - """URL of the previous page, or `None` for the first page.""" - - next_url: str | None - """URL of the next page, or `None` for the last page.""" - - first_url: str | None - """URL of the first page.""" - - @classmethod - def from_header(cls, header: str | None) -> Self: - """Parse an :rfc:`8288` ``Link`` with pagination URLs. - - Parameters - ---------- - header - Contents of an RFC 8288 ``Link`` header. - - Returns - ------- - LinkData - Parsed form of that header. - """ - links = {} - if header: - for element in header.split(","): - if m := re.match(_LINK_REGEX, element): - if m.group("type") in ("prev", "next", "first"): - links[m.group("type")] = m.group("target") - elif m.group("type") == "previous": - links["prev"] = m.group("target") - - return cls( - prev_url=links.get("prev"), - next_url=links.get("next"), - first_url=links.get("first"), - ) diff --git a/safir/tests/database_test.py b/safir/tests/database_test.py index 95647362..a3cff269 100644 --- a/safir/tests/database_test.py +++ b/safir/tests/database_test.py @@ -27,6 +27,7 @@ from safir.database import ( DatetimeIdCursor, + PaginatedLinkData, PaginatedQueryRunner, create_async_session, create_database_engine, @@ -525,3 +526,44 @@ async def test_pagination(database_url: str, database_password: str) -> None: assert not result.prev_cursor base_url = URL("https://example.com/query?foo=b") assert result.link_header(base_url) == (f'<{base_url!s}>; rel="first"') + + +def test_link_data() -> None: + header = ( + '; rel="first", ' + '; rel="next"' + ) + link = PaginatedLinkData.from_header(header) + assert not link.prev_url + assert link.next_url == "https://example.com/query?cursor=1600000000.5_1" + assert link.first_url == "https://example.com/query" + + header = ( + '; rel="first", ' + '; rel="next", ' + '; rel="prev"' + ) + link = PaginatedLinkData.from_header(header) + assert link.prev_url == "https://example.com/query?limit=10&cursor=p5_1" + assert link.next_url == "https://example.com/query?limit=10&cursor=15_2" + assert link.first_url == "https://example.com/query?limit=10" + + header = ( + '; rel="first", ' + '; rel="previous"' + ) + link = PaginatedLinkData.from_header(header) + assert link.prev_url == "https://example.com/query?cursor=p1510000000_2" + assert not link.next_url + assert link.first_url == "https://example.com/query" + + header = '; rel="first"' + link = PaginatedLinkData.from_header(header) + assert not link.prev_url + assert not link.next_url + assert link.first_url == "https://example.com/query?foo=b" + + link = PaginatedLinkData.from_header("") + assert not link.prev_url + assert not link.next_url + assert not link.first_url diff --git a/safir/tests/models_test.py b/safir/tests/models_test.py index ba84dc69..acd9d2e1 100644 --- a/safir/tests/models_test.py +++ b/safir/tests/models_test.py @@ -4,7 +4,7 @@ import json -from safir.models import ErrorModel, LinkData +from safir.models import ErrorModel def test_error_model() -> None: @@ -20,44 +20,3 @@ def test_error_model() -> None: } model = ErrorModel.model_validate_json(json.dumps(error)) assert model.model_dump() == error - - -def test_link_data() -> None: - header = ( - '; rel="first", ' - '; rel="next"' - ) - link = LinkData.from_header(header) - assert not link.prev_url - assert link.next_url == "https://example.com/query?cursor=1600000000.5_1" - assert link.first_url == "https://example.com/query" - - header = ( - '; rel="first", ' - '; rel="next", ' - '; rel="prev"' - ) - link = LinkData.from_header(header) - assert link.prev_url == "https://example.com/query?limit=10&cursor=p5_1" - assert link.next_url == "https://example.com/query?limit=10&cursor=15_2" - assert link.first_url == "https://example.com/query?limit=10" - - header = ( - '; rel="first", ' - '; rel="previous"' - ) - link = LinkData.from_header(header) - assert link.prev_url == "https://example.com/query?cursor=p1510000000_2" - assert not link.next_url - assert link.first_url == "https://example.com/query" - - header = '; rel="first"' - link = LinkData.from_header(header) - assert not link.prev_url - assert not link.next_url - assert link.first_url == "https://example.com/query?foo=b" - - link = LinkData.from_header("") - assert not link.prev_url - assert not link.next_url - assert not link.first_url