domainstack_axum/
lib.rs

1//! # domainstack-axum
2//!
3//! Axum integration for domainstack validation with automatic DTO→Domain conversion.
4//!
5//! This crate provides Axum extractors that automatically deserialize, validate, and convert
6//! DTOs to domain types—returning structured error responses on validation failure.
7//!
8//! ## What it provides
9//!
10//! - **`DomainJson<T, Dto>`** - Extract JSON, validate, and convert DTO to domain type in one step
11//! - **`ValidatedJson<Dto>`** - Extract and validate a DTO without domain conversion
12//! - **`ErrorResponse`** - Automatic structured error responses with field-level details
13//!
14//! ## Example - DomainJson
15//!
16//! ```rust,no_run
17//! use axum::{routing::post, Router, Json};
18//! use domainstack::prelude::*;
19//! use domainstack_axum::{DomainJson, ErrorResponse};
20//! use serde::Deserialize;
21//!
22//! #[derive(Deserialize)]
23//! struct CreateUserDto {
24//!     name: String,
25//!     age: u8,
26//! }
27//!
28//! struct User {
29//!     name: String,
30//!     age: u8,
31//! }
32//!
33//! impl TryFrom<CreateUserDto> for User {
34//!     type Error = domainstack::ValidationError;
35//!
36//!     fn try_from(dto: CreateUserDto) -> Result<Self, Self::Error> {
37//!         validate("name", dto.name.as_str(), &rules::min_len(2).and(rules::max_len(50)))?;
38//!         validate("age", &dto.age, &rules::range(18, 120))?;
39//!         Ok(Self { name: dto.name, age: dto.age })
40//!     }
41//! }
42//!
43//! // Type alias for cleaner handler signatures
44//! type UserJson = DomainJson<User, CreateUserDto>;
45//!
46//! async fn create_user(
47//!     UserJson { domain: user, .. }: UserJson
48//! ) -> Result<Json<String>, ErrorResponse> {
49//!     // user is guaranteed valid here!
50//!     Ok(Json(format!("Created: {}", user.name)))
51//! }
52//!
53//! // In your main.rs or server setup:
54//! fn setup_router() -> Router {
55//!     Router::new().route("/users", post(create_user))
56//! }
57//! ```
58//!
59//! ## Example - ValidatedJson
60//!
61//! ```rust,ignore
62//! use axum::{routing::post, Router, Json};
63//! use domainstack::Validate;
64//! use domainstack_axum::{ValidatedJson, ErrorResponse};
65//!
66//! #[derive(Debug, Validate, serde::Deserialize)]
67//! struct UserDto {
68//!     #[validate(length(min = 2, max = 50))]
69//!     name: String,
70//!
71//!     #[validate(range(min = 18, max = 120))]
72//!     age: u8,
73//! }
74//!
75//! async fn create_user(
76//!     ValidatedJson(dto): ValidatedJson<UserDto>
77//! ) -> Result<Json<UserDto>, ErrorResponse> {
78//!     // dto is guaranteed valid here!
79//!     Ok(Json(dto))
80//! }
81//! ```
82//!
83//! ## Error Response Format
84//!
85//! On validation failure, returns a 400 Bad Request with structured errors:
86//!
87//! ```json
88//! {
89//!   "code": "VALIDATION",
90//!   "status": 400,
91//!   "message": "Validation failed with 2 errors",
92//!   "details": {
93//!     "fields": {
94//!       "name": [{"code": "min_length", "message": "Must be at least 2 characters"}],
95//!       "age": [{"code": "out_of_range", "message": "Must be between 18 and 120"}]
96//!     }
97//!   }
98//! }
99//! ```
100
101use axum::{
102    extract::{FromRequest, Request},
103    response::{IntoResponse, Response},
104    Json,
105};
106use domainstack::ValidationError;
107use std::marker::PhantomData;
108
109pub struct DomainJson<T, Dto = ()> {
110    pub domain: T,
111    _dto: PhantomData<Dto>,
112}
113
114impl<T, Dto> DomainJson<T, Dto> {
115    pub fn new(domain: T) -> Self {
116        Self {
117            domain,
118            _dto: PhantomData,
119        }
120    }
121}
122
123pub struct ErrorResponse(pub error_envelope::Error);
124
125impl From<error_envelope::Error> for ErrorResponse {
126    fn from(err: error_envelope::Error) -> Self {
127        ErrorResponse(err)
128    }
129}
130
131impl From<ValidationError> for ErrorResponse {
132    fn from(err: ValidationError) -> Self {
133        use domainstack_envelope::IntoEnvelopeError;
134        ErrorResponse(err.into_envelope_error())
135    }
136}
137
138#[axum::async_trait]
139impl<T, Dto, S> FromRequest<S> for DomainJson<T, Dto>
140where
141    Dto: serde::de::DeserializeOwned,
142    T: TryFrom<Dto, Error = ValidationError>,
143    S: Send + Sync,
144{
145    type Rejection = ErrorResponse;
146
147    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
148        let Json(dto) = Json::<Dto>::from_request(req, state).await.map_err(|e| {
149            ErrorResponse(error_envelope::Error::bad_request(format!(
150                "Invalid JSON: {}",
151                e
152            )))
153        })?;
154
155        let domain = domainstack_http::into_domain(dto).map_err(ErrorResponse)?;
156
157        Ok(DomainJson::new(domain))
158    }
159}
160
161impl IntoResponse for ErrorResponse {
162    fn into_response(self) -> Response {
163        let status = axum::http::StatusCode::from_u16(self.0.status)
164            .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
165
166        let body = serde_json::to_string(&self.0).unwrap_or_else(|_| {
167            r#"{"code":"INTERNAL","message":"Serialization failed"}"#.to_string()
168        });
169
170        (status, body).into_response()
171    }
172}
173
174pub struct ValidatedJson<Dto>(pub Dto);
175
176#[axum::async_trait]
177impl<Dto, S> FromRequest<S> for ValidatedJson<Dto>
178where
179    Dto: serde::de::DeserializeOwned + domainstack::Validate,
180    S: Send + Sync,
181{
182    type Rejection = ErrorResponse;
183
184    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
185        let Json(dto) = Json::<Dto>::from_request(req, state).await.map_err(|e| {
186            ErrorResponse(error_envelope::Error::bad_request(format!(
187                "Invalid JSON: {}",
188                e
189            )))
190        })?;
191
192        dto.validate().map(|_| ValidatedJson(dto)).map_err(|e| {
193            use domainstack_envelope::IntoEnvelopeError;
194            ErrorResponse(e.into_envelope_error())
195        })
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use axum::{routing::post, Router};
203    use domainstack::{prelude::*, Validate};
204
205    // DTOs used with DomainJson are just serde shapes
206    // Validation happens during TryFrom conversion to domain
207    #[derive(Debug, Clone, serde::Deserialize)]
208    struct UserDto {
209        name: String,
210        age: u8,
211    }
212
213    #[derive(Debug, serde::Serialize)]
214    struct User {
215        name: String,
216        age: u8,
217    }
218
219    impl TryFrom<UserDto> for User {
220        type Error = ValidationError;
221
222        fn try_from(dto: UserDto) -> Result<Self, Self::Error> {
223            let mut err = ValidationError::new();
224
225            let name_rule = rules::min_len(2).and(rules::max_len(50));
226            if let Err(e) = validate("name", dto.name.as_str(), &name_rule) {
227                err.extend(e);
228            }
229
230            let age_rule = rules::range(18, 120);
231            if let Err(e) = validate("age", &dto.age, &age_rule) {
232                err.extend(e);
233            }
234
235            if !err.is_empty() {
236                return Err(err);
237            }
238
239            Ok(Self {
240                name: dto.name,
241                age: dto.age,
242            })
243        }
244    }
245
246    async fn create_user(DomainJson { domain: user, .. }: DomainJson<User, UserDto>) -> Json<User> {
247        Json(user)
248    }
249
250    type UserJson = DomainJson<User, UserDto>;
251
252    async fn create_user_with_alias(UserJson { domain: user, .. }: UserJson) -> Json<User> {
253        Json(user)
254    }
255
256    async fn create_user_result_style(
257        UserJson { domain: user, .. }: UserJson,
258    ) -> Result<Json<User>, ErrorResponse> {
259        Ok(Json(user))
260    }
261
262    #[tokio::test]
263    async fn test_domain_json_valid() {
264        let app = Router::new().route("/", post(create_user));
265
266        let server = axum_test::TestServer::new(app).unwrap();
267
268        let response = server
269            .post("/")
270            .json(&serde_json::json!({"name": "Alice", "age": 30}))
271            .await;
272
273        response.assert_status_ok();
274    }
275
276    #[tokio::test]
277    async fn test_domain_json_invalid() {
278        let app = Router::new().route("/", post(create_user));
279
280        let server = axum_test::TestServer::new(app).unwrap();
281
282        let response = server
283            .post("/")
284            .json(&serde_json::json!({"name": "A", "age": 200}))
285            .await;
286
287        response.assert_status_bad_request();
288
289        let body: serde_json::Value = response.json();
290
291        assert!(body["details"].is_object());
292        assert_eq!(
293            body["message"].as_str().unwrap(),
294            "Validation failed with 2 errors"
295        );
296
297        let details = body["details"].as_object().unwrap();
298        let fields = details["fields"].as_object().unwrap();
299
300        assert!(fields.contains_key("name"));
301        assert!(fields.contains_key("age"));
302    }
303
304    #[tokio::test]
305    async fn test_domain_json_malformed_json() {
306        let app = Router::new().route("/", post(create_user));
307
308        let server = axum_test::TestServer::new(app).unwrap();
309
310        let response = server.post("/").text("{invalid json").await;
311
312        response.assert_status_bad_request();
313    }
314
315    #[tokio::test]
316    async fn test_domain_json_missing_fields() {
317        let app = Router::new().route("/", post(create_user));
318
319        let server = axum_test::TestServer::new(app).unwrap();
320
321        let response = server
322            .post("/")
323            .json(&serde_json::json!({"name": "Alice"}))
324            .await;
325
326        response.assert_status_bad_request();
327    }
328
329    #[tokio::test]
330    async fn test_type_alias_pattern() {
331        let app = Router::new().route("/", post(create_user_with_alias));
332
333        let server = axum_test::TestServer::new(app).unwrap();
334
335        let response = server
336            .post("/")
337            .json(&serde_json::json!({"name": "Alice", "age": 30}))
338            .await;
339
340        response.assert_status_ok();
341    }
342
343    #[tokio::test]
344    async fn test_result_style_handler() {
345        let app = Router::new().route("/", post(create_user_result_style));
346
347        let server = axum_test::TestServer::new(app).unwrap();
348
349        let response = server
350            .post("/")
351            .json(&serde_json::json!({"name": "Alice", "age": 30}))
352            .await;
353
354        response.assert_status_ok();
355    }
356
357    // ValidatedJson tests - DTOs that derive Validate
358    #[derive(Debug, Clone, Validate, serde::Deserialize, serde::Serialize)]
359    struct ValidatedUserDto {
360        #[validate(length(min = 2, max = 50))]
361        name: String,
362
363        #[validate(range(min = 18, max = 120))]
364        age: u8,
365    }
366
367    async fn accept_validated_dto(
368        ValidatedJson(dto): ValidatedJson<ValidatedUserDto>,
369    ) -> Json<ValidatedUserDto> {
370        Json(dto)
371    }
372
373    #[tokio::test]
374    async fn test_validated_json_valid() {
375        let app = Router::new().route("/", post(accept_validated_dto));
376
377        let server = axum_test::TestServer::new(app).unwrap();
378
379        let response = server
380            .post("/")
381            .json(&serde_json::json!({"name": "Alice", "age": 30}))
382            .await;
383
384        response.assert_status_ok();
385        let body: ValidatedUserDto = response.json();
386        assert_eq!(body.name, "Alice");
387        assert_eq!(body.age, 30);
388    }
389
390    #[tokio::test]
391    async fn test_validated_json_invalid() {
392        let app = Router::new().route("/", post(accept_validated_dto));
393
394        let server = axum_test::TestServer::new(app).unwrap();
395
396        let response = server
397            .post("/")
398            .json(&serde_json::json!({"name": "A", "age": 200}))
399            .await;
400
401        response.assert_status_bad_request();
402
403        let body: serde_json::Value = response.json();
404        assert_eq!(
405            body["message"].as_str().unwrap(),
406            "Validation failed with 2 errors"
407        );
408
409        let details = body["details"].as_object().unwrap();
410        let fields = details["fields"].as_object().unwrap();
411
412        assert!(fields.contains_key("name"));
413        assert!(fields.contains_key("age"));
414    }
415
416    #[tokio::test]
417    async fn test_validated_json_malformed_json() {
418        let app = Router::new().route("/", post(accept_validated_dto));
419
420        let server = axum_test::TestServer::new(app).unwrap();
421
422        let response = server.post("/").text("{invalid json").await;
423
424        response.assert_status_bad_request();
425    }
426
427    #[tokio::test]
428    async fn test_error_response_into_response() {
429        let err = ErrorResponse(error_envelope::Error::bad_request("Test error"));
430        let response = err.into_response();
431        assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
432    }
433
434    #[tokio::test]
435    async fn test_error_response_custom_status() {
436        let mut err = error_envelope::Error::bad_request("Test");
437        err.status = 422;
438        let response = ErrorResponse(err).into_response();
439        assert_eq!(
440            response.status(),
441            axum::http::StatusCode::UNPROCESSABLE_ENTITY
442        );
443    }
444}