Skip to main content

proxima_primitives/pipe/
transform.rs

1use alloc::vec::Vec;
2use bytes::Bytes;
3use core::future::Future;
4
5use crate::pipe::SendPipe;
6pub use crate::pipe::capabilities::ApplyOps;
7use crate::pipe::primitives::Pipe;
8
9use crate::pipe::handler::{PipeHandle, ThreadLocalPipeHandle};
10use crate::pipe::request::{Request, Response};
11use proxima_core::ProximaError;
12
13#[cfg(feature = "std")]
14use crate::pipe::handler::into_handle;
15#[cfg(feature = "std")]
16use crate::pipe::mutate::MutateOp;
17#[cfg(feature = "std")]
18use crate::pipe::pipe_factory::PipeFactory;
19#[cfg(feature = "std")]
20use alloc::string::String;
21#[cfg(feature = "std")]
22use regex::Regex;
23#[cfg(feature = "std")]
24use serde::{Deserialize, Serialize};
25#[cfg(feature = "std")]
26use serde_json::Value;
27#[cfg(feature = "std")]
28use std::pin::Pin;
29
30// ── main struct ───────────────────────────────────────────────────────────────
31
32/// Request/response transformation middleware. Generic over the inner pipe
33/// AND the op types; the std default type parameters (the HTTP `RequestOp` /
34/// `ResponseOp`, which carry `regex::Regex`) keep all existing call sites
35/// unchanged. Under no_std+alloc the HTTP op types are absent, so the defaults
36/// fall back to `()`; callers name their own op types explicitly.
37#[cfg(feature = "std")]
38pub struct Transform<Inner = PipeHandle, InOp = RequestOp, OutOp = ResponseOp> {
39    pub inner: Inner,
40    pub(crate) in_ops: Vec<InOp>,
41    pub(crate) out_ops: Vec<OutOp>,
42}
43
44#[cfg(not(feature = "std"))]
45pub struct Transform<Inner = PipeHandle, InOp = (), OutOp = ()> {
46    pub inner: Inner,
47    pub(crate) in_ops: Vec<InOp>,
48    pub(crate) out_ops: Vec<OutOp>,
49}
50
51impl<Inner, InOp, OutOp> Transform<Inner, InOp, OutOp> {
52    #[must_use]
53    pub fn new(inner: Inner) -> Self {
54        Self {
55            inner,
56            in_ops: Vec::new(),
57            out_ops: Vec::new(),
58        }
59    }
60
61    #[must_use]
62    pub fn with_request_op(mut self, op: InOp) -> Self {
63        self.in_ops.push(op);
64        self
65    }
66
67    #[must_use]
68    pub fn with_response_op(mut self, op: OutOp) -> Self {
69        self.out_ops.push(op);
70        self
71    }
72}
73
74impl<Inner, InOp, OutOp> SendPipe for Transform<Inner, InOp, OutOp>
75where
76    Inner: SendPipe + Clone,
77    Inner::In: ApplyOps<InOp> + Send,
78    Inner::Out: ApplyOps<OutOp>,
79    Inner::Err: Send,
80    InOp: Clone + Send + Sync + 'static,
81    OutOp: Clone + Send + Sync + 'static,
82{
83    type In = Inner::In;
84    type Out = Inner::Out;
85    type Err = Inner::Err;
86
87    fn call(
88        &self,
89        input: Inner::In,
90    ) -> impl Future<Output = Result<Inner::Out, Inner::Err>> + Send {
91        let mapped = input.apply(&self.in_ops);
92        let out_ops = self.out_ops.clone();
93        let inner = self.inner.clone();
94        async move {
95            let output = SendPipe::call(&inner, mapped).await?;
96            Ok(output.apply(&out_ops))
97        }
98    }
99}
100
101impl<InOp, OutOp> Pipe for Transform<ThreadLocalPipeHandle, InOp, OutOp>
102where
103    Request<Bytes>: ApplyOps<InOp>,
104    Response<Bytes>: ApplyOps<OutOp>,
105    InOp: Clone + Send + Sync + 'static,
106    OutOp: Clone + Send + Sync + 'static,
107{
108    type In = Request<Bytes>;
109    type Out = Response<Bytes>;
110    type Err = ProximaError;
111
112    fn call(
113        &self,
114        input: Request<Bytes>,
115    ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> {
116        let mapped = input.apply(&self.in_ops);
117        let out_ops = self.out_ops.clone();
118        let inner = self.inner.clone();
119        async move {
120            let output = Pipe::call(&inner, mapped).await?;
121            Ok(output.apply(&out_ops))
122        }
123    }
124}
125
126// ── factory ──────────────────────────────────────────────────────────────────
127
128/// Serialisable request op — the config mirror of [`RequestOp`]. Tagged by
129/// `op`; `RewritePath` carries the pattern as a string (compiled to a
130/// [`Regex`] at [`TransformConfig::into_transform`] time), and `Mutate` flattens
131/// the existing serde [`MutateOp`]. The `body`/`callback` ops are accepted by
132/// the wire form but rejected at lowering, preserving the historical errors.
133#[cfg(feature = "std")]
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(tag = "op", rename_all = "snake_case")]
136pub enum RequestOpConfig {
137    SetHeader {
138        name: String,
139        value: String,
140    },
141    RemoveHeader {
142        name: String,
143    },
144    RewritePath {
145        pattern: String,
146        template: String,
147    },
148    Mutate {
149        #[serde(flatten)]
150        mutate: MutateOp,
151    },
152    Body,
153    SetBody,
154    RewriteBody,
155    Callback,
156}
157
158#[cfg(feature = "std")]
159impl RequestOpConfig {
160    fn into_op(self) -> Result<RequestOp, ProximaError> {
161        match self {
162            RequestOpConfig::SetHeader { name, value } => Ok(RequestOp::SetHeader { name, value }),
163            RequestOpConfig::RemoveHeader { name } => Ok(RequestOp::RemoveHeader { name }),
164            RequestOpConfig::RewritePath { pattern, template } => {
165                let pattern = Regex::new(&pattern)
166                    .map_err(|err| ProximaError::Config(format!("rewrite_path pattern: {err}")))?;
167                Ok(RequestOp::RewritePath { pattern, template })
168            }
169            RequestOpConfig::Mutate { mutate } => Ok(RequestOp::Mutate(mutate)),
170            RequestOpConfig::Body | RequestOpConfig::SetBody | RequestOpConfig::RewriteBody => {
171                Err(ProximaError::Config(
172                    "transform body ops not implemented in v1; use a downstream pipe for body transforms"
173                        .into(),
174                ))
175            }
176            RequestOpConfig::Callback => Err(ProximaError::Config(
177                "transform callbacks land in chunk 2 alongside the callback upstream registry".into(),
178            )),
179        }
180    }
181}
182
183/// Serialisable response op — the config mirror of [`ResponseOp`].
184#[cfg(feature = "std")]
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(tag = "op", rename_all = "snake_case")]
187pub enum ResponseOpConfig {
188    SetHeader {
189        name: String,
190        value: String,
191    },
192    RemoveHeader {
193        name: String,
194    },
195    Mutate {
196        #[serde(flatten)]
197        mutate: MutateOp,
198    },
199}
200
201#[cfg(feature = "std")]
202impl ResponseOpConfig {
203    fn into_op(self) -> ResponseOp {
204        match self {
205            ResponseOpConfig::SetHeader { name, value } => ResponseOp::SetHeader { name, value },
206            ResponseOpConfig::RemoveHeader { name } => ResponseOp::RemoveHeader { name },
207            ResponseOpConfig::Mutate { mutate } => ResponseOp::Mutate(mutate),
208        }
209    }
210}
211
212/// Typed config surface for the `transform` middleware — ordered request- and
213/// response-side op pipelines.
214#[cfg(feature = "std")]
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
216pub struct TransformConfig {
217    #[serde(default)]
218    pub request_pipeline: alloc::vec::Vec<RequestOpConfig>,
219    #[serde(default)]
220    pub response_pipeline: alloc::vec::Vec<ResponseOpConfig>,
221}
222
223#[cfg(feature = "std")]
224impl TransformConfig {
225    /// Fluent constructor: start from an empty pipeline.
226    #[must_use]
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Fluent: append a request-side op.
232    #[must_use]
233    pub fn with_request_op(mut self, op: RequestOpConfig) -> Self {
234        self.request_pipeline.push(op);
235        self
236    }
237
238    /// Fluent: append a response-side op.
239    #[must_use]
240    pub fn with_response_op(mut self, op: ResponseOpConfig) -> Self {
241        self.response_pipeline.push(op);
242        self
243    }
244
245    /// Materialise the transform middleware around `inner`, compiling any
246    /// `rewrite_path` patterns and rejecting unimplemented body/callback ops.
247    pub fn into_transform(self, inner: PipeHandle) -> Result<Transform<PipeHandle>, ProximaError> {
248        let mut transform = Transform::new(inner);
249        for op in self.request_pipeline {
250            transform.in_ops.push(op.into_op()?);
251        }
252        for op in self.response_pipeline {
253            transform.out_ops.push(op.into_op());
254        }
255        Ok(transform)
256    }
257}
258
259#[cfg(feature = "std")]
260impl Transform<PipeHandle> {
261    pub fn from_spec(inner: PipeHandle, value: &Value) -> Result<Self, ProximaError> {
262        let config: TransformConfig = serde_json::from_value(value.clone())
263            .map_err(|err| ProximaError::Config(format!("transform config: {err}")))?;
264        config.into_transform(inner)
265    }
266}
267
268#[cfg(feature = "std")]
269pub struct TransformFactory;
270
271#[cfg(feature = "std")]
272impl PipeFactory for TransformFactory {
273    fn name(&self) -> &str {
274        "transform"
275    }
276
277    fn build(
278        &self,
279        spec: &Value,
280        inner: Option<PipeHandle>,
281    ) -> Pin<Box<dyn Future<Output = Result<PipeHandle, ProximaError>> + Send + '_>> {
282        let spec = spec.clone();
283        Box::pin(async move {
284            let inner = inner
285                .ok_or_else(|| ProximaError::Config("transform requires an inner pipe".into()))?;
286            let transform = Transform::from_spec(inner, &spec)?;
287            Ok(into_handle(transform))
288        })
289    }
290}
291
292// ── HTTP op types ─────────────────────────────────────────────────────────────
293//
294// `RequestOp` carries a `regex::Regex` (RewritePath), and the parse/apply path
295// uses serde_json + the std-only `MutateOp`/regex; the whole HTTP op surface is
296// std-tier. The generic `Transform<Inner, InOp, OutOp>` above is alloc-tier.
297
298#[cfg(feature = "std")]
299#[derive(Debug, Clone)]
300pub enum RequestOp {
301    SetHeader { name: String, value: String },
302    RemoveHeader { name: String },
303    RewritePath { pattern: Regex, template: String },
304    Mutate(MutateOp),
305}
306
307#[cfg(feature = "std")]
308#[derive(Debug, Clone)]
309pub enum ResponseOp {
310    SetHeader { name: String, value: String },
311    RemoveHeader { name: String },
312    Mutate(MutateOp),
313}
314
315#[cfg(feature = "std")]
316impl ApplyOps<RequestOp> for Request<Bytes> {
317    fn apply(mut self, ops: &[RequestOp]) -> Self {
318        for op in ops {
319            match op {
320                RequestOp::SetHeader { name, value } => {
321                    self.metadata.insert(name.clone(), value.clone());
322                }
323                RequestOp::RemoveHeader { name } => {
324                    self.metadata.remove(name);
325                }
326                RequestOp::RewritePath { pattern, template } => {
327                    let path_view = std::str::from_utf8(&self.path).unwrap_or("");
328                    let rewritten = pattern.replace(path_view, template.as_str()).into_owned();
329                    self.path = bytes::Bytes::from(rewritten);
330                }
331                RequestOp::Mutate(mutate) => {
332                    self = mutate.apply_to(self);
333                }
334            }
335        }
336        self
337    }
338}
339
340#[cfg(feature = "std")]
341impl ApplyOps<ResponseOp> for Response<Bytes> {
342    fn apply(mut self, ops: &[ResponseOp]) -> Self {
343        for op in ops {
344            match op {
345                ResponseOp::SetHeader { name, value } => {
346                    self.metadata.insert(name.clone(), value.clone());
347                }
348                ResponseOp::RemoveHeader { name } => {
349                    self.metadata.remove(name);
350                }
351                ResponseOp::Mutate(mutate) => {
352                    self = mutate.apply_to(self);
353                }
354            }
355        }
356        self
357    }
358}
359
360// `#[proxima::test]` pulls in the `proxima` dev-dependency, which the
361// loom build keeps out of the graph (see
362// `[target.'cfg(not(loom))'.dev-dependencies]` in Cargo.toml); these
363// tests are unrelated to the Notify/watch loom protocol.
364#[cfg(all(test, not(loom)))]
365#[allow(clippy::unwrap_used, clippy::expect_used)]
366mod tests {
367    use std::future::Future;
368    use std::sync::{Arc, Mutex};
369
370    use super::*;
371    use crate::pipe::handler::into_handle;
372
373    struct Capture {
374        captured: Arc<Mutex<Option<Request<Bytes>>>>,
375    }
376
377    impl Capture {
378        fn new() -> (Self, Arc<Mutex<Option<Request<Bytes>>>>) {
379            let captured: Arc<Mutex<Option<Request<Bytes>>>> = Arc::new(Mutex::new(None));
380            (
381                Self {
382                    captured: captured.clone(),
383                },
384                captured,
385            )
386        }
387    }
388
389    impl SendPipe for Capture {
390        type In = Request<Bytes>;
391        type Out = Response<Bytes>;
392        type Err = ProximaError;
393
394        fn call(
395            &self,
396            request: Request<Bytes>,
397        ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
398            *self.captured.lock().expect("capture lock") = Some(Request {
399                method: request.method.clone(),
400                path: request.path.clone(),
401                query: request.query.clone(),
402                metadata: request.metadata.clone(),
403                payload: bytes::Bytes::new(),
404                stream: None,
405                context: request.context.clone(),
406            });
407            async move {
408                let mut response = Response::ok("ok");
409                response.metadata.insert("server", "test/1.0");
410                Ok(response)
411            }
412        }
413    }
414
415    fn build_request() -> Request<Bytes> {
416        Request::builder()
417            .method("GET")
418            .path("/v1/items/42")
419            .header("user-agent", "rstest")
420            .build()
421            .expect("builder")
422    }
423
424    fn dummy() -> PipeHandle {
425        let (capture, _) = Capture::new();
426        into_handle(capture)
427    }
428
429    #[proxima::test]
430    async fn set_header_adds_header_to_request() {
431        let (capture, captured) = Capture::new();
432        let stack = Transform::new(into_handle(capture)).with_request_op(RequestOp::SetHeader {
433            name: "x-trace".into(),
434            value: "abc".into(),
435        });
436        let _ = SendPipe::call(&stack, build_request()).await.expect("call");
437        let guard = captured.lock().expect("captured");
438        let request = guard.as_ref().expect("captured request");
439        assert_eq!(request.metadata.get_str("x-trace"), Some("abc"));
440    }
441
442    #[proxima::test]
443    async fn remove_header_drops_header_from_request() {
444        let (capture, captured) = Capture::new();
445        let stack = Transform::new(into_handle(capture)).with_request_op(RequestOp::RemoveHeader {
446            name: "user-agent".into(),
447        });
448        let _ = SendPipe::call(&stack, build_request()).await.expect("call");
449        let guard = captured.lock().expect("captured");
450        let request = guard.as_ref().expect("captured request");
451        assert!(!request.metadata.contains_key("user-agent"));
452    }
453
454    #[proxima::test]
455    async fn rewrite_path_swaps_v1_to_v2() {
456        let (capture, captured) = Capture::new();
457        let pattern = Regex::new("^/v1/(.*)").expect("regex");
458        let stack = Transform::new(into_handle(capture)).with_request_op(RequestOp::RewritePath {
459            pattern,
460            template: "/v2/$1".into(),
461        });
462        let _ = SendPipe::call(&stack, build_request()).await.expect("call");
463        let guard = captured.lock().expect("captured");
464        let request = guard.as_ref().expect("captured request");
465        assert_eq!(request.path, "/v2/items/42");
466    }
467
468    #[proxima::test]
469    async fn response_remove_header_strips_header_from_response() {
470        let (capture, _) = Capture::new();
471        let stack =
472            Transform::new(into_handle(capture)).with_response_op(ResponseOp::RemoveHeader {
473                name: "server".into(),
474            });
475        let response = SendPipe::call(&stack, build_request()).await.expect("call");
476        assert!(!response.metadata.contains_key("server"));
477    }
478
479    #[test]
480    fn body_op_is_a_config_error_in_v1() {
481        let value = serde_json::json!({
482            "request_pipeline": [{"op": "body", "value": "x"}],
483        });
484        let outcome = Transform::from_spec(dummy(), &value);
485        assert!(matches!(outcome, Err(ProximaError::Config(_))));
486    }
487
488    #[test]
489    fn callback_op_is_a_config_error_until_chunk_2() {
490        let value = serde_json::json!({
491            "request_pipeline": [{"op": "callback", "name": "redact"}],
492        });
493        let outcome = Transform::from_spec(dummy(), &value);
494        assert!(matches!(outcome, Err(ProximaError::Config(_))));
495    }
496
497    #[test]
498    fn from_spec_parses_set_remove_rewrite_pipeline() {
499        let value = serde_json::json!({
500            "request_pipeline": [
501                {"op": "set_header", "name": "x-corr", "value": "abc"},
502                {"op": "remove_header", "name": "cookie"},
503                {"op": "rewrite_path", "pattern": "^/old/(.*)", "template": "/new/$1"},
504            ],
505            "response_pipeline": [
506                {"op": "set_header", "name": "x-served-by", "value": "proxima"},
507            ],
508        });
509        let transform = Transform::from_spec(dummy(), &value).expect("parse");
510        assert_eq!(transform.in_ops.len(), 3);
511        assert_eq!(transform.out_ops.len(), 1);
512    }
513
514    fn echo_body_pipe() -> PipeHandle {
515        struct EchoBody;
516        impl SendPipe for EchoBody {
517            type In = Request<Bytes>;
518            type Out = Response<Bytes>;
519            type Err = ProximaError;
520
521            fn call(
522                &self,
523                request: Request<Bytes>,
524            ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
525                async move {
526                    let (_, body) = request.body_bytes().await?;
527                    Ok(Response::new(200).with_body(body))
528                }
529            }
530        }
531        into_handle(EchoBody)
532    }
533
534    fn body_request(body: &'static [u8]) -> Request<Bytes> {
535        Request::builder()
536            .method("POST")
537            .path("/")
538            .body(bytes::Bytes::from_static(body))
539            .build()
540            .expect("builder")
541    }
542
543    #[proxima::test]
544    async fn response_mutate_corrupts_the_body_deterministically() {
545        use crate::pipe::mutate::{MutateOp, Mutation};
546        const PAYLOAD: &[u8] = b"the quick brown fox jumps over the lazy dog";
547        let stack = Transform::new(echo_body_pipe()).with_response_op(ResponseOp::Mutate(
548            MutateOp::new(Mutation::BitFlip { bits: 3 }, 0x5EED),
549        ));
550
551        let response = SendPipe::call(&stack, body_request(PAYLOAD))
552            .await
553            .expect("call");
554        let corrupted = response.collect_body().await.expect("body");
555
556        let expected = Mutation::BitFlip { bits: 3 }.mutate(0x5EED, 0, PAYLOAD);
557        assert_eq!(
558            &corrupted[..],
559            &expected[..],
560            "transform corrupts with the seeded mutation"
561        );
562        assert_ne!(&corrupted[..], PAYLOAD, "body was actually corrupted");
563    }
564
565    // principle-4 parity: the fluent config builder and the config value must
566    // lower to identical Transform op state (counts + the parsed ops).
567    #[test]
568    fn parity_fluent_builder_and_config_value_match() {
569        use crate::pipe::mutate::{MutateOp, Mutation};
570        let from_value: TransformConfig = serde_json::from_value(serde_json::json!({
571            "request_pipeline": [
572                {"op": "set_header", "name": "x-corr", "value": "abc"},
573                {"op": "rewrite_path", "pattern": "^/old/(.*)", "template": "/new/$1"},
574            ],
575            "response_pipeline": [
576                {"op": "mutate", "kind": "truncate", "seed": 11},
577            ],
578        }))
579        .expect("from_value");
580        let from_value = from_value
581            .into_transform(dummy())
582            .expect("into_transform value");
583
584        let from_builder = TransformConfig::new()
585            .with_request_op(RequestOpConfig::SetHeader {
586                name: "x-corr".into(),
587                value: "abc".into(),
588            })
589            .with_request_op(RequestOpConfig::RewritePath {
590                pattern: "^/old/(.*)".into(),
591                template: "/new/$1".into(),
592            })
593            .with_response_op(ResponseOpConfig::Mutate {
594                mutate: MutateOp::new(Mutation::Truncate, 11),
595            })
596            .into_transform(dummy())
597            .expect("into_transform builder");
598
599        assert_eq!(from_value.in_ops.len(), from_builder.in_ops.len());
600        assert_eq!(from_value.out_ops.len(), from_builder.out_ops.len());
601        match (&from_value.in_ops[1], &from_builder.in_ops[1]) {
602            (
603                RequestOp::RewritePath {
604                    pattern: left_pattern,
605                    template: left_template,
606                },
607                RequestOp::RewritePath {
608                    pattern: right_pattern,
609                    template: right_template,
610                },
611            ) => {
612                assert_eq!(left_pattern.as_str(), right_pattern.as_str());
613                assert_eq!(left_template, right_template);
614            }
615            other => panic!("expected matching rewrite_path ops, got {other:?}"),
616        }
617        match (&from_value.out_ops[0], &from_builder.out_ops[0]) {
618            (ResponseOp::Mutate(left), ResponseOp::Mutate(right)) => assert_eq!(left, right),
619            other => panic!("expected matching mutate ops, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn from_spec_parses_mutate_op_into_the_pipeline() {
625        use crate::pipe::mutate::{MutateOp, Mutation};
626        let value = serde_json::json!({
627            "response_pipeline": [
628                {"op": "mutate", "kind": "truncate", "seed": 11},
629            ],
630        });
631        let transform = Transform::from_spec(dummy(), &value).expect("parse");
632        assert_eq!(transform.out_ops.len(), 1);
633        match &transform.out_ops[0] {
634            ResponseOp::Mutate(op) => {
635                assert_eq!(
636                    op,
637                    &MutateOp::new(Mutation::Truncate, 11),
638                    "parses the seeded mutate op"
639                );
640            }
641            other => panic!("expected a mutate op, got {other:?}"),
642        }
643    }
644
645    #[derive(Clone, PartialEq, Debug)]
646    struct CounterMsg(u64);
647
648    #[derive(Clone)]
649    enum CounterMsgOp {
650        Double,
651        AddConst(u64),
652    }
653
654    impl ApplyOps<CounterMsgOp> for CounterMsg {
655        fn apply(mut self, ops: &[CounterMsgOp]) -> Self {
656            for op in ops {
657                match op {
658                    CounterMsgOp::Double => self.0 *= 2,
659                    CounterMsgOp::AddConst(constant) => self.0 += constant,
660                }
661            }
662            self
663        }
664    }
665
666    #[derive(Clone)]
667    struct CounterSink;
668
669    impl SendPipe for CounterSink {
670        type In = CounterMsg;
671        type Out = CounterMsg;
672        type Err = ProximaError;
673
674        fn call(
675            &self,
676            input: CounterMsg,
677        ) -> impl Future<Output = Result<CounterMsg, ProximaError>> + Send {
678            async move { Ok(CounterMsg(input.0 + 1)) }
679        }
680    }
681
682    #[proxima::test]
683    async fn transform_is_generic_over_a_non_http_payload() {
684        let stack = Transform::new(CounterSink)
685            .with_request_op(CounterMsgOp::Double)
686            .with_response_op(CounterMsgOp::AddConst(10));
687        let out = SendPipe::call(&stack, CounterMsg(3)).await.expect("call");
688        assert_eq!(
689            out,
690            CounterMsg(17),
691            "Double(3)=6 -> sink=7 -> AddConst(10)=17"
692        );
693    }
694}