# HeaderKit EDRI's HTTP header subsystem — a lossless, lazy, typed header accessor and response-header builder for ASGI scopes. It exists because Python's standard library and prevailing WSGI/ASGI tooling do not provide the combination of: - **Lossless** round-tripping (original byte pairs preserved) - **Lazy semantic parsing** (only parse a header when the application asks for it) - **RFC-compliant** MIME type, cache-control, and entity-tag grammars - **Pluggable** parsing and response-header construction The `headerkit` lives at `edri.api.headerkit` — it is a standalone subsystem with no dependency on the rest of EDRI aside from its own error hierarchy. --- ## Core concepts ### Content-Type negotiation The parsed `Accept` header is exposed as an {class}`~edri.api.headerkit.types.Accept` object whose {meth}`~edri.api.headerkit.types.Accept.best_match` method implements RFC 7231 content negotiation (quality, specificity, and order). The framework uses this internally during the content-negotiation step described in {doc}`api_content_negotiation`. ### Cache policy Cache-related headers (`Cache-Control`, `Expires`, `ETag`, `If-None-Match`, etc.) are parsed into typed dataclasses. `Cache-Control` directives are exposed as a mapping; `EntityTag` and `EntityTagSet` support weak/strong comparison. See {class}`~edri.api.headerkit.types.CacheControl`. ### MIME type grammar The {mod}`edri.api.headerkit.grammar` module provides RFC 5234 / RFC 7230 low-level parsing primitives: | Function | Purpose | |----------|---------| | `is_token` / `require_token` | Validate RFC token characters | | `validate_name_bytes` / `validate_value_bytes` | Reject NUL, CR, LF in header values | | `split_commas` / `split_semicolons` | Split with quoted-string awareness | | `unquote` / `quote` | RFC quoted-string round-trip | | `parse_key_value` / `parse_params` | Parameter dictionary extraction (e.g. `charset=utf-8`) | | `parse_quality` | `q`-value parsing with range validation | --- ## Architecture ### `registry.py` — Header field registry {class}`~edri.api.headerkit.registry.Registry` stores a {class}`~edri.api.headerkit.registry.FieldSpec` per known header: ```python @dataclass(frozen=True, slots=True) class FieldSpec: name: bytes combine: CombineMode parser: Parser | None = None allow_duplicate_identical: bool = False request: bool = True response: bool = True description: str = "" ``` Each spec declares: - **`combine`** — how multi-line headers are combined (comma-list, cookie-style `; `, singleton, never-combine, or opaque). - **`parser`** — optional callable that lazily converts the raw bytes into a typed object. - **`request` / `response`** — which side of the HTTP exchange the header is valid on (used for documentation and future validation). The {data}`~edri.api.headerkit.defaults.DEFAULT_REGISTRY` pre-registers ~40 common HTTP headers (Accept, Content-Type, Cache-Control, Cookie, Authorization, ETag, Range, CORS fields, etc.). ### `policy.py` — Parsing policy {class}`~edri.api.headerkit.policy.HeaderPolicy` controls parsing strictness: | Field | Default | Meaning | |-------|---------|---------| | `mode` | `STRICT` | `STRICT` or `COMPAT` (affects obs-text allowance) | | `unknown` | `RAW` | How to handle unregistered headers — `RAW`, `COMBINE`, or `REJECT_DUPES` | | `max_total_bytes` | 64 KB | Aggregate header size limit | | `max_field_lines` | 256 | Maximum number of header field lines | | `allow_obs_text` | `True` | Permit RFC "obs-text" (bytes 0x80–0xFF) | | `strip_ows` | `True` | Strip optional whitespace from decoded values | Pre-built constants: `STRICT_POLICY` and `COMPAT_POLICY`. ### `grammar.py` — RFC-compliant MIME parsing Grammar functions operate on raw byte or string values and raise {class}`~edri.api.headerkit.errors.ParseError` on malformed input. They are the building blocks for all semantic parsers in {mod}`edri.api.headerkit.parsers`. ### `core.py` — The `Headers` class {class}`~edri.api.headerkit.core.Headers` is the main entry point. Constructed from an ASGI scope's `"headers"` list: ```python headers = Headers.from_scope(scope) ``` It provides: - **`first(name)` / `singleton(name)`** — get a single header value - **`combined(name)`** — get the semantically combined value per the registry's `CombineMode` - **`parse(name)`** — lazily parse a header through its registered parser (result cached) - **Typed properties** — `headers.accept`, `headers.cache_control`, `headers.content_type`, `headers.cookies`, etc. ### `response.py` — ResponseHeaders builder {class}`~edri.api.headerkit.response.ResponseHeaders` constructs ASGI response header byte pairs: ```python resp = ResponseHeaders() resp.set_content_type("text/html", charset="utf-8") resp.set_cookie("session", "abc123", httponly=True, secure=True) resp.set_date("expires") resp.asgi() # → list[tuple[bytes, bytes]] ``` `Set-Cookie` is append-only (never coalesced). `delete_cookie()` sets an immediate expiry. --- ## How to customise ### Registering a custom header parser Create a parser function and register it on a {class}`~edri.api.headerkit.registry.Registry`: ```python from edri.api.headerkit import Registry, FieldSpec, CombineMode def parse_my_header(headers, name: bytes) -> MyHeader: raw = headers.singleton(name) return MyHeader.parse(raw) registry = Registry() registry.register(FieldSpec(b"x-my-header", CombineMode.SINGLETON, parse_my_header)) # Pass to Headers headers = Headers.from_scope(scope, registry=registry) ``` The default registry is a module-level singleton. To add headers globally, create a custom registry and pass it on construction — the singleton is never mutated after import. ### Adding a response header transformer Wrap or subclass `ResponseHeaders`: ```python class MyResponseHeaders(ResponseHeaders): def append(self, name, value): # Add a server-timing header to every response super().append(b"server-timing", b"app;dur=42") super().append(name, value) ``` ### Custom cache-header policy ```python from edri.api.headerkit import Headers, HeaderPolicy, ParseMode, UnknownHeaderMode # Accept duplicate unknown headers by combining them with commas policy = HeaderPolicy( mode=ParseMode.COMPAT, unknown=UnknownHeaderMode.COMBINE, max_total_bytes=128 * 1024, ) headers = Headers.from_scope(scope, policy=policy) ``` --- ## See also - {doc}`api_content_negotiation` — how `Accept`-based negotiation uses HeaderKit - {doc}`middleware` — middleware can inspect parsed headers via `event._headers`