Skip to main content

nidus_validation/
lib.rs

1#![deny(missing_docs)]
2
3//! Validation pipe support.
4
5use std::ops::{Deref, DerefMut};
6
7use axum::{
8    Json,
9    extract::{FromRequest, Request, rejection::JsonRejection},
10    response::IntoResponse,
11};
12use garde::Validate;
13use http::StatusCode;
14use serde::{Serialize, de::DeserializeOwned};
15
16/// Typed request transformation or validation pipe.
17pub trait Pipe<Input>: Send + Sync + 'static {
18    /// Output produced by this pipe.
19    type Output;
20
21    /// Error emitted when transformation or validation fails.
22    type Error: std::error::Error + Send + Sync + 'static;
23
24    /// Transforms or validates the input value.
25    fn transform(&self, input: Input) -> std::result::Result<Self::Output, Self::Error>;
26}
27
28/// Request validation pipe backed by the `garde` crate.
29#[derive(Clone, Debug, Default)]
30pub struct ValidationPipe;
31
32impl ValidationPipe {
33    /// Creates a validation pipe.
34    pub fn new() -> Self {
35        Self
36    }
37
38    /// Validates and returns the input value unchanged when valid.
39    pub fn transform<T>(&self, input: T) -> Result<T>
40    where
41        T: Validate<Context = ()>,
42    {
43        input.validate().map_err(ValidationPipeError::Validation)?;
44        Ok(input)
45    }
46}
47
48impl<T> Pipe<T> for ValidationPipe
49where
50    T: Validate<Context = ()>,
51{
52    type Output = T;
53    type Error = ValidationPipeError;
54
55    fn transform(&self, input: T) -> std::result::Result<Self::Output, Self::Error> {
56        ValidationPipe::transform(self, input)
57    }
58}
59
60/// Axum extractor that deserializes a JSON body and validates it with [`ValidationPipe`].
61///
62/// JSON parsing errors keep Axum's normal JSON rejection response. Values that
63/// parse successfully but fail validation return Nidus's stable validation
64/// error response.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct ValidatedJson<T>(pub T);
67
68impl<T> ValidatedJson<T> {
69    /// Consumes the extractor and returns the validated value.
70    pub fn into_inner(self) -> T {
71        self.0
72    }
73}
74
75impl<T> Deref for ValidatedJson<T> {
76    type Target = T;
77
78    fn deref(&self) -> &Self::Target {
79        &self.0
80    }
81}
82
83impl<T> DerefMut for ValidatedJson<T> {
84    fn deref_mut(&mut self) -> &mut Self::Target {
85        &mut self.0
86    }
87}
88
89impl<S, T> FromRequest<S> for ValidatedJson<T>
90where
91    S: Send + Sync,
92    T: DeserializeOwned + Validate<Context = ()>,
93{
94    type Rejection = ValidatedJsonRejection;
95
96    async fn from_request(req: Request, state: &S) -> std::result::Result<Self, Self::Rejection> {
97        let Json(value) = Json::<T>::from_request(req, state)
98            .await
99            .map_err(ValidatedJsonRejection::Json)?;
100        let value = ValidationPipe::new()
101            .transform(value)
102            .map_err(ValidatedJsonRejection::Validation)?;
103        Ok(Self(value))
104    }
105}
106
107/// Rejection emitted by [`ValidatedJson`].
108#[derive(Debug, thiserror::Error)]
109pub enum ValidatedJsonRejection {
110    /// The request body was not valid JSON for the target type.
111    #[error(transparent)]
112    Json(#[from] JsonRejection),
113    /// The parsed JSON failed validation.
114    #[error(transparent)]
115    Validation(#[from] ValidationPipeError),
116}
117
118impl IntoResponse for ValidatedJsonRejection {
119    fn into_response(self) -> axum::response::Response {
120        match self {
121            Self::Json(error) => error.into_response(),
122            Self::Validation(error) => error.into_response(),
123        }
124    }
125}
126
127/// Result type for validation pipes.
128pub type Result<T> = std::result::Result<T, ValidationPipeError>;
129
130/// Errors emitted by validation pipes.
131#[derive(Debug, thiserror::Error)]
132pub enum ValidationPipeError {
133    /// The input failed validation.
134    #[error("validation failed: {0}")]
135    Validation(#[from] garde::Report),
136}
137
138impl ValidationPipeError {
139    /// Returns the HTTP status code corresponding to this validation failure.
140    pub fn status_code(&self) -> StatusCode {
141        StatusCode::UNPROCESSABLE_ENTITY
142    }
143
144    /// Returns the stable machine-readable error code.
145    pub fn code(&self) -> &'static str {
146        "validation_failed"
147    }
148
149    /// Returns field-level validation errors in deterministic order.
150    pub fn field_errors(&self) -> Vec<FieldValidationError> {
151        match self {
152            Self::Validation(errors) => {
153                let mut field_errors = errors
154                    .iter()
155                    .map(|(path, error)| field_error(&path.to_string(), error.message()))
156                    .collect::<Vec<_>>();
157                field_errors.sort_by(|left, right| {
158                    left.field
159                        .cmp(&right.field)
160                        .then_with(|| left.code.cmp(&right.code))
161                });
162                field_errors
163            }
164        }
165    }
166}
167
168fn field_error(field: &str, message: &str) -> FieldValidationError {
169    FieldValidationError {
170        field: field.to_owned(),
171        code: validation_code(message).to_owned(),
172        message: Some(message.to_owned()),
173    }
174}
175
176fn validation_code(message: &str) -> &'static str {
177    if message.starts_with("not a valid email") {
178        "email"
179    } else if message.starts_with("length is ") {
180        "length"
181    } else if message.starts_with("not a valid url") {
182        "url"
183    } else if message.starts_with("not a valid IP") || message.starts_with("not a valid IPv") {
184        "ip"
185    } else if message.starts_with("lower than ") || message.starts_with("greater than ") {
186        "range"
187    } else {
188        "invalid"
189    }
190}
191
192impl IntoResponse for ValidationPipeError {
193    fn into_response(self) -> axum::response::Response {
194        let status = self.status_code();
195        let body = Json(ValidationErrorBody {
196            error: ValidationErrorDetails {
197                code: self.code(),
198                message: "request validation failed",
199                fields: self.field_errors(),
200            },
201        });
202        (status, body).into_response()
203    }
204}
205
206#[derive(Debug, Serialize)]
207struct ValidationErrorBody {
208    error: ValidationErrorDetails,
209}
210
211#[derive(Debug, Serialize)]
212struct ValidationErrorDetails {
213    code: &'static str,
214    message: &'static str,
215    fields: Vec<FieldValidationError>,
216}
217
218/// Stable field-level validation error summary.
219#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
220pub struct FieldValidationError {
221    field: String,
222    code: String,
223    message: Option<String>,
224}
225
226impl FieldValidationError {
227    /// Returns the invalid field name.
228    pub fn field(&self) -> &str {
229        &self.field
230    }
231
232    /// Returns the validation rule error code.
233    pub fn code(&self) -> &str {
234        &self.code
235    }
236
237    /// Returns the optional validation message.
238    pub fn message(&self) -> Option<&str> {
239        self.message.as_deref()
240    }
241}