Parse HTTP Link Headers into Named Relations

Parse RFC 8288 Link headers into named relations while respecting commas inside quoted titles and target URLs.

PythonData Fetching

Code

snippet
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Link:
    target: str
    rel: str
    title: str | None = None


def split_parts(header: str) -> list[str]:
    parts: list[str] = []
    current: list[str] = []
    in_quotes = False
    in_angle = False
    for char in header:
        if char == '"':
            in_quotes = not in_quotes
        elif char == "<" and not in_quotes:
            in_angle = True
        elif char == ">" and not in_quotes:
            in_angle = False
        if char == "," and not in_quotes and not in_angle:
            parts.append("".join(current).strip())
            current = []
        else:
            current.append(char)
    if "".join(current).strip():
        parts.append("".join(current).strip())
    return parts


def parse_link_header(header: str) -> dict[str, Link]:
    links: dict[str, Link] = {}
    for part in split_parts(header):
        if not part.startswith("<") or ">" not in part:
            continue
        target, _, rest = part[1:].partition(">")
        attributes: dict[str, str] = {}
        for attribute in rest.split(";"):
            attribute = attribute.strip()
            if not attribute or "=" not in attribute:
                continue
            name, _, raw_value = attribute.partition("=")
            attributes[name.strip().lower()] = raw_value.strip().strip('"')
        rel = attributes.get("rel")
        if rel:
            links[rel] = Link(target=target, rel=rel, title=attributes.get("title"))
    return links


def main() -> None:
    header = (
        '<https://api.example.com/items?page=2&limit=20>; rel="next"; '
        'title="Next, page 2", '
        '<https://api.example.com/items?page=9&limit=20>; rel="last"'
    )
    links = parse_link_header(header)
    print(links["next"].target)
    print(links["next"].title)
    assert links["last"].target.endswith("page=9&limit=20")
    assert parse_link_header("") == {}


if __name__ == "__main__":
    main()