osproxy_spi/request.rs
1//! The read-only view of an authenticated request handed to the SPI.
2//
3// JUSTIFY(file-length): one cohesive unit, the `RequestCtx` request view plus its
4// small companion types (`HeaderView`, `BodyDoc`, `Protocol`, `HttpMethod`) and
5// the builder/getter surface SPI implementers compile against. They share the
6// borrowed-`'a` request lifetime and exist to be read together; splitting them
7// would scatter the request facade for no gain. Tests live in the `tests` module.
8
9use osproxy_core::{EndpointKind, PrincipalId, RequestId};
10
11use crate::principal::Principal;
12
13/// The wire protocol a request arrived on (or is sent upstream on).
14///
15/// `#[non_exhaustive]` so additional protocols are additive. M1 implements
16/// [`Protocol::Http1`] only; HTTP/2 and gRPC arrive in M4 (`docs/11`).
17///
18/// # Examples
19///
20/// ```
21/// use osproxy_spi::Protocol;
22/// let ingress = Protocol::Http2;
23/// assert!(matches!(ingress, Protocol::Http2));
24/// ```
25#[non_exhaustive]
26#[derive(Clone, Copy, PartialEq, Eq, Debug)]
27pub enum Protocol {
28 /// HTTP/1.1, cleartext or over TLS.
29 Http1,
30 /// HTTP/2.
31 Http2,
32 /// gRPC (over HTTP/2).
33 Grpc,
34}
35
36/// The HTTP method of a request.
37///
38/// # Examples
39///
40/// ```
41/// use osproxy_spi::HttpMethod;
42/// assert_ne!(HttpMethod::Get, HttpMethod::Put);
43/// ```
44#[non_exhaustive]
45#[derive(Clone, Copy, PartialEq, Eq, Debug)]
46pub enum HttpMethod {
47 /// `GET`.
48 Get,
49 /// `PUT`.
50 Put,
51 /// `POST`.
52 Post,
53 /// `DELETE`.
54 Delete,
55 /// `HEAD`.
56 Head,
57}
58
59/// A minimal, borrowed view of request headers.
60///
61/// Backed by the transport's parsed headers; the SPI may read a header (e.g. to
62/// find a partition key) but cannot mutate it here, mutations are expressed as
63/// [`crate::HeaderOp`]s in the returned decision.
64///
65/// # Examples
66///
67/// ```
68/// use osproxy_spi::HeaderView;
69/// let raw = vec![("X-Tenant".to_owned(), "acme".to_owned())];
70/// let view = HeaderView::new(&raw);
71/// assert_eq!(view.get("x-tenant"), Some("acme")); // case-insensitive
72/// assert_eq!(view.get("absent"), None);
73/// ```
74#[derive(Clone, Copy, Debug)]
75pub struct HeaderView<'a> {
76 headers: &'a [(String, String)],
77}
78
79impl<'a> HeaderView<'a> {
80 /// Wraps a parsed header list.
81 #[must_use]
82 pub fn new(headers: &'a [(String, String)]) -> Self {
83 Self { headers }
84 }
85
86 /// Returns the first value for `name` (ASCII-case-insensitive), if present.
87 #[must_use]
88 pub fn get(&self, name: &str) -> Option<&'a str> {
89 self.headers
90 .iter()
91 .find(|(k, _)| k.eq_ignore_ascii_case(name))
92 .map(|(_, v)| v.as_str())
93 }
94}
95
96/// A read-only view of the request body for partition extraction.
97///
98/// Handed to [`crate::TenancySpi::resolve_partition`] so an implementer can pull
99/// the partition key out of the document **without parsing JSON or touching raw
100/// bytes** (ADR-014): the proxy scans the body on demand, reading just the field
101/// asked for and never materializing a tree. This is the extraction utility the
102/// SPI composes over, it deliberately exposes no byte accessor, so the
103/// memory-bounded scan is the only way in.
104///
105/// Backed by the raw body (the whole request for single-doc ingest, or one
106/// operation's source line for `_bulk`). A body that is absent or not a JSON
107/// object simply yields `None` from every lookup.
108///
109/// # Examples
110///
111/// ```
112/// use osproxy_spi::BodyDoc;
113///
114/// let doc = BodyDoc::new(br#"{"tenant_id":"acme","meta":{"region":"eu"}}"#);
115/// assert_eq!(doc.scalar("tenant_id").as_deref(), Some("acme"));
116/// assert_eq!(doc.scalar("meta.region").as_deref(), Some("eu"));
117/// assert_eq!(doc.scalar("missing"), None);
118/// ```
119#[derive(Clone, Copy, Debug)]
120pub struct BodyDoc<'a> {
121 bytes: &'a [u8],
122}
123
124impl<'a> BodyDoc<'a> {
125 /// Wraps the raw body bytes.
126 #[must_use]
127 pub fn new(bytes: &'a [u8]) -> Self {
128 Self { bytes }
129 }
130
131 /// The scalar at a dotted `path` (e.g. `"tenant_id"` or `"meta.region"`),
132 /// or `None` if the path is absent, the leaf is not a scalar, or the body is
133 /// not a JSON object. String leaves are decoded; numbers and bools use their
134 /// source text. The scan reads only as far as the field and allocates nothing
135 /// beyond the returned string.
136 #[must_use]
137 pub fn scalar(&self, path: &str) -> Option<String> {
138 osproxy_core::json::scalar_at_path(self.bytes, path.split('.')).ok()
139 }
140
141 /// Whether the body is empty (no document to read).
142 #[must_use]
143 pub fn is_empty(&self) -> bool {
144 self.bytes.is_empty()
145 }
146}
147
148/// The read-only view of an authenticated request given to the SPI to decide
149/// routing.
150///
151/// For M1 (single-doc ingest) the body is provided as a borrowed byte slice:
152/// one document fits comfortably in memory. Streaming body access for bulk
153/// arrives with the demux work in M3 (`docs/04` §3); the field is intentionally
154/// accessed only through [`RequestCtx::body`] so that change stays internal.
155///
156/// # Examples
157///
158/// ```
159/// use osproxy_spi::{RequestCtx, HttpMethod, Protocol, HeaderView, Principal};
160/// use osproxy_spi::core::{PrincipalId, RequestId, EndpointKind};
161///
162/// let principal = Principal::new(PrincipalId::from("svc"));
163/// let rid = RequestId::from("req-1");
164/// let headers = vec![("x-tenant".to_owned(), "acme".to_owned())];
165/// let ctx = RequestCtx::new(
166/// &principal,
167/// &rid,
168/// HttpMethod::Put,
169/// EndpointKind::IngestDoc,
170/// Protocol::Http1,
171/// "orders",
172/// HeaderView::new(&headers),
173/// b"{}",
174/// );
175/// assert_eq!(ctx.logical_index(), "orders");
176/// assert_eq!(ctx.headers().get("x-tenant"), Some("acme"));
177/// ```
178#[derive(Clone, Copy, Debug)]
179pub struct RequestCtx<'a> {
180 principal: &'a Principal,
181 request_id: &'a RequestId,
182 method: HttpMethod,
183 endpoint: EndpointKind,
184 protocol: Protocol,
185 logical_index: &'a str,
186 doc_id: Option<&'a str>,
187 headers: HeaderView<'a>,
188 body: &'a [u8],
189 query: Option<&'a str>,
190 path: &'a str,
191 forward_headers: &'a [(String, String)],
192}
193
194impl<'a> RequestCtx<'a> {
195 /// Constructs a request context from its already-authenticated parts.
196 #[must_use]
197 #[allow(
198 clippy::too_many_arguments,
199 reason = "an authenticated request genuinely has this many independent, \
200 read-only facets; bundling them into sub-structs would only \
201 shuffle the same fields around (docs/08 §3)"
202 )]
203 pub fn new(
204 principal: &'a Principal,
205 request_id: &'a RequestId,
206 method: HttpMethod,
207 endpoint: EndpointKind,
208 protocol: Protocol,
209 logical_index: &'a str,
210 headers: HeaderView<'a>,
211 body: &'a [u8],
212 ) -> Self {
213 Self {
214 principal,
215 request_id,
216 method,
217 endpoint,
218 protocol,
219 logical_index,
220 doc_id: None,
221 headers,
222 body,
223 query: None,
224 path: "",
225 forward_headers: &[],
226 }
227 }
228
229 /// Sets the client headers to forward verbatim to the upstream (builder
230 /// style). Distinct from [`headers`](Self::headers): that view is the
231 /// auth-stripped set used for routing and observability, while this is the
232 /// policy-sanitized set the proxy relays to the cluster (which may include the
233 /// client's `Authorization` and vendor trace headers). Empty by default, so
234 /// the upstream sees only the proxy-managed headers unless the binding fills it.
235 #[must_use]
236 pub fn with_forward_headers(mut self, forward_headers: &'a [(String, String)]) -> Self {
237 self.forward_headers = forward_headers;
238 self
239 }
240
241 /// Sets the raw request path (e.g. `/_cat/indices`). Builder style. Used by
242 /// the admin pass-through, which forwards the path verbatim to the configured
243 /// admin cluster; the tenancy-aware paths derive their index/id at classify
244 /// time and do not consult it.
245 #[must_use]
246 pub fn with_path(mut self, path: &'a str) -> Self {
247 self.path = path;
248 self
249 }
250
251 /// Sets the document id from the request path (e.g. `_doc/{id}`), present on
252 /// by-id reads/writes. Builder style; `RequestCtx` is `Copy` (`docs/04` §5).
253 #[must_use]
254 pub fn with_doc_id(mut self, doc_id: Option<&'a str>) -> Self {
255 self.doc_id = doc_id;
256 self
257 }
258
259 /// Sets the raw URL query string (without the `?`). Builder style. Only an
260 /// allow-list of cursor params (`scroll`/`keep_alive`) is ever forwarded
261 /// upstream, query-affecting params are dropped so the body partition filter
262 /// cannot be bypassed (NFR-S4).
263 #[must_use]
264 pub fn with_query(mut self, query: Option<&'a str>) -> Self {
265 self.query = query;
266 self
267 }
268
269 /// The authenticated caller.
270 #[must_use]
271 pub fn principal(&self) -> &Principal {
272 self.principal
273 }
274
275 /// The principal's id (convenience).
276 #[must_use]
277 pub fn principal_id(&self) -> &PrincipalId {
278 self.principal.id()
279 }
280
281 /// The request correlation id (telemetry).
282 #[must_use]
283 pub fn request_id(&self) -> &RequestId {
284 self.request_id
285 }
286
287 /// The HTTP method.
288 #[must_use]
289 pub fn method(&self) -> HttpMethod {
290 self.method
291 }
292
293 /// The endpoint classification.
294 #[must_use]
295 pub fn endpoint(&self) -> EndpointKind {
296 self.endpoint
297 }
298
299 /// The ingress protocol.
300 #[must_use]
301 pub fn protocol(&self) -> Protocol {
302 self.protocol
303 }
304
305 /// The logical index from the request path (pre-rewrite).
306 #[must_use]
307 pub fn logical_index(&self) -> &str {
308 self.logical_index
309 }
310
311 /// The client-supplied document id from the path, if the endpoint carries
312 /// one (`GetById`/`DeleteById`/by-id ingest). This is the **logical** id;
313 /// the tenancy layer maps it to the physical id (`docs/04` §5).
314 #[must_use]
315 pub fn doc_id(&self) -> Option<&'a str> {
316 self.doc_id
317 }
318
319 /// The raw URL query string (without the `?`), if any. Consumers must forward
320 /// only an allow-list of cursor params (`scroll`/`keep_alive`) upstream.
321 #[must_use]
322 pub fn query(&self) -> Option<&'a str> {
323 self.query
324 }
325
326 /// The raw request path, if set (`with_path`). Empty unless the consumer
327 /// attached it; the admin pass-through forwards it verbatim upstream.
328 #[must_use]
329 pub fn path(&self) -> &'a str {
330 self.path
331 }
332
333 /// The request headers.
334 #[must_use]
335 pub fn headers(&self) -> HeaderView<'a> {
336 self.headers
337 }
338
339 /// The client headers to forward verbatim to the upstream (`with_forward_headers`),
340 /// or an empty slice if none were attached.
341 #[must_use]
342 pub fn forward_headers(&self) -> &'a [(String, String)] {
343 self.forward_headers
344 }
345
346 /// The raw request body.
347 #[must_use]
348 pub fn body(&self) -> &'a [u8] {
349 self.body
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn header_lookup_is_case_insensitive() {
359 let raw = vec![("X-Tenant".to_owned(), "acme".to_owned())];
360 let view = HeaderView::new(&raw);
361 assert_eq!(view.get("x-tenant"), Some("acme"));
362 assert_eq!(view.get("X-TENANT"), Some("acme"));
363 assert_eq!(view.get("absent"), None);
364 }
365
366 #[test]
367 fn ctx_exposes_its_parts() {
368 let principal = Principal::new(PrincipalId::from("svc"));
369 let rid = RequestId::from("req-1");
370 let raw: Vec<(String, String)> = vec![];
371 let ctx = RequestCtx::new(
372 &principal,
373 &rid,
374 HttpMethod::Put,
375 EndpointKind::IngestDoc,
376 Protocol::Http1,
377 "orders",
378 HeaderView::new(&raw),
379 b"{}",
380 );
381 assert_eq!(ctx.method(), HttpMethod::Put);
382 assert_eq!(ctx.endpoint(), EndpointKind::IngestDoc);
383 assert_eq!(ctx.protocol(), Protocol::Http1);
384 assert_eq!(ctx.logical_index(), "orders");
385 assert_eq!(ctx.principal_id().as_str(), "svc");
386 assert_eq!(ctx.request_id().as_str(), "req-1");
387 assert_eq!(ctx.body(), b"{}");
388 assert_eq!(ctx.doc_id(), None);
389 }
390
391 #[test]
392 fn doc_id_is_attached_by_builder() {
393 let principal = Principal::new(PrincipalId::from("svc"));
394 let rid = RequestId::from("req-1");
395 let raw: Vec<(String, String)> = vec![];
396 let ctx = RequestCtx::new(
397 &principal,
398 &rid,
399 HttpMethod::Get,
400 EndpointKind::GetById,
401 Protocol::Http1,
402 "orders",
403 HeaderView::new(&raw),
404 b"",
405 )
406 .with_doc_id(Some("7"));
407 assert_eq!(ctx.doc_id(), Some("7"));
408 }
409}