Skip to content

Commit 3d92157

Browse files
committed
Add rustapi-validate crate for validation support
Introduces the rustapi-validate crate, providing a validation system for the RustAPI framework. Includes error types, a Validate trait wrapping validator, and re-exports for easy integration. Implements standard error formats and comprehensive tests.
1 parent 1ae4c75 commit 3d92157

4 files changed

Lines changed: 498 additions & 0 deletions

File tree

crates/rustapi-validate/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "rustapi-validate"
3+
description = "Validation system for RustAPI framework"
4+
version.workspace = true
5+
edition.workspace = true
6+
authors.workspace = true
7+
license.workspace = true
8+
9+
[dependencies]
10+
# Validation (internal - not exposed in public API)
11+
validator = { version = "0.18", features = ["derive"] }
12+
13+
# Serialization
14+
serde = { workspace = true }
15+
serde_json = { workspace = true }
16+
17+
# Error handling
18+
thiserror = { workspace = true }
19+
20+
# HTTP types for response
21+
http = { workspace = true }
22+
23+
[dev-dependencies]
24+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
//! Validation error types and JSON error format.
2+
3+
use serde::{Deserialize, Serialize};
4+
use std::collections::HashMap;
5+
use std::fmt;
6+
7+
/// A single field validation error.
8+
#[derive(Debug, Clone, Serialize, Deserialize)]
9+
pub struct FieldError {
10+
/// The field name that failed validation
11+
pub field: String,
12+
/// The validation rule code (e.g., "email", "length", "range")
13+
pub code: String,
14+
/// Human-readable error message
15+
pub message: String,
16+
/// Optional additional parameters (e.g., min/max values)
17+
#[serde(skip_serializing_if = "Option::is_none")]
18+
pub params: Option<HashMap<String, serde_json::Value>>,
19+
}
20+
21+
impl FieldError {
22+
/// Create a new field error.
23+
pub fn new(field: impl Into<String>, code: impl Into<String>, message: impl Into<String>) -> Self {
24+
Self {
25+
field: field.into(),
26+
code: code.into(),
27+
message: message.into(),
28+
params: None,
29+
}
30+
}
31+
32+
/// Create a field error with parameters.
33+
pub fn with_params(
34+
field: impl Into<String>,
35+
code: impl Into<String>,
36+
message: impl Into<String>,
37+
params: HashMap<String, serde_json::Value>,
38+
) -> Self {
39+
Self {
40+
field: field.into(),
41+
code: code.into(),
42+
message: message.into(),
43+
params: Some(params),
44+
}
45+
}
46+
}
47+
48+
/// Internal error structure for JSON serialization.
49+
#[derive(Debug, Clone, Serialize, Deserialize)]
50+
struct ErrorBody {
51+
#[serde(rename = "type")]
52+
error_type: String,
53+
message: String,
54+
fields: Vec<FieldError>,
55+
}
56+
57+
/// Wrapper for the error response format.
58+
#[derive(Debug, Clone, Serialize, Deserialize)]
59+
struct ErrorWrapper {
60+
error: ErrorBody,
61+
}
62+
63+
/// Validation error containing all field errors.
64+
///
65+
/// This type serializes to the standard RustAPI error format:
66+
///
67+
/// ```json
68+
/// {
69+
/// "error": {
70+
/// "type": "validation_error",
71+
/// "message": "Validation failed",
72+
/// "fields": [...]
73+
/// }
74+
/// }
75+
/// ```
76+
#[derive(Debug, Clone)]
77+
pub struct ValidationError {
78+
/// Collection of field-level validation errors
79+
pub fields: Vec<FieldError>,
80+
/// Custom error message (default: "Validation failed")
81+
pub message: String,
82+
}
83+
84+
impl ValidationError {
85+
/// Create a new validation error with field errors.
86+
pub fn new(fields: Vec<FieldError>) -> Self {
87+
Self {
88+
fields,
89+
message: "Validation failed".to_string(),
90+
}
91+
}
92+
93+
/// Create a validation error with a custom message.
94+
pub fn with_message(fields: Vec<FieldError>, message: impl Into<String>) -> Self {
95+
Self {
96+
fields,
97+
message: message.into(),
98+
}
99+
}
100+
101+
/// Create a validation error for a single field.
102+
pub fn field(field: impl Into<String>, code: impl Into<String>, message: impl Into<String>) -> Self {
103+
Self::new(vec![FieldError::new(field, code, message)])
104+
}
105+
106+
/// Check if there are any validation errors.
107+
pub fn is_empty(&self) -> bool {
108+
self.fields.is_empty()
109+
}
110+
111+
/// Get the number of field errors.
112+
pub fn len(&self) -> usize {
113+
self.fields.len()
114+
}
115+
116+
/// Add a field error.
117+
pub fn add(&mut self, error: FieldError) {
118+
self.fields.push(error);
119+
}
120+
121+
/// Convert validator errors to our format.
122+
pub fn from_validator_errors(errors: validator::ValidationErrors) -> Self {
123+
let mut field_errors = Vec::new();
124+
125+
for (field, error_kinds) in errors.field_errors() {
126+
for error in error_kinds {
127+
let code = error.code.to_string();
128+
let message = error
129+
.message
130+
.as_ref()
131+
.map(|m| m.to_string())
132+
.unwrap_or_else(|| format!("Validation failed for field '{}'", field));
133+
134+
let params = if error.params.is_empty() {
135+
None
136+
} else {
137+
let mut map = HashMap::new();
138+
for (key, value) in &error.params {
139+
if let Ok(json_value) = serde_json::to_value(value) {
140+
map.insert(key.to_string(), json_value);
141+
}
142+
}
143+
Some(map)
144+
};
145+
146+
field_errors.push(FieldError {
147+
field: field.to_string(),
148+
code,
149+
message,
150+
params,
151+
});
152+
}
153+
}
154+
155+
Self::new(field_errors)
156+
}
157+
}
158+
159+
impl fmt::Display for ValidationError {
160+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161+
write!(f, "{}: {} field error(s)", self.message, self.fields.len())
162+
}
163+
}
164+
165+
impl std::error::Error for ValidationError {}
166+
167+
impl Serialize for ValidationError {
168+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
169+
where
170+
S: serde::Serializer,
171+
{
172+
let wrapper = ErrorWrapper {
173+
error: ErrorBody {
174+
error_type: "validation_error".to_string(),
175+
message: self.message.clone(),
176+
fields: self.fields.clone(),
177+
},
178+
};
179+
wrapper.serialize(serializer)
180+
}
181+
}
182+
183+
impl<'de> Deserialize<'de> for ValidationError {
184+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
185+
where
186+
D: serde::Deserializer<'de>,
187+
{
188+
let wrapper = ErrorWrapper::deserialize(deserializer)?;
189+
Ok(Self {
190+
fields: wrapper.error.fields,
191+
message: wrapper.error.message,
192+
})
193+
}
194+
}
195+
196+
#[cfg(test)]
197+
mod tests {
198+
use super::*;
199+
200+
#[test]
201+
fn field_error_creation() {
202+
let error = FieldError::new("email", "email", "Invalid email format");
203+
assert_eq!(error.field, "email");
204+
assert_eq!(error.code, "email");
205+
assert_eq!(error.message, "Invalid email format");
206+
assert!(error.params.is_none());
207+
}
208+
209+
#[test]
210+
fn validation_error_serialization() {
211+
let error = ValidationError::new(vec![
212+
FieldError::new("email", "email", "Invalid email format"),
213+
]);
214+
215+
let json = serde_json::to_value(&error).unwrap();
216+
217+
assert_eq!(json["error"]["type"], "validation_error");
218+
assert_eq!(json["error"]["message"], "Validation failed");
219+
assert_eq!(json["error"]["fields"][0]["field"], "email");
220+
}
221+
222+
#[test]
223+
fn validation_error_display() {
224+
let error = ValidationError::new(vec![
225+
FieldError::new("email", "email", "Invalid email"),
226+
FieldError::new("age", "range", "Out of range"),
227+
]);
228+
229+
assert_eq!(error.to_string(), "Validation failed: 2 field error(s)");
230+
}
231+
}

crates/rustapi-validate/src/lib.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//! # RustAPI Validation
2+
//!
3+
//! Validation system for RustAPI framework. Provides declarative validation
4+
//! on structs using the `#[derive(Validate)]` macro.
5+
//!
6+
//! ## Example
7+
//!
8+
//! ```rust
9+
//! use rustapi_validate::prelude::*;
10+
//!
11+
//! #[derive(Validate)]
12+
//! struct CreateUser {
13+
//! #[validate(email)]
14+
//! email: String,
15+
//!
16+
//! #[validate(length(min = 3, max = 50))]
17+
//! username: String,
18+
//!
19+
//! #[validate(range(min = 18, max = 120))]
20+
//! age: u8,
21+
//! }
22+
//! ```
23+
//!
24+
//! ## Validation Rules
25+
//!
26+
//! - `email` - Validates email format
27+
//! - `length(min = X, max = Y)` - String length validation
28+
//! - `range(min = X, max = Y)` - Numeric range validation
29+
//! - `regex = "..."` - Regex pattern validation
30+
//! - `non_empty` - Non-empty string/collection validation
31+
//! - `nested` - Validates nested structs
32+
//!
33+
//! ## Error Format
34+
//!
35+
//! Validation errors return a 422 Unprocessable Entity with JSON:
36+
//!
37+
//! ```json
38+
//! {
39+
//! "error": {
40+
//! "type": "validation_error",
41+
//! "message": "Validation failed",
42+
//! "fields": [
43+
//! {"field": "email", "code": "email", "message": "Invalid email format"},
44+
//! {"field": "age", "code": "range", "message": "Value must be between 18 and 120"}
45+
//! ]
46+
//! }
47+
//! }
48+
//! ```
49+
50+
mod error;
51+
mod validate;
52+
53+
pub use error::{FieldError, ValidationError};
54+
pub use validate::Validate;
55+
56+
// Re-export the derive macro from validator (wrapped)
57+
// In a full implementation, we'd create our own proc-macro
58+
// For now, we use validator's derive with our own trait
59+
pub use validator::Validate as ValidatorValidate;
60+
61+
/// Prelude module for validation
62+
pub mod prelude {
63+
pub use crate::error::{FieldError, ValidationError};
64+
pub use crate::validate::Validate;
65+
pub use validator::Validate as ValidatorValidate;
66+
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::*;
71+
72+
#[test]
73+
fn validation_error_to_json() {
74+
let error = ValidationError::new(vec![
75+
FieldError::new("email", "email", "Invalid email format"),
76+
FieldError::new("age", "range", "Value must be between 18 and 120"),
77+
]);
78+
79+
let json = serde_json::to_string_pretty(&error).unwrap();
80+
assert!(json.contains("validation_error"));
81+
assert!(json.contains("email"));
82+
assert!(json.contains("age"));
83+
}
84+
}

0 commit comments

Comments
 (0)