Skip to main content

proxima_primitives/pipe/
validate.rs

1use bytes::Bytes;
2use core::future::Future;
3
4use crate::pipe::SendPipe;
5pub use crate::pipe::capabilities::{CheckOutcome, Checkable};
6use crate::pipe::primitives::Pipe;
7
8use crate::pipe::handler::{PipeHandle, ThreadLocalPipeHandle};
9use crate::pipe::request::{Request, Response};
10use proxima_core::ProximaError;
11
12// proxima-config's schema module (Schema/SchemaRegistry/ValidationError) is
13// std-only, and the HTTP `Checkable` path uses serde_json; the whole
14// schema-backed validate surface is std-tier. The generic `Validate<Inner, Op>`
15// over `Checkable<Op>` is alloc-tier.
16#[cfg(feature = "std")]
17use crate::pipe::handler::into_handle;
18#[cfg(feature = "std")]
19use crate::pipe::pipe_factory::PipeFactory;
20#[cfg(feature = "std")]
21use alloc::sync::Arc;
22#[cfg(feature = "std")]
23use proxima_config::schema::{Schema, SchemaRegistry, ValidationError};
24#[cfg(feature = "std")]
25use serde::{Deserialize, Serialize};
26#[cfg(feature = "std")]
27use serde_json::{Value, json};
28#[cfg(feature = "std")]
29use std::pin::Pin;
30
31#[cfg(feature = "std")]
32const METRIC_REJECTED: &str = "proxima.validate.rejected_total";
33#[cfg(feature = "std")]
34const METRIC_ADMITTED: &str = "proxima.validate.admitted_total";
35
36// ── main struct ───────────────────────────────────────────────────────────────
37
38/// Predicate/schema-gated admission middleware. Generic over the inner pipe AND
39/// the check op. The std default op is the HTTP `ValidateOp` (proxima-config's
40/// schema module); under no_std+alloc that type is absent, so the default
41/// falls back to `()` and callers name their own `Checkable` op explicitly.
42#[cfg(feature = "std")]
43pub struct Validate<Inner = PipeHandle, Op = ValidateOp> {
44    pub inner: Inner,
45    op: Op,
46}
47
48#[cfg(not(feature = "std"))]
49pub struct Validate<Inner = PipeHandle, Op = ()> {
50    pub inner: Inner,
51    op: Op,
52}
53
54impl<Inner, Op> Validate<Inner, Op> {
55    #[must_use]
56    pub fn new(inner: Inner, op: Op) -> Self {
57        Self { inner, op }
58    }
59}
60
61impl<Inner, Op> SendPipe for Validate<Inner, Op>
62where
63    Inner: SendPipe + Clone,
64    Inner::In: Checkable<Op, Out = Inner::Out> + Send,
65    Inner::Out: Send,
66    Inner::Err: From<ProximaError> + Send,
67    Op: Clone + Send + Sync + 'static,
68{
69    type In = Inner::In;
70    type Out = Inner::Out;
71    type Err = Inner::Err;
72
73    fn call(
74        &self,
75        input: Inner::In,
76    ) -> impl Future<Output = Result<Inner::Out, Inner::Err>> + Send {
77        let op = self.op.clone();
78        let inner = self.inner.clone();
79        async move {
80            match input.check(&op).await.map_err(Inner::Err::from)? {
81                CheckOutcome::Pass(admitted) => SendPipe::call(&inner, admitted).await,
82                CheckOutcome::Reject(rejection) => Ok(rejection),
83            }
84        }
85    }
86}
87
88impl<Op> Pipe for Validate<ThreadLocalPipeHandle, Op>
89where
90    Request<Bytes>: Checkable<Op, Out = Response<Bytes>>,
91    Op: Clone + Send + Sync + 'static,
92{
93    type In = Request<Bytes>;
94    type Out = Response<Bytes>;
95    type Err = ProximaError;
96
97    fn call(
98        &self,
99        input: Request<Bytes>,
100    ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> {
101        let op = self.op.clone();
102        let inner = self.inner.clone();
103        async move {
104            match input.check(&op).await? {
105                CheckOutcome::Pass(admitted) => Pipe::call(&inner, admitted).await,
106                CheckOutcome::Reject(rejection) => Ok(rejection),
107            }
108        }
109    }
110}
111
112// ── HTTP op type + Checkable impl ─────────────────────────────────────────────
113
114#[cfg(feature = "std")]
115#[derive(Clone)]
116pub struct ValidateOp {
117    pub schema: Schema,
118    pub schemas: Arc<SchemaRegistry>,
119}
120
121#[cfg(feature = "std")]
122impl ValidateOp {
123    #[must_use]
124    pub fn new(schema: Schema, schemas: Arc<SchemaRegistry>) -> Self {
125        Self { schema, schemas }
126    }
127}
128
129#[cfg(feature = "std")]
130impl Checkable<ValidateOp> for Request<Bytes> {
131    type Out = Response<Bytes>;
132
133    fn check(
134        self,
135        op: &ValidateOp,
136    ) -> impl Future<Output = Result<CheckOutcome<Self, Response<Bytes>>, ProximaError>> + Send
137    {
138        let schema = op.schema.clone();
139        let schemas = op.schemas.clone();
140        async move {
141            let telemetry = self.context.telemetry.clone();
142            let labels = self.context.metric_labels(&[]);
143            let (request, buffered) = self.body_bytes().await?;
144            let Request::<Bytes> {
145                method,
146                path,
147                query,
148                metadata,
149                payload: _,
150                stream: _,
151                context,
152            } = request;
153            if buffered.is_empty()
154                && matches!(
155                    method,
156                    crate::pipe::method::Method::Get
157                        | crate::pipe::method::Method::Head
158                        | crate::pipe::method::Method::Delete
159                )
160            {
161                telemetry.counter_inc(METRIC_ADMITTED, &labels, 1);
162                let forwarded = Request {
163                    method,
164                    path,
165                    query,
166                    metadata,
167                    payload: buffered,
168                    stream: None,
169                    context,
170                };
171                return Ok(CheckOutcome::Pass(forwarded));
172            }
173            let value: Value = if buffered.is_empty() {
174                Value::Null
175            } else {
176                serde_json::from_slice(&buffered).map_err(|err| {
177                    ProximaError::Body(format!("validate: request payload is not JSON: {err}"))
178                })?
179            };
180            if let Err(err) = schema.validate(&value, schemas.as_ref()) {
181                telemetry.counter_inc(METRIC_REJECTED, &labels, 1);
182                return Ok(CheckOutcome::Reject(rejected_response(&err)));
183            }
184            telemetry.counter_inc(METRIC_ADMITTED, &labels, 1);
185            let forwarded = Request {
186                method,
187                path,
188                query,
189                metadata,
190                payload: buffered,
191                stream: None,
192                context,
193            };
194            Ok(CheckOutcome::Pass(forwarded))
195        }
196    }
197}
198
199#[cfg(feature = "std")]
200fn rejected_response(err: &ValidationError) -> Response<Bytes> {
201    let body = json!({
202        "error": "validation_failed",
203        "path": err.path_string(),
204        "message": err.message,
205    });
206    Response::new(400)
207        .with_header("content-type", "application/json")
208        .with_body(body.to_string())
209}
210
211// ── factory + constructors ────────────────────────────────────────────────────
212
213#[cfg(feature = "std")]
214impl Validate<PipeHandle, ValidateOp> {
215    pub fn from_schema(inner: PipeHandle, schema: Schema, schemas: Arc<SchemaRegistry>) -> Self {
216        Self::new(inner, ValidateOp::new(schema, schemas))
217    }
218
219    pub fn from_spec(
220        inner: PipeHandle,
221        value: &Value,
222        schemas: &Arc<SchemaRegistry>,
223    ) -> Result<Self, ProximaError> {
224        let config: ValidateConfig = serde_json::from_value(value.clone())
225            .map_err(|err| ProximaError::Config(format!("validate config: {err}")))?;
226        config.from_config(inner, schemas)
227    }
228}
229
230/// A schema reference: either a name resolved against the [`SchemaRegistry`], or
231/// an inline [`Schema`] definition. Untagged so a JSON string deserialises to
232/// [`SchemaRef::Named`] and a JSON object to [`SchemaRef::Inline`] — matching the
233/// historical hand-parser's string-then-object precedence.
234#[cfg(feature = "std")]
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(untagged)]
237pub enum SchemaRef {
238    Named(String),
239    Inline(Schema),
240}
241
242/// Typed config surface for the `validate` middleware. The [`SchemaRegistry`] is
243/// runtime state supplied at materialisation (not serialisable data), mirroring
244/// how `callback` resolves its function from a registry.
245#[cfg(feature = "std")]
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ValidateConfig {
248    pub schema: SchemaRef,
249}
250
251#[cfg(feature = "std")]
252impl ValidateConfig {
253    /// Fluent constructor referencing a named schema.
254    #[must_use]
255    pub fn named(name: impl Into<String>) -> Self {
256        Self {
257            schema: SchemaRef::Named(name.into()),
258        }
259    }
260
261    /// Fluent constructor carrying an inline schema.
262    #[must_use]
263    pub fn inline(schema: Schema) -> Self {
264        Self {
265            schema: SchemaRef::Inline(schema),
266        }
267    }
268
269    /// Materialise the validate middleware around `inner`, resolving a named
270    /// schema against `schemas`.
271    pub fn from_config(
272        self,
273        inner: PipeHandle,
274        schemas: &Arc<SchemaRegistry>,
275    ) -> Result<Validate<PipeHandle, ValidateOp>, ProximaError> {
276        let schema = match self.schema {
277            SchemaRef::Named(name) => schemas.get(&name).ok_or_else(|| {
278                ProximaError::Config(format!(
279                    "validate middleware references unknown schema `{name}`"
280                ))
281            })?,
282            SchemaRef::Inline(schema) => schema,
283        };
284        Ok(Validate::new(
285            inner,
286            ValidateOp::new(schema, schemas.clone()),
287        ))
288    }
289}
290
291#[cfg(feature = "std")]
292pub struct ValidateFactory {
293    schemas: Arc<SchemaRegistry>,
294}
295
296#[cfg(feature = "std")]
297impl ValidateFactory {
298    #[must_use]
299    pub fn new(schemas: Arc<SchemaRegistry>) -> Self {
300        Self { schemas }
301    }
302}
303
304#[cfg(feature = "std")]
305impl PipeFactory for ValidateFactory {
306    fn name(&self) -> &str {
307        "validate"
308    }
309
310    fn build(
311        &self,
312        spec: &Value,
313        inner: Option<PipeHandle>,
314    ) -> Pin<Box<dyn Future<Output = Result<PipeHandle, ProximaError>> + Send + '_>> {
315        let spec = spec.clone();
316        Box::pin(async move {
317            let inner = inner
318                .ok_or_else(|| ProximaError::Config("validate requires an inner pipe".into()))?;
319            let validate = Validate::from_spec(inner, &spec, &self.schemas)?;
320            Ok(into_handle(validate))
321        })
322    }
323}
324
325// `#[proxima::test]` pulls in the `proxima` dev-dependency, which the
326// loom build keeps out of the graph (see
327// `[target.'cfg(not(loom))'.dev-dependencies]` in Cargo.toml); these
328// tests are unrelated to the Notify/watch loom protocol.
329#[cfg(all(test, not(loom)))]
330#[allow(clippy::unwrap_used, clippy::expect_used)]
331mod tests {
332    use std::future::Future;
333
334    use super::*;
335    use crate::pipe::handler::into_handle;
336    use crate::pipe::request::Request;
337    use crate::pipe::telemetry_surface::{NoopTelemetry, TelemetryHandle};
338    use proxima_config::schema::{FieldFlags, StructField};
339
340    fn echo_pipe() -> PipeHandle {
341        struct EchoPipe;
342        impl SendPipe for EchoPipe {
343            type In = Request<Bytes>;
344            type Out = Response<Bytes>;
345            type Err = ProximaError;
346
347            fn call(
348                &self,
349                request: Request<Bytes>,
350            ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
351                async move {
352                    let (_, body) = request.body_bytes().await?;
353                    Ok(Response::new(200).with_body(body))
354                }
355            }
356        }
357        into_handle(EchoPipe)
358    }
359
360    fn user_schema() -> Schema {
361        Schema::Struct {
362            name: "User".into(),
363            fields: vec![StructField {
364                name: "name".into(),
365                schema: Schema::String {
366                    pattern: None,
367                    format: None,
368                    min_len: Some(1),
369                    max_len: None,
370                },
371                flags: FieldFlags::default(),
372            }],
373        }
374    }
375
376    fn noop_telemetry() -> TelemetryHandle {
377        NoopTelemetry::handle()
378    }
379
380    // principle-4 parity: the fluent builder and the config value must lower to
381    // identical ValidateOp state (the resolved schema).
382    #[test]
383    fn parity_fluent_builder_and_config_value_match() {
384        let schemas = Arc::new(SchemaRegistry::new());
385        let schema_json = serde_json::to_value(user_schema()).expect("schema json");
386
387        let from_value: ValidateConfig =
388            serde_json::from_value(json!({ "schema": schema_json })).expect("from_value");
389        let from_value = from_value
390            .from_config(echo_pipe(), &schemas)
391            .expect("from_config value");
392
393        let from_builder = ValidateConfig::inline(user_schema())
394            .from_config(echo_pipe(), &schemas)
395            .expect("from_config builder");
396
397        assert_eq!(
398            serde_json::to_value(&from_value.op.schema).expect("ser value"),
399            serde_json::to_value(&from_builder.op.schema).expect("ser builder"),
400        );
401    }
402
403    #[proxima::test]
404    async fn admits_valid_body() {
405        let schemas = Arc::new(SchemaRegistry::new());
406        let wrapped = Validate::from_schema(echo_pipe(), user_schema(), schemas);
407        let telemetry = noop_telemetry();
408        let request = Request::builder()
409            .method("POST")
410            .path("/")
411            .body(bytes::Bytes::from_static(br#"{"name":"brian"}"#))
412            .header("content-type", "application/json")
413            .telemetry(telemetry)
414            .build()
415            .expect("builder");
416        let response = SendPipe::call(&wrapped, request).await.expect("call");
417        assert_eq!(response.status, 200);
418    }
419
420    #[proxima::test]
421    async fn rejects_missing_required_field() {
422        let schemas = Arc::new(SchemaRegistry::new());
423        let wrapped = Validate::from_schema(echo_pipe(), user_schema(), schemas);
424        let telemetry = noop_telemetry();
425        let request = Request::builder()
426            .method("POST")
427            .path("/")
428            .body(bytes::Bytes::from_static(b"{}"))
429            .header("content-type", "application/json")
430            .telemetry(telemetry)
431            .build()
432            .expect("builder");
433        let response = SendPipe::call(&wrapped, request).await.expect("call");
434        assert_eq!(response.status, 400);
435        let body = response.collect_body().await.expect("body");
436        let parsed: Value = serde_json::from_slice(&body).expect("json");
437        assert_eq!(parsed["error"], "validation_failed");
438        assert!(
439            parsed["message"]
440                .as_str()
441                .unwrap_or("")
442                .contains("missing required field `name`")
443        );
444    }
445
446    #[proxima::test]
447    async fn resolves_named_schema_from_registry() {
448        let schemas = Arc::new(SchemaRegistry::new());
449        schemas.register("User", user_schema()).expect("register");
450        let wrapped = Validate::from_spec(echo_pipe(), &json!({"schema": "User"}), &schemas)
451            .expect("from spec");
452        let telemetry = noop_telemetry();
453        let request = Request::builder()
454            .method("POST")
455            .path("/")
456            .body(bytes::Bytes::from_static(br#"{"name":"brian"}"#))
457            .header("content-type", "application/json")
458            .telemetry(telemetry)
459            .build()
460            .expect("builder");
461        let response = SendPipe::call(&wrapped, request).await.expect("call");
462        assert_eq!(response.status, 200);
463    }
464
465    #[proxima::test]
466    async fn empty_body_get_short_circuits_validation() {
467        let schemas = Arc::new(SchemaRegistry::new());
468        let wrapped = Validate::from_schema(echo_pipe(), user_schema(), schemas);
469        let telemetry = noop_telemetry();
470        let request = Request::builder()
471            .method("GET")
472            .path("/todos")
473            .telemetry(telemetry)
474            .build()
475            .expect("builder");
476        let response = SendPipe::call(&wrapped, request).await.expect("call");
477        assert_eq!(
478            response.status, 200,
479            "GET with empty body must skip validate"
480        );
481    }
482
483    #[proxima::test]
484    async fn empty_body_delete_short_circuits_validation() {
485        let schemas = Arc::new(SchemaRegistry::new());
486        let wrapped = Validate::from_schema(echo_pipe(), user_schema(), schemas);
487        let telemetry = noop_telemetry();
488        let request = Request::builder()
489            .method("DELETE")
490            .path("/todos/abc")
491            .telemetry(telemetry)
492            .build()
493            .expect("builder");
494        let response = SendPipe::call(&wrapped, request).await.expect("call");
495        assert_eq!(
496            response.status, 200,
497            "DELETE with empty body must skip validate"
498        );
499    }
500
501    #[proxima::test]
502    async fn from_spec_errors_on_unknown_named_schema() {
503        let schemas = Arc::new(SchemaRegistry::new());
504        let outcome =
505            Validate::from_spec(echo_pipe(), &json!({"schema": "MissingShape"}), &schemas);
506        assert!(matches!(outcome, Err(ProximaError::Config(_))));
507    }
508
509    #[derive(Clone, PartialEq, Debug)]
510    struct EventMsg {
511        kind: &'static str,
512        value: u64,
513    }
514
515    #[derive(Clone)]
516    struct EventOp {
517        allowed_kind: &'static str,
518    }
519
520    impl Checkable<EventOp> for EventMsg {
521        type Out = EventMsg;
522
523        fn check(
524            self,
525            op: &EventOp,
526        ) -> impl Future<Output = Result<CheckOutcome<Self, Self::Out>, ProximaError>> + Send
527        {
528            let allowed = op.allowed_kind;
529            async move {
530                if self.kind == allowed {
531                    Ok(CheckOutcome::Pass(self))
532                } else {
533                    Ok(CheckOutcome::Reject(EventMsg {
534                        kind: "rejected",
535                        value: 0,
536                    }))
537                }
538            }
539        }
540    }
541
542    #[derive(Clone)]
543    struct EventSink;
544
545    impl SendPipe for EventSink {
546        type In = EventMsg;
547        type Out = EventMsg;
548        type Err = ProximaError;
549
550        fn call(
551            &self,
552            input: EventMsg,
553        ) -> impl Future<Output = Result<EventMsg, ProximaError>> + Send {
554            async move {
555                Ok(EventMsg {
556                    kind: "processed",
557                    value: input.value + 1,
558                })
559            }
560        }
561    }
562
563    #[proxima::test]
564    async fn validate_is_generic_over_a_non_http_payload() {
565        let stack = Validate::new(
566            EventSink,
567            EventOp {
568                allowed_kind: "ping",
569            },
570        );
571
572        let admitted = SendPipe::call(
573            &stack,
574            EventMsg {
575                kind: "ping",
576                value: 10,
577            },
578        )
579        .await
580        .expect("admitted call");
581        assert_eq!(
582            admitted,
583            EventMsg {
584                kind: "processed",
585                value: 11
586            }
587        );
588
589        let rejected = SendPipe::call(
590            &stack,
591            EventMsg {
592                kind: "unknown",
593                value: 99,
594            },
595        )
596        .await
597        .expect("rejected call");
598        assert_eq!(
599            rejected,
600            EventMsg {
601                kind: "rejected",
602                value: 0
603            }
604        );
605    }
606}