domainstack_actix/
lib.rs

1//! # domainstack-actix
2//!
3//! Actix-web integration for domainstack validation with automatic DTO→Domain conversion.
4//!
5//! This crate provides Actix-web 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 actix_web::{post, web, App, HttpServer};
18//! use domainstack::prelude::*;
19//! use domainstack_actix::{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//! #[post("/users")]
47//! async fn create_user(
48//!     UserJson { domain: user, .. }: UserJson
49//! ) -> Result<web::Json<String>, ErrorResponse> {
50//!     // user is guaranteed valid here!
51//!     Ok(web::Json(format!("Created: {}", user.name)))
52//! }
53//!
54//! #[actix_web::main]
55//! async fn main() -> std::io::Result<()> {
56//!     HttpServer::new(|| App::new().service(create_user))
57//!         .bind(("127.0.0.1", 8080))?
58//!         .run()
59//!         .await
60//! }
61//! ```
62//!
63//! ## Example - ValidatedJson
64//!
65//! ```rust,ignore
66//! use actix_web::{post, web};
67//! use domainstack::Validate;
68//! use domainstack_actix::{ValidatedJson, ErrorResponse};
69//!
70//! #[derive(Debug, Validate, serde::Deserialize)]
71//! struct UserDto {
72//!     #[validate(length(min = 2, max = 50))]
73//!     name: String,
74//!
75//!     #[validate(range(min = 18, max = 120))]
76//!     age: u8,
77//! }
78//!
79//! #[post("/users")]
80//! async fn create_user(
81//!     ValidatedJson(dto): ValidatedJson<UserDto>
82//! ) -> Result<web::Json<UserDto>, ErrorResponse> {
83//!     // dto is guaranteed valid here!
84//!     Ok(web::Json(dto))
85//! }
86//! ```
87//!
88//! ## Error Response Format
89//!
90//! On validation failure, returns a 400 Bad Request with structured errors:
91//!
92//! ```json
93//! {
94//!   "code": "VALIDATION",
95//!   "status": 400,
96//!   "message": "Validation failed with 2 errors",
97//!   "details": {
98//!     "fields": {
99//!       "name": [{"code": "min_length", "message": "Must be at least 2 characters"}],
100//!       "age": [{"code": "out_of_range", "message": "Must be between 18 and 120"}]
101//!     }
102//!   }
103//! }
104//! ```
105
106use actix_web::{error::ResponseError, web, FromRequest, HttpRequest, HttpResponse};
107use domainstack::ValidationError;
108use futures::future::{ready, Ready};
109use std::marker::PhantomData;
110
111pub struct DomainJson<T, Dto = ()> {
112    pub domain: T,
113    _dto: PhantomData<Dto>,
114}
115
116impl<T, Dto> DomainJson<T, Dto> {
117    pub fn new(domain: T) -> Self {
118        Self {
119            domain,
120            _dto: PhantomData,
121        }
122    }
123}
124
125pub struct ErrorResponse(pub error_envelope::Error);
126
127impl From<error_envelope::Error> for ErrorResponse {
128    fn from(err: error_envelope::Error) -> Self {
129        ErrorResponse(err)
130    }
131}
132
133impl From<ValidationError> for ErrorResponse {
134    fn from(err: ValidationError) -> Self {
135        use domainstack_envelope::IntoEnvelopeError;
136        ErrorResponse(err.into_envelope_error())
137    }
138}
139
140impl<T, Dto> FromRequest for DomainJson<T, Dto>
141where
142    Dto: serde::de::DeserializeOwned,
143    T: TryFrom<Dto, Error = ValidationError>,
144{
145    type Error = ErrorResponse;
146    type Future = Ready<Result<Self, Self::Error>>;
147
148    fn from_request(req: &HttpRequest, payload: &mut actix_web::dev::Payload) -> Self::Future {
149        let json_fut = web::Json::<Dto>::from_request(req, payload);
150
151        // Note: Using block_on() here is required by Actix-web's synchronous extractor pattern.
152        // FromRequest returns a Ready<T> future (not async), so we must synchronously extract
153        // the JSON. This is the standard pattern for Actix-web 4.x extractors.
154        // Performance note: This blocks the current task but does not block the async runtime.
155        // For truly async extraction, consider using web::Json::from_request directly in your
156        // handler and calling into_domain() on the DTO.
157        ready(match futures::executor::block_on(json_fut) {
158            Ok(web::Json(dto)) => domainstack_http::into_domain(dto)
159                .map(DomainJson::new)
160                .map_err(ErrorResponse),
161            Err(e) => Err(ErrorResponse(error_envelope::Error::bad_request(format!(
162                "Invalid JSON: {}",
163                e
164            )))),
165        })
166    }
167}
168
169impl ResponseError for ErrorResponse {
170    fn status_code(&self) -> actix_web::http::StatusCode {
171        actix_web::http::StatusCode::from_u16(self.0.status)
172            .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR)
173    }
174
175    fn error_response(&self) -> HttpResponse {
176        HttpResponse::build(self.status_code()).json(&self.0)
177    }
178}
179
180impl std::fmt::Debug for ErrorResponse {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(f, "ErrorResponse({:?})", self.0)
183    }
184}
185
186impl std::fmt::Display for ErrorResponse {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(f, "{}", self.0.message)
189    }
190}
191
192pub struct ValidatedJson<Dto>(pub Dto);
193
194impl<Dto> FromRequest for ValidatedJson<Dto>
195where
196    Dto: serde::de::DeserializeOwned + domainstack::Validate,
197{
198    type Error = ErrorResponse;
199    type Future = Ready<Result<Self, Self::Error>>;
200
201    fn from_request(req: &HttpRequest, payload: &mut actix_web::dev::Payload) -> Self::Future {
202        let json_fut = web::Json::<Dto>::from_request(req, payload);
203
204        // Note: See DomainJson implementation for explanation of block_on() usage.
205        // This is the standard Actix-web 4.x extractor pattern.
206        ready(match futures::executor::block_on(json_fut) {
207            Ok(web::Json(dto)) => dto.validate().map(|_| ValidatedJson(dto)).map_err(|e| {
208                use domainstack_envelope::IntoEnvelopeError;
209                ErrorResponse(e.into_envelope_error())
210            }),
211            Err(e) => Err(ErrorResponse(error_envelope::Error::bad_request(format!(
212                "Invalid JSON: {}",
213                e
214            )))),
215        })
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use actix_web::{test, web, App};
223    use domainstack::{prelude::*, Validate};
224
225    // DTOs used with DomainJson are just serde shapes
226    // Validation happens during TryFrom conversion to domain
227    #[derive(Debug, Clone, serde::Deserialize)]
228    struct UserDto {
229        name: String,
230        age: u8,
231    }
232
233    #[derive(Debug, serde::Serialize)]
234    struct User {
235        name: String,
236        age: u8,
237    }
238
239    impl TryFrom<UserDto> for User {
240        type Error = ValidationError;
241
242        fn try_from(dto: UserDto) -> Result<Self, Self::Error> {
243            let mut err = ValidationError::new();
244
245            let name_rule = rules::min_len(2).and(rules::max_len(50));
246            if let Err(e) = validate("name", dto.name.as_str(), &name_rule) {
247                err.extend(e);
248            }
249
250            let age_rule = rules::range(18, 120);
251            if let Err(e) = validate("age", &dto.age, &age_rule) {
252                err.extend(e);
253            }
254
255            if !err.is_empty() {
256                return Err(err);
257            }
258
259            Ok(Self {
260                name: dto.name,
261                age: dto.age,
262            })
263        }
264    }
265
266    async fn create_user(
267        DomainJson { domain: user, .. }: DomainJson<User, UserDto>,
268    ) -> web::Json<User> {
269        web::Json(user)
270    }
271
272    type UserJson = DomainJson<User, UserDto>;
273
274    async fn create_user_with_alias(UserJson { domain: user, .. }: UserJson) -> web::Json<User> {
275        web::Json(user)
276    }
277
278    async fn create_user_result_style(
279        UserJson { domain: user, .. }: UserJson,
280    ) -> Result<web::Json<User>, ErrorResponse> {
281        Ok(web::Json(user))
282    }
283
284    #[actix_rt::test]
285    async fn test_domain_json_valid() {
286        let app = test::init_service(App::new().route("/", web::post().to(create_user))).await;
287
288        let req = test::TestRequest::post()
289            .uri("/")
290            .set_json(serde_json::json!({"name": "Alice", "age": 30}))
291            .to_request();
292
293        let resp = test::call_service(&app, req).await;
294        assert_eq!(resp.status(), 200);
295    }
296
297    #[actix_rt::test]
298    async fn test_domain_json_invalid() {
299        let app = test::init_service(App::new().route("/", web::post().to(create_user))).await;
300
301        let req = test::TestRequest::post()
302            .uri("/")
303            .set_json(serde_json::json!({"name": "A", "age": 200}))
304            .to_request();
305
306        let resp = test::call_service(&app, req).await;
307        assert_eq!(resp.status(), 400);
308
309        let body: serde_json::Value = test::read_body_json(resp).await;
310        assert!(body["details"].is_object());
311        assert_eq!(
312            body["message"].as_str().unwrap(),
313            "Validation failed with 2 errors"
314        );
315
316        let details = body["details"].as_object().unwrap();
317        let fields = details["fields"].as_object().unwrap();
318
319        assert!(fields.contains_key("name"));
320        assert!(fields.contains_key("age"));
321    }
322
323    #[actix_rt::test]
324    async fn test_domain_json_malformed_json() {
325        let app = test::init_service(App::new().route("/", web::post().to(create_user))).await;
326
327        let req = test::TestRequest::post()
328            .uri("/")
329            .set_payload("{invalid json")
330            .insert_header(("content-type", "application/json"))
331            .to_request();
332
333        let resp = test::call_service(&app, req).await;
334        assert_eq!(resp.status(), 400);
335    }
336
337    #[actix_rt::test]
338    async fn test_domain_json_missing_fields() {
339        let app = test::init_service(App::new().route("/", web::post().to(create_user))).await;
340
341        let req = test::TestRequest::post()
342            .uri("/")
343            .set_json(serde_json::json!({"name": "Alice"}))
344            .to_request();
345
346        let resp = test::call_service(&app, req).await;
347        assert_eq!(resp.status(), 400);
348    }
349
350    #[actix_rt::test]
351    async fn test_type_alias_pattern() {
352        let app =
353            test::init_service(App::new().route("/", web::post().to(create_user_with_alias))).await;
354
355        let req = test::TestRequest::post()
356            .uri("/")
357            .set_json(serde_json::json!({"name": "Alice", "age": 30}))
358            .to_request();
359
360        let resp = test::call_service(&app, req).await;
361        assert_eq!(resp.status(), 200);
362    }
363
364    #[actix_rt::test]
365    async fn test_result_style_handler() {
366        let app =
367            test::init_service(App::new().route("/", web::post().to(create_user_result_style)))
368                .await;
369
370        let req = test::TestRequest::post()
371            .uri("/")
372            .set_json(serde_json::json!({"name": "Alice", "age": 30}))
373            .to_request();
374
375        let resp = test::call_service(&app, req).await;
376        assert_eq!(resp.status(), 200);
377    }
378
379    // ValidatedJson tests - DTOs that derive Validate
380    #[derive(Debug, Clone, Validate, serde::Deserialize, serde::Serialize)]
381    struct ValidatedUserDto {
382        #[validate(length(min = 2, max = 50))]
383        name: String,
384
385        #[validate(range(min = 18, max = 120))]
386        age: u8,
387    }
388
389    async fn accept_validated_dto(
390        ValidatedJson(dto): ValidatedJson<ValidatedUserDto>,
391    ) -> web::Json<ValidatedUserDto> {
392        web::Json(dto)
393    }
394
395    #[actix_rt::test]
396    async fn test_validated_json_valid() {
397        let app =
398            test::init_service(App::new().route("/", web::post().to(accept_validated_dto))).await;
399
400        let req = test::TestRequest::post()
401            .uri("/")
402            .set_json(serde_json::json!({"name": "Alice", "age": 30}))
403            .to_request();
404
405        let resp = test::call_service(&app, req).await;
406        assert_eq!(resp.status(), 200);
407
408        let body: ValidatedUserDto = test::read_body_json(resp).await;
409        assert_eq!(body.name, "Alice");
410        assert_eq!(body.age, 30);
411    }
412
413    #[actix_rt::test]
414    async fn test_validated_json_invalid() {
415        let app =
416            test::init_service(App::new().route("/", web::post().to(accept_validated_dto))).await;
417
418        let req = test::TestRequest::post()
419            .uri("/")
420            .set_json(serde_json::json!({"name": "A", "age": 200}))
421            .to_request();
422
423        let resp = test::call_service(&app, req).await;
424        assert_eq!(resp.status(), 400);
425
426        let body: serde_json::Value = test::read_body_json(resp).await;
427        assert_eq!(
428            body["message"].as_str().unwrap(),
429            "Validation failed with 2 errors"
430        );
431
432        let details = body["details"].as_object().unwrap();
433        let fields = details["fields"].as_object().unwrap();
434
435        assert!(fields.contains_key("name"));
436        assert!(fields.contains_key("age"));
437    }
438
439    #[actix_rt::test]
440    async fn test_validated_json_malformed_json() {
441        let app =
442            test::init_service(App::new().route("/", web::post().to(accept_validated_dto))).await;
443
444        let req = test::TestRequest::post()
445            .uri("/")
446            .set_payload("{invalid json")
447            .insert_header(("content-type", "application/json"))
448            .to_request();
449
450        let resp = test::call_service(&app, req).await;
451        assert_eq!(resp.status(), 400);
452    }
453
454    #[actix_rt::test]
455    async fn test_error_response_debug() {
456        let err = ErrorResponse(error_envelope::Error::bad_request("Test error"));
457        let debug_str = format!("{:?}", err);
458        assert!(debug_str.contains("ErrorResponse"));
459    }
460
461    #[actix_rt::test]
462    async fn test_error_response_display() {
463        let err = ErrorResponse(error_envelope::Error::bad_request("Custom message"));
464        let display_str = format!("{}", err);
465        assert_eq!(display_str, "Custom message");
466    }
467
468    #[actix_rt::test]
469    async fn test_error_response_status_code() {
470        use actix_web::ResponseError;
471
472        let err = ErrorResponse(error_envelope::Error::bad_request("Bad request"));
473        assert_eq!(err.status_code().as_u16(), 400);
474
475        let mut custom_err = error_envelope::Error::bad_request("Custom");
476        custom_err.status = 422;
477        let err = ErrorResponse(custom_err);
478        assert_eq!(err.status_code().as_u16(), 422);
479    }
480}