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}