Skip to main content

featherbit/context/
mod.rs

1//! The per-request `Context` object that flows through every node of a policy
2//! graph, carrying the inbound request, the response under construction,
3//! free-form inter-node state, and any errors accumulated along the way.
4//! Serializable so it can be marshalled to and from Lua scripts.
5
6use bytes::Bytes;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::net::SocketAddr;
10
11pub mod stream;
12
13/// Holds all state for one request as it travels through a policy graph.
14///
15/// Each plugin receives the context, may mutate any part of it, and passes it
16/// on through its success or error port. The engine sends `response` back to
17/// the client once graph execution finishes.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Context {
20    /// The inbound HTTP request as received by the listener (plugins may rewrite it before proxying).
21    pub request: GatewayRequest,
22    /// The response being built; whatever is here when the graph finishes is sent to the client.
23    pub response: GatewayResponse,
24    /// Free-form key/value scratch space for passing data between nodes (e.g. auth claims).
25    pub message: HashMap<String, serde_json::Value>,
26    /// Errors recorded by nodes; routing through a node's `error` port appends here.
27    pub errors: Vec<GatewayError>,
28}
29
30/// Protocol-agnostic snapshot of the inbound request, decoupled from hyper types.
31///
32/// Multi-valued headers and query parameters are preserved as `Vec<String>`.
33/// The body is fully buffered; it serializes as base64 (see `bytes_serde`),
34/// which is how it crosses into Lua scripts.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct GatewayRequest {
37    /// HTTP method (`GET`, `POST`, ...).
38    pub method: String,
39    /// Request path without the query string.
40    pub path: String,
41    /// Request authority: the `Host` header, or the HTTP/2 `:authority`
42    /// pseudo-header when no `Host` is present (empty string when neither is).
43    pub host: String,
44    /// URI scheme, defaulting to `http` when the URI carries none.
45    pub scheme: String,
46    /// Header name → list of values (headers may repeat).
47    pub headers: HashMap<String, Vec<String>>,
48    /// Query parameter name → list of values (parameters may repeat).
49    pub query_params: HashMap<String, Vec<String>>,
50    /// Fully buffered request body, serialized as base64.
51    #[serde(with = "bytes_serde")]
52    pub body: Bytes,
53    /// Client socket address as `ip:port`.
54    pub remote_addr: String,
55    /// Wire protocol the request arrived on.
56    pub protocol: Protocol,
57}
58
59/// The response under construction, ultimately returned to the client.
60#[derive(Debug, Serialize, Deserialize)]
61pub struct GatewayResponse {
62    /// HTTP status code; `0` until a node (e.g. upstream or error-handler) sets it.
63    pub status_code: u16,
64    /// Header name → list of values.
65    pub headers: HashMap<String, Vec<String>>,
66    /// Response body, serialized as base64.
67    #[serde(with = "bytes_serde")]
68    pub body: Bytes,
69    /// Streaming body, when the upstream response is relayed unbuffered.
70    /// Skipped by serde: `Context` must stay serializable for Lua marshalling
71    /// and debug snapshots. Invariant: when this is `Some`, `body` is empty.
72    /// Set by the `upstream` node when a policy is inferred stream-capable at
73    /// compile time; read by the listener (`build_response`), which relays it
74    /// to the client instead of the buffered `body`.
75    #[serde(skip)]
76    pub stream: Option<crate::context::stream::ResponseStream>,
77}
78
79impl Clone for GatewayResponse {
80    /// Cloning drops any stream: a stream has exactly one consumer, and every
81    /// caller that clones a response (debug snapshots, cache stores) wants the
82    /// buffered form.
83    fn clone(&self) -> Self {
84        Self {
85            status_code: self.status_code,
86            headers: self.headers.clone(),
87            body: self.body.clone(),
88            stream: None,
89        }
90    }
91}
92
93/// An error recorded by a node during graph execution.
94///
95/// Errors do not abort the pipeline by themselves; the graph engine routes
96/// the context through the failing node's `error` port (or the policy's
97/// error handler) with this record appended to `Context::errors`.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct GatewayError {
100    /// Id of the node that produced the error.
101    pub node_id: String,
102    /// Machine-readable error code (e.g. `unauthorized`, `rate_limited`).
103    pub code: String,
104    /// Human-readable description.
105    pub message: String,
106    /// Optional structured details; defaults to empty when absent.
107    #[serde(default)]
108    pub metadata: HashMap<String, serde_json::Value>,
109}
110
111/// Wire protocol of the inbound connection.
112///
113/// Serialized in lowercase (`http1`, `http2`, ...). Only `Http1` and `Http2`
114/// are currently produced; the remaining variants are reserved for planned
115/// WebSocket/TCP/UDP proxying.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117#[serde(rename_all = "lowercase")]
118pub enum Protocol {
119    Http1,
120    Http2,
121    WebSocket,
122    Tcp,
123    Udp,
124}
125
126impl Context {
127    /// Creates a fresh context for an inbound request.
128    ///
129    /// The response starts empty with `status_code == 0` (i.e. "unset"),
130    /// and `message`/`errors` start empty.
131    pub fn new(request: GatewayRequest) -> Self {
132        Self {
133            request,
134            response: GatewayResponse {
135                status_code: 0,
136                headers: HashMap::new(),
137                body: Bytes::new(),
138                stream: None,
139            },
140            message: HashMap::new(),
141            errors: Vec::new(),
142        }
143    }
144}
145
146impl GatewayRequest {
147    /// Builds a `GatewayRequest` from parsed hyper request parts and a buffered body.
148    ///
149    /// Non-UTF-8 header values become empty strings, the query string is split
150    /// on `&`/`=` without percent-decoding, and the protocol is classified as
151    /// HTTP/2 only for `http::Version::HTTP_2` (HTTP/1.x otherwise).
152    pub fn from_hyper(req: &http::request::Parts, body: Bytes, remote_addr: SocketAddr) -> Self {
153        let mut headers: HashMap<String, Vec<String>> = HashMap::new();
154        for (name, value) in req.headers.iter() {
155            headers
156                .entry(name.as_str().to_string())
157                .or_default()
158                .push(value.to_str().unwrap_or("").to_string());
159        }
160
161        // RFC 9113 §8.2.3: an HTTP/2 client may split one request's cookies
162        // across several `cookie` fields, and the server must concatenate them
163        // before processing. Join here, once, rather than in each reader —
164        // every cookie consumer in the gateway takes the first field only, so
165        // an unjoined second field is invisible to all of them.
166        if let Some(cookies) = headers.get_mut("cookie") {
167            if cookies.len() > 1 {
168                *cookies = vec![cookies.join("; ")];
169            }
170        }
171
172        let mut query_params: HashMap<String, Vec<String>> = HashMap::new();
173        if let Some(query) = req.uri.query() {
174            for pair in query.split('&') {
175                let mut parts = pair.splitn(2, '=');
176                let key = parts.next().unwrap_or("").to_string();
177                let value = parts.next().unwrap_or("").to_string();
178                query_params.entry(key).or_default().push(value);
179            }
180        }
181
182        // HTTP/1.x carries the authority in the `Host` header. HTTP/2 carries
183        // it in the `:authority` pseudo-header instead — which hyper exposes on
184        // the URI, not as a header — and browsers send no `Host` at all over
185        // h2. Falling back to the URI authority keeps `request.host` populated
186        // on both, so `match.host` route rules, `$host` and the
187        // `http_to_https` redirect target behave the same either way.
188        let host = req
189            .headers
190            .get("host")
191            .and_then(|v| v.to_str().ok())
192            .filter(|h| !h.is_empty())
193            .map(|h| h.to_string())
194            .or_else(|| req.uri.authority().map(|a| a.as_str().to_string()))
195            .unwrap_or_default();
196
197        let scheme = req.uri.scheme_str().unwrap_or("http").to_string();
198
199        let protocol = if req.version == http::Version::HTTP_2 {
200            Protocol::Http2
201        } else {
202            Protocol::Http1
203        };
204
205        Self {
206            method: req.method.as_str().to_string(),
207            path: req.uri.path().to_string(),
208            host,
209            scheme,
210            headers,
211            query_params,
212            body,
213            remote_addr: remote_addr.to_string(),
214            protocol,
215        }
216    }
217}
218
219/// Serde adapter that encodes `Bytes` as a base64 string, keeping binary
220/// bodies intact through JSON/Lua round-trips.
221mod bytes_serde {
222    use base64::{engine::general_purpose::STANDARD, Engine};
223    use bytes::Bytes;
224    use serde::{self, Deserialize, Deserializer, Serializer};
225
226    pub fn serialize<S>(bytes: &Bytes, serializer: S) -> Result<S::Ok, S::Error>
227    where
228        S: Serializer,
229    {
230        serializer.serialize_str(&STANDARD.encode(bytes))
231    }
232
233    pub fn deserialize<'de, D>(deserializer: D) -> Result<Bytes, D::Error>
234    where
235        D: Deserializer<'de>,
236    {
237        let s = String::deserialize(deserializer)?;
238        STANDARD
239            .decode(&s)
240            .map(Bytes::from)
241            .map_err(serde::de::Error::custom)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::net::SocketAddr;
249
250    fn parts_with(headers: Vec<(&str, &str)>) -> http::request::Parts {
251        let mut builder = http::Request::builder().uri("/cb").method("GET");
252        for (k, v) in headers {
253            builder = builder.header(k, v);
254        }
255        builder.body(()).unwrap().into_parts().0
256    }
257
258    /// HTTP/2 clients may split the cookies of one request across several
259    /// `cookie` header fields -- RFC 9113 sec. 8.2.3 permits it and requires the
260    /// server to join them before processing. Firefox does exactly this.
261    /// Every cookie reader in the gateway takes `.first()`, so an unjoined
262    /// second field is silently invisible: the OIDC login-flow cookie goes
263    /// missing and the callback rejects a perfectly good login.
264    #[test]
265    fn test_multiple_cookie_fields_are_joined() {
266        let parts = parts_with(vec![
267            ("cookie", "oidc_session=abc"),
268            ("cookie", "oidc_session_flow=xyz"),
269        ]);
270        let req = GatewayRequest::from_hyper(
271            &parts,
272            Bytes::new(),
273            "1.2.3.4:5".parse::<SocketAddr>().unwrap(),
274        );
275
276        let cookies = req.headers.get("cookie").expect("cookie header present");
277        assert_eq!(
278            cookies.len(),
279            1,
280            "cookie fields must be joined into one, got {cookies:?}"
281        );
282        assert_eq!(cookies[0], "oidc_session=abc; oidc_session_flow=xyz");
283    }
284
285    /// HTTP/2 carries the authority in the `:authority` pseudo-header, which
286    /// hyper exposes on the request URI rather than as a `Host` header --
287    /// browsers do not send `Host` over h2 at all. Reading only the header
288    /// leaves `request.host` empty, which silently breaks every `match.host`
289    /// route rule (the request matches no host-scoped route), `$host` /
290    /// `{{request.host}}`, and the `http_to_https` redirect target.
291    #[test]
292    fn test_http2_authority_populates_host() {
293        let parts = http::Request::builder()
294            .method("GET")
295            .version(http::Version::HTTP_2)
296            .uri("https://api.example.com/thing")
297            .body(())
298            .unwrap()
299            .into_parts()
300            .0;
301
302        let req = GatewayRequest::from_hyper(
303            &parts,
304            Bytes::new(),
305            "1.2.3.4:5".parse::<SocketAddr>().unwrap(),
306        );
307
308        assert_eq!(req.host, "api.example.com");
309    }
310
311    /// The `Host` header remains authoritative for HTTP/1.x, where hyper gives
312    /// the URI in origin-form and there is no authority to fall back to.
313    #[test]
314    fn test_http1_host_header_still_used() {
315        let parts = parts_with(vec![("host", "legacy.example.com")]);
316        let req = GatewayRequest::from_hyper(
317            &parts,
318            Bytes::new(),
319            "1.2.3.4:5".parse::<SocketAddr>().unwrap(),
320        );
321
322        assert_eq!(req.host, "legacy.example.com");
323    }
324}