Skip to content

HATEOAS See header

Status: ready to use

This specification is legitimately useful and ready to go. No changes are anticipated to be needed.

HAL/HAL-JSON, JSON-LD, and JSON API are unacceptably cumbersome for many REST-like APIs, and existing APIs can’t be made conformant without breaking changes.

Example problems:

  • JSON API requires putting all the resource data under a data key, which is awkward and always a breaking change.
  • HAL uses _embedded and requires clients to understand CURIEs.
  • HTTP methods can’t be included using any of the three standards.

In the spirit of XKCD #927, here is a new proposal.

HTTP See header specification

To avoid polluting your JSON response payloads, the links should be encoded in an HTTP header. This nonstandard header is called Link. Note: the X- prefix convention is deprecated.

Example:

See: <https://api.tld>; rel="delete"; method="DELETE", <https://api.tld?page=2>; rel="next"; method="GET"
ABNF

Refer to the ABNF (RFC5234) docs.

see = "See:" *1(*SP link *("," *SP link))
link     = "<" URI ">" *(";" *SP param)
param    = ("method=" method) / ("rel=" rel) / ("doc=" "<" URI ">")
method   = "HEAD" / "GET" / "PUT" / "DELETE" / "PATCH" / "POST"
rel      = 1*TCHAR

; tchar as defined in RFC 7230 for valid characters in a token
TCHAR    = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "."
           / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
XML meta-grammar

Refer to XML’s custom meta-grammar.

see      ::= "See:" ( link ("," ' '* link)* )?
link     ::= "<" URI ">" (";" ' '* param)*
param    ::= ("method=" method) | ("rel=" rel) | ("doc=" "<" URI ">")
method   ::= "HEAD" | "GET" | "PUT" | "DELETE" | "PATCH" | "POST"
rel      ::= TCHAR+

URI      ::= <per RFC 3986>
; tchar as defined in RFC 7230 for valid characters in a token
TCHAR    ::= [!#$%&'*+.^_`|~0-9A-Za-z-]

Suggested rels

  • Pagination: prev, next, first, last, up
  • Actions: validate, submit, clone
  • This resource: metadata, data
  • Context: about, history
  • Other resources: related

Python encoder and decoder

import re
from dataclasses import asdict, dataclass
from typing import Iterable, Self
from urllib.parse import unquote, urlencode


_LINK_PATTERN = re.compile(r"(?:^|, *)<(?P<uri>[^>]+)>(?P<params>[^,]+)")
_PARAMS_PATTERN = re.compile(r"(?:^|; *)(?P<key>method|rel|doc)=<?(?P<value>[^ ;>]+)>?")


@dataclass(slots=True, frozen=True, order=True)
class See:
    uri: str
    method: str | None
    rel: str | None
    doc: str | None


class SeeEncoding:

    def encode(self: Self, see_links: Iterable[See]) -> str:
        return ", ".join(self._encode_single(see) for see in see_links)

    def decode(self: Self, header: str) -> list[See]:
        return [
            self._decode_single(m.group("uri"), m.group("params"))
            for m in _LINK_PATTERN.finditer(header)
        ]

    def _encode_single(self: Self, see: See) -> str:
        data = {
            k: urlencode(f"<{v}>") if k == "doc" else urlencode(v)
            for k, v in asdict(see).items()
            if k != "uri"
        }
        return "; ".join([f"<{urlencode(see.uri)}>", *data])

    def _decode_single(self: Self, uri: str, params: str) -> See:
        data = {
            m.group("key"): unquote(m.group("value"))
            for m in _PARAMS_PATTERN.finditer(params)
        }
        return See(uri, **data)