Skip to content

Commit

Permalink
Move LinkData and rename it to PaginatedLinkData
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
rra committed Nov 25, 2024
1 parent 782b377 commit 78af1ad
Show file tree
Hide file tree
Showing 8 changed files with 98 additions and 104 deletions.
2 changes: 1 addition & 1 deletion changelog.d/20241122_150037_rra_DM_47769a.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions docs/user-guide/database/pagination.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 2 additions & 0 deletions safir/src/safir/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from ._pagination import (
DatetimeIdCursor,
PaginatedLinkData,
PaginatedList,
PaginatedQueryRunner,
PaginationCursor,
Expand All @@ -29,6 +30,7 @@
"DatabaseInitializationError",
"DatetimeIdCursor",
"PaginationCursor",
"PaginatedLinkData",
"PaginatedList",
"PaginatedQueryRunner",
"create_async_session",
Expand Down
48 changes: 48 additions & 0 deletions safir/src/safir/database/_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import re
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
from datetime import UTC, datetime
Expand All @@ -20,6 +21,9 @@

from ._datetime import datetime_to_db

_LINK_REGEX = re.compile(r'\s*<(?P<target>[^>]+)>;\s*rel="(?P<type>[^"]+)"')
"""Matches a component of a valid ``Link`` header."""

C = TypeVar("C", bound="PaginationCursor")
"""Type of a cursor for a paginated list."""

Expand All @@ -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.
Expand Down
2 changes: 0 additions & 2 deletions safir/src/safir/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@
"""

from ._errors import ErrorDetail, ErrorLocation, ErrorModel
from ._link import LinkData

__all__ = [
"ErrorDetail",
"ErrorLocation",
"ErrorModel",
"LinkData",
]
55 changes: 0 additions & 55 deletions safir/src/safir/models/_link.py

This file was deleted.

42 changes: 42 additions & 0 deletions safir/tests/database_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from safir.database import (
DatetimeIdCursor,
PaginatedLinkData,
PaginatedQueryRunner,
create_async_session,
create_database_engine,
Expand Down Expand Up @@ -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 = (
'<https://example.com/query>; rel="first", '
'<https://example.com/query?cursor=1600000000.5_1>; 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 = (
'<https://example.com/query?limit=10>; rel="first", '
'<https://example.com/query?limit=10&cursor=15_2>; rel="next", '
'<https://example.com/query?limit=10&cursor=p5_1>; 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 = (
'<https://example.com/query>; rel="first", '
'<https://example.com/query?cursor=p1510000000_2>; 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 = '<https://example.com/query?foo=b>; 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
43 changes: 1 addition & 42 deletions safir/tests/models_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import json

from safir.models import ErrorModel, LinkData
from safir.models import ErrorModel


def test_error_model() -> None:
Expand All @@ -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 = (
'<https://example.com/query>; rel="first", '
'<https://example.com/query?cursor=1600000000.5_1>; 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 = (
'<https://example.com/query?limit=10>; rel="first", '
'<https://example.com/query?limit=10&cursor=15_2>; rel="next", '
'<https://example.com/query?limit=10&cursor=p5_1>; 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 = (
'<https://example.com/query>; rel="first", '
'<https://example.com/query?cursor=p1510000000_2>; 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 = '<https://example.com/query?foo=b>; 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

0 comments on commit 78af1ad

Please sign in to comment.