dwara_core/dataplane/
versioning.rs

1//! API versioning aids (DW-048): the Accept media-type criterion of
2//! route matching, and the Deprecation/Sunset response-header
3//! automation.
4//!
5//! ## What is code here vs already expressible (the honest split)
6//!
7//! The router (DW-010) already covers the common versioning shapes, and
8//! this module deliberately does NOT re-implement any of them:
9//!
10//! - **Path-segment versions** (`/v1/users`, `/v2/users`): plain path
11//!   routes with the canonical precedence (exact > regex > prefix;
12//!   longest prefix wins), plus `rewrite.replace_prefix` to strip the
13//!   version segment toward a version-agnostic upstream. Nothing new
14//!   was needed.
15//! - **Version-header constraints** (`X-API-Version: "2"`): the exact
16//!   header matcher (`match.headers`) already expresses "this route
17//!   additionally requires that header value". Nothing new was needed.
18//! - **Version-in-query** (`?version=2`): `match.query` covers it.
19//!
20//! Two things were genuinely missing, and are the whole of the code:
21//!
22//! 1. **Accept media-type selection** ([`accept_matches`]): the exact
23//!    header matcher requires byte-equality of the ENTIRE header value,
24//!    so `Accept: application/vnd.acme.v2+json, application/json;q=0.8`
25//!    cannot select a version. `match.accept` names one media type; the
26//!    route applies when any Accept entry names that type/subtype
27//!    (case-insensitive), ignoring parameters and q-values — including
28//!    q=0, which RFC 9110 section 12.5.1 defines as `not acceptable`
29//!    but which does not affect selection in v1 (a documented design
30//!    decision: naming the version selects it, however reluctantly).
31//!    The configured value is normalized (padding, case) once at
32//!    snapshot compile and the compiled form is what the criterion
33//!    compares, so the raw config string never reaches the hot path.
34//!    Wildcard entries and a missing Accept never match: version
35//!    selection requires the client to NAME the version, so
36//!    unconstrained clients fall through to the unversioned default
37//!    route.
38//! 2. **Deprecation/Sunset automation** ([`decorate`]): the
39//!    `routes[].deprecation` config block emitting the RFC headers (see
40//!    `config::Deprecation` for the frozen semantics).
41//!
42//! ## The v1 same-path limitation (documented, deliberately not fixed)
43//!
44//! Non-path criteria are applied AFTER path resolution and a criteria
45//! miss does NOT fall through to another candidate (the frozen DW-010
46//! model; duplicate exact templates are additionally rejected at
47//! compile). Consequence: multiple versions of ONE path cannot be
48//! selected by header or Accept in v1 — a version family uses distinct
49//! paths (`/v1/`, `/v2/`, optionally constrained further by
50//! `match.headers`/`match.accept`), or a single route serving one
51//! version. Candidate iteration across same-path routes is a router
52//! model change, out of scope for this aid; noted for the future.
53//!
54//! ## Caching correctness
55//!
56//! A response whose route was selected by `match.accept` varies with
57//! the request's `Accept`, so the decoration tail merges
58//! `Vary: Accept` into every response of such a route (the same
59//! reasoning as CORS's `Vary: Origin`), composing with compression's
60//! `Vary: Accept-Encoding`.
61
62use hyper::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT};
63
64use crate::config::CompiledDeprecation;
65
66const DEPRECATION: HeaderName = HeaderName::from_static("deprecation");
67const SUNSET: HeaderName = HeaderName::from_static("sunset");
68const LINK: HeaderName = HeaderName::from_static("link");
69
70/// Does the request's `Accept` header name `media_type` (the compiled,
71/// NORMALIZED `type/subtype` of `match.accept` —
72/// `RouteTable::accept_media_type`, never the raw config string)? Any
73/// comma-separated entry of ANY `Accept` header line matches,
74/// case-insensitively, with parameters (`;q=...`, media-type
75/// parameters) and surrounding whitespace ignored. Wildcard entries
76/// (`*/*`, `type/*`) never match a specific type, and no `Accept`
77/// header at all never matches — both deliberately (see the module
78/// docs).
79pub fn accept_matches(headers: &HeaderMap, media_type: &str) -> bool {
80    headers.get_all(&ACCEPT).iter().any(|value| {
81        value
82            .to_str()
83            .is_ok_and(|raw| accept_lists_media_type(raw, media_type))
84    })
85}
86
87/// One `Accept` header value: does any of its comma-separated media
88/// ranges equal `want` on type/subtype?
89fn accept_lists_media_type(raw: &str, want: &str) -> bool {
90    raw.split(',').any(|entry| {
91        let media_type = entry.split(';').next().unwrap_or("").trim();
92        !media_type.is_empty() && media_type.eq_ignore_ascii_case(want)
93    })
94}
95
96/// Stamp a route's deprecation policy onto a response (DW-048).
97/// `Deprecation` and `Sunset` REPLACE any upstream-sent values (the
98/// gateway is the source of truth for the policy it is configured to
99/// emit — the same rule as the `X-RateLimit-*` headers); `Link` is
100/// APPENDED (a list header: upstream links must survive). Unbuildable
101/// values are skipped, never panic — validation has already rejected
102/// them for publishable configs, so this is only a generation-tear
103/// backstop, the same posture as the respond-action headers.
104pub fn decorate(headers: &mut HeaderMap, dep: &CompiledDeprecation) {
105    if let Some(v) = dep.deprecation_header() {
106        if let Ok(hv) = HeaderValue::from_str(v) {
107            headers.insert(&DEPRECATION, hv);
108        }
109    }
110    if let Some(v) = dep.sunset_header() {
111        if let Ok(hv) = HeaderValue::from_str(v) {
112            headers.insert(&SUNSET, hv);
113        }
114    }
115    if let Some(v) = dep.link_header() {
116        if let Ok(hv) = HeaderValue::from_str(v) {
117            headers.append(&LINK, hv);
118        }
119    }
120}