domainstack_http/
lib.rs

1//! # domainstack-http
2//!
3//! Framework-agnostic HTTP validation helpers for domainstack.
4//!
5//! This crate provides reusable helper functions for converting DTOs to domain types and
6//! validating DTOs, with automatic conversion to structured error responses.
7//!
8//! ## What it provides
9//!
10//! - **`into_domain<T, Dto>(dto)`** - Convert DTO to domain type via `TryFrom`, return envelope error on failure
11//! - **`validate_dto<Dto>(dto)`** - Validate a DTO and return it, or return envelope error on failure
12//!
13//! These functions are used internally by framework adapters (`domainstack-axum`, `domainstack-actix`, `domainstack-rocket`)
14//! but can also be used directly in custom extractors or handlers.
15//!
16//! ## Example - `into_domain`
17//!
18//! ```rust
19//! use domainstack::prelude::*;
20//! use domainstack_http::into_domain;
21//!
22//! struct User { name: String, age: u8 }
23//!
24//! struct UserDto { name: String, age: u8 }
25//!
26//! impl TryFrom<UserDto> for User {
27//!     type Error = domainstack::ValidationError;
28//!
29//!     fn try_from(dto: UserDto) -> Result<Self, Self::Error> {
30//!         validate("name", dto.name.as_str(), &rules::min_len(2))?;
31//!         validate("age", &dto.age, &rules::range(18, 120))?;
32//!         Ok(Self { name: dto.name, age: dto.age })
33//!     }
34//! }
35//!
36//! let dto = UserDto { name: "Alice".to_string(), age: 30 };
37//! let user = into_domain::<User, UserDto>(dto).expect("Valid user");
38//! ```
39//!
40//! ## Example - `validate_dto`
41//!
42//! ```rust
43//! use domainstack::Validate;
44//! use domainstack_http::validate_dto;
45//!
46//! #[derive(Validate)]
47//! struct UserDto {
48//!     #[validate(length(min = 2, max = 50))]
49//!     name: String,
50//!
51//!     #[validate(range(min = 18, max = 120))]
52//!     age: u8,
53//! }
54//!
55//! let dto = UserDto { name: "Alice".to_string(), age: 30 };
56//! let validated = validate_dto(dto).expect("Valid DTO");
57//! ```
58
59use domainstack::{Validate, ValidationError};
60use domainstack_envelope::IntoEnvelopeError;
61
62#[allow(clippy::result_large_err)]
63pub fn into_domain<T, Dto>(dto: Dto) -> Result<T, error_envelope::Error>
64where
65    T: TryFrom<Dto, Error = ValidationError>,
66{
67    T::try_from(dto).map_err(|e| e.into_envelope_error())
68}
69
70#[allow(clippy::result_large_err)]
71pub fn validate_dto<Dto>(dto: Dto) -> Result<Dto, error_envelope::Error>
72where
73    Dto: Validate,
74{
75    dto.validate()
76        .map(|_| dto)
77        .map_err(|e| e.into_envelope_error())
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use domainstack::prelude::*;
84    use domainstack::Validate;
85
86    #[derive(Debug, Clone, Validate)]
87    struct EmailDto {
88        #[validate(length(min = 5, max = 255))]
89        value: String,
90    }
91
92    #[derive(Debug, Clone)]
93    struct Email(#[allow(dead_code)] String);
94
95    impl Email {
96        #[allow(clippy::result_large_err)]
97        pub fn new(raw: String) -> Result<Self, ValidationError> {
98            let rule = rules::min_len(5).and(rules::max_len(255));
99            validate("email", raw.as_str(), &rule)?;
100            Ok(Self(raw))
101        }
102    }
103
104    impl TryFrom<EmailDto> for Email {
105        type Error = ValidationError;
106
107        fn try_from(dto: EmailDto) -> Result<Self, Self::Error> {
108            Email::new(dto.value)
109        }
110    }
111
112    #[test]
113    fn test_into_domain_valid() {
114        let dto = EmailDto {
115            value: "test@example.com".to_string(),
116        };
117
118        let result = into_domain::<Email, EmailDto>(dto);
119        assert!(result.is_ok());
120    }
121
122    #[test]
123    fn test_into_domain_invalid_too_short() {
124        let dto = EmailDto {
125            value: "abc".to_string(),
126        };
127
128        let result = into_domain::<Email, EmailDto>(dto);
129        assert!(result.is_err());
130
131        let err = result.unwrap_err();
132        assert_eq!(err.status, 400);
133        assert!(err.details.is_some());
134    }
135
136    #[test]
137    fn test_into_domain_invalid_too_long() {
138        let dto = EmailDto {
139            value: "a".repeat(300),
140        };
141
142        let result = into_domain::<Email, EmailDto>(dto);
143        assert!(result.is_err());
144
145        let err = result.unwrap_err();
146        assert_eq!(err.status, 400);
147        assert!(err.details.is_some());
148    }
149
150    #[test]
151    fn test_validate_dto_valid() {
152        let dto = EmailDto {
153            value: "test@example.com".to_string(),
154        };
155
156        let result = validate_dto(dto.clone());
157        assert!(result.is_ok());
158        assert_eq!(result.unwrap().value, dto.value);
159    }
160
161    #[test]
162    fn test_validate_dto_invalid() {
163        let dto = EmailDto {
164            value: "abc".to_string(),
165        };
166
167        let result = validate_dto(dto);
168        assert!(result.is_err());
169
170        let err = result.unwrap_err();
171        assert_eq!(err.status, 400);
172        assert!(err.details.is_some());
173    }
174
175    #[derive(Debug, Clone, Validate)]
176    struct UserDto {
177        #[validate(length(min = 2, max = 50))]
178        name: String,
179
180        #[validate(range(min = 18, max = 120))]
181        age: u8,
182    }
183
184    #[derive(Debug)]
185    #[allow(dead_code)]
186    struct User {
187        name: String,
188        age: u8,
189    }
190
191    impl TryFrom<UserDto> for User {
192        type Error = ValidationError;
193
194        fn try_from(dto: UserDto) -> Result<Self, Self::Error> {
195            let mut err = ValidationError::new();
196
197            let name_rule = rules::min_len(2).and(rules::max_len(50));
198            if let Err(e) = validate("name", dto.name.as_str(), &name_rule) {
199                err.extend(e);
200            }
201
202            let age_rule = rules::range(18, 120);
203            if let Err(e) = validate("age", &dto.age, &age_rule) {
204                err.extend(e);
205            }
206
207            if !err.is_empty() {
208                return Err(err);
209            }
210
211            Ok(Self {
212                name: dto.name,
213                age: dto.age,
214            })
215        }
216    }
217
218    #[test]
219    fn test_into_domain_user_valid() {
220        let dto = UserDto {
221            name: "Alice".to_string(),
222            age: 30,
223        };
224
225        let result = into_domain::<User, UserDto>(dto);
226        assert!(result.is_ok());
227    }
228
229    #[test]
230    fn test_into_domain_user_invalid_multiple_errors() {
231        let dto = UserDto {
232            name: "A".to_string(),
233            age: 200,
234        };
235
236        let result = into_domain::<User, UserDto>(dto);
237        assert!(result.is_err());
238
239        let err = result.unwrap_err();
240        assert_eq!(err.status, 400);
241
242        let details = err.details.as_ref().unwrap();
243        let fields = details.as_object().unwrap().get("fields").unwrap();
244        let fields_obj = fields.as_object().unwrap();
245
246        assert!(fields_obj.contains_key("name"));
247        assert!(fields_obj.contains_key("age"));
248    }
249
250    #[test]
251    fn test_validate_dto_user_valid() {
252        let dto = UserDto {
253            name: "Alice".to_string(),
254            age: 30,
255        };
256
257        let result = validate_dto(dto.clone());
258        assert!(result.is_ok());
259        let validated = result.unwrap();
260        assert_eq!(validated.name, dto.name);
261        assert_eq!(validated.age, dto.age);
262    }
263
264    #[test]
265    fn test_validate_dto_user_invalid() {
266        let dto = UserDto {
267            name: "A".to_string(),
268            age: 200,
269        };
270
271        let result = validate_dto(dto);
272        assert!(result.is_err());
273
274        let err = result.unwrap_err();
275        assert_eq!(err.status, 400);
276
277        let details = err.details.as_ref().unwrap();
278        let fields = details.as_object().unwrap().get("fields").unwrap();
279        let fields_obj = fields.as_object().unwrap();
280
281        assert!(fields_obj.contains_key("name"));
282        assert!(fields_obj.contains_key("age"));
283    }
284}