Skip to main content

osproxy_spi/
decision.rs

1//! What the SPI returns: where to send a request and how to transform it.
2
3use osproxy_core::{Epoch, Target};
4
5use crate::request::Protocol;
6use crate::rules::{DocIdRule, InjectedField};
7
8/// A mutation to apply to the request headers before forwarding upstream.
9///
10/// # Examples
11///
12/// ```
13/// use osproxy_spi::HeaderOp;
14/// let op = HeaderOp::Add { name: "x-tenant".into(), value: "acme".into() };
15/// assert!(matches!(op, HeaderOp::Add { .. }));
16/// ```
17#[non_exhaustive]
18#[derive(Clone, PartialEq, Eq, Debug)]
19pub enum HeaderOp {
20    /// Add a header (does not remove an existing one of the same name).
21    Add {
22        /// Header name.
23        name: String,
24        /// Header value.
25        value: String,
26    },
27    /// Remove all headers with this name.
28    Remove {
29        /// Header name to remove.
30        name: String,
31    },
32    /// Replace (remove-then-add) a header.
33    Replace {
34        /// Header name.
35        name: String,
36        /// New value.
37        value: String,
38    },
39}
40
41/// How the request body must be transformed before it is forwarded.
42///
43/// For single-doc ingest the transform injects tenancy fields and/or constructs
44/// the document `_id`. `osproxy-rewrite` performs the transform; this enum is
45/// the instruction. Not `#[non_exhaustive]`: the engine must apply every
46/// transform kind, so a new kind should force the plan builder to be updated.
47///
48/// # Examples
49///
50/// ```
51/// use osproxy_spi::BodyTransform;
52/// assert!(BodyTransform::None.is_none());
53/// assert!(!BodyTransform::Inject(vec![]).is_none());
54/// ```
55#[derive(Clone, PartialEq, Eq, Debug)]
56pub enum BodyTransform {
57    /// Forward the body unchanged.
58    None,
59    /// Inject named fields into the document.
60    Inject(Vec<InjectedField>),
61    /// Construct the `_id` (and optionally `_routing`) from a rule.
62    ConstructId(DocIdRule),
63    /// Both inject fields and construct the id.
64    Both {
65        /// Fields to inject.
66        inject: Vec<InjectedField>,
67        /// Id-construction rule.
68        id: DocIdRule,
69    },
70}
71
72impl BodyTransform {
73    /// Whether this transform leaves the body untouched.
74    #[must_use]
75    pub fn is_none(&self) -> bool {
76        matches!(self, Self::None)
77    }
78}
79
80/// The routing decision: the single destination plus the transforms to apply.
81///
82/// In v1 exactly one [`Target`] is resolved, no synchronous fan-out (ADR-002).
83/// The [`Epoch`] is the placement-table generation the decision was derived
84/// from; it is stamped onto the write so the sink can reject a stale-epoch write
85/// during a migration (`docs/06` ยง2).
86///
87/// Read-path concerns are **derived**, not separate fields: the mandatory
88/// partition query filter and the response field-strip are both computed from
89/// [`BodyTransform`] (the injected `PartitionId` field is the isolation key, see
90/// `osproxy-engine`'s `read` module), and cursor (scroll/PIT) affinity is handled
91/// by the engine's cursor signer on those endpoints. Deriving them from the same
92/// `body_transform` that drives the write-path inject is what keeps the two
93/// provably inverse (`docs/02`, round-trip property test in `docs/09`).
94///
95/// # Examples
96///
97/// ```
98/// use osproxy_spi::{RouteDecision, BodyTransform, Protocol};
99/// use osproxy_spi::core::{Target, ClusterId, IndexName, Epoch};
100///
101/// let target = Target::new(ClusterId::from("eu-1"), IndexName::from("orders"));
102/// let decision = RouteDecision::passthrough(target, Protocol::Http1, Epoch::new(1));
103/// assert!(decision.body_transform.is_none());
104/// assert_eq!(decision.epoch, Epoch::new(1));
105/// ```
106#[derive(Clone, PartialEq, Eq, Debug)]
107pub struct RouteDecision {
108    /// The single physical destination.
109    pub target: Target,
110    /// The protocol to use upstream (may differ from ingress).
111    pub upstream_protocol: Protocol,
112    /// Header mutations to apply before forwarding.
113    pub header_ops: Vec<HeaderOp>,
114    /// The body transform to apply.
115    pub body_transform: BodyTransform,
116    /// The placement epoch this decision was derived from.
117    pub epoch: Epoch,
118}
119
120impl RouteDecision {
121    /// Constructs a decision with no header ops and no body transform.
122    #[must_use]
123    pub fn passthrough(target: Target, upstream_protocol: Protocol, epoch: Epoch) -> Self {
124        Self {
125            target,
126            upstream_protocol,
127            header_ops: Vec::new(),
128            body_transform: BodyTransform::None,
129            epoch,
130        }
131    }
132
133    /// Sets the body transform (builder style).
134    #[must_use]
135    pub fn with_body_transform(mut self, transform: BodyTransform) -> Self {
136        self.body_transform = transform;
137        self
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use osproxy_core::{ClusterId, IndexName};
145
146    fn target() -> Target {
147        Target::new(ClusterId::from("c"), IndexName::from("i"))
148    }
149
150    #[test]
151    fn passthrough_has_no_transform() {
152        let d = RouteDecision::passthrough(target(), Protocol::Http1, Epoch::ZERO);
153        assert!(d.body_transform.is_none());
154        assert!(d.header_ops.is_empty());
155        assert_eq!(d.epoch, Epoch::ZERO);
156    }
157
158    #[test]
159    fn body_transform_can_be_attached() {
160        let d = RouteDecision::passthrough(target(), Protocol::Http1, Epoch::new(2))
161            .with_body_transform(BodyTransform::Inject(vec![]));
162        assert!(!d.body_transform.is_none());
163    }
164}