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#[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#[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#[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#[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#[cfg(feature = "std")]
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(untagged)]
237pub enum SchemaRef {
238 Named(String),
239 Inline(Schema),
240}
241
242#[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 #[must_use]
255 pub fn named(name: impl Into<String>) -> Self {
256 Self {
257 schema: SchemaRef::Named(name.into()),
258 }
259 }
260
261 #[must_use]
263 pub fn inline(schema: Schema) -> Self {
264 Self {
265 schema: SchemaRef::Inline(schema),
266 }
267 }
268
269 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#[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 #[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}