Skip to main content

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}