← 中文资源

简体中文版本

解析 HTTP Link 响应头

按 RFC 8288 解析 Link 头中的命名关系,正确处理引号和尖括号内的逗号。

阅读英文原文

功能说明

不少 API 用 Link 响应头返回分页或相关资源链接,例如 next、prev、last。这个解析器把头部拆成若干个链接项,再提取目标 URL、rel 和可选的 title,最终以 rel 为键返回字典。

难点在于逗号既可能分隔链接,也可能出现在带引号的 title 或尖括号包裹的 URL 中。代码用一个简单的状态机区分两种上下文,只在两者之外才把逗号当作分隔符。

关键代码

split_parts 扫描每个字符,维护 in_quotes 和 in_angle 两个布尔状态;遇到不在引号、也不在尖括号内的逗号就切分。parse_link_header 从 <...> 中取出目标地址,再把剩余属性按分号拆分,用 partition("=") 得到键值并去掉包裹的引号。

main 使用包含逗号标题的示例,验证 next 的 title 没有被截断,同时确认 last 的目标地址完整。

使用注意

同名 rel 出现多次时,后出现的会覆盖先出现的;如果业务需要保留全部关系,应把值改成列表。属性值内部若含分号,当前实现会误切分,这是简化解析器的已知限制。RFC 允许一个 rel 包含多个以空格分隔的关系名,这里会把整串当作一个键。

返回的目标地址可能是相对路径,调用方需要结合请求 URL 解析。多个同名响应头应先用逗号连接再解析。解析器不验证 URL 协议,也不处理扩展属性中的转义引号。

验证方式

示例中 next 的目标以 page=2&limit=20 结尾,title 完整保留为 Next, page 2;last 的目标以 page=9&limit=20 结尾;空字符串返回空字典。可以再加入三个链接、其中一个 title 含逗号,确认拆分数量仍然正确。

代码示例

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()