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}