Skip to content

Commit 9bba035

Browse files
committed
!
1 parent 3d92157 commit 9bba035

20 files changed

Lines changed: 1459 additions & 36 deletions

File tree

Cargo.lock

Lines changed: 487 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ members = [
44
"crates/rustapi-rs",
55
"crates/rustapi-core",
66
"crates/rustapi-macros",
7+
"crates/rustapi-validate",
8+
"crates/rustapi-openapi",
79
"examples/hello-world",
810
]
911

@@ -48,9 +50,17 @@ pin-project-lite = "0.2"
4850
# Proc macros
4951
syn = { version = "2.0", features = ["full", "parsing", "extra-traits"] }
5052
quote = "1.0"
53+
54+
# Validation
55+
validator = { version = "0.18", features = ["derive"] }
5156
proc-macro2 = "1.0"
5257
inventory = "0.3"
5358

59+
# OpenAPI
60+
utoipa = { version = "5", features = ["preserve_order"] }
61+
5462
# Internal crates
5563
rustapi-core = { path = "crates/rustapi-core" }
5664
rustapi-macros = { path = "crates/rustapi-macros" }
65+
rustapi-openapi = { path = "crates/rustapi-openapi" }
66+
rustapi-validate = { path = "crates/rustapi-validate" }

crates/rustapi-core/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ serde = { workspace = true }
2727
serde_json = { workspace = true }
2828
serde_urlencoded = "0.7"
2929

30+
# Validation
31+
validator = { workspace = true }
32+
33+
# OpenAPI
34+
rustapi-openapi = { workspace = true }
35+
3036
# Middleware
3137
tower = { workspace = true }
3238
tower-service = { workspace = true }

crates/rustapi-core/src/app.rs

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
//! RustApi application builder
22
33
use crate::error::Result;
4-
use crate::router::{MethodRouter, Router};
4+
use crate::router::{get, MethodRouter, Router};
55
use crate::server::Server;
6+
use crate::response::{Html, Response};
7+
use crate::extract::Json;
8+
use rustapi_openapi::{OpenApiDoc, swagger_ui_html};
9+
use std::sync::Arc;
610
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
711

812
/// Main application builder for RustAPI
@@ -24,6 +28,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte
2428
/// ```
2529
pub struct RustApi {
2630
router: Router,
31+
openapi: Option<OpenApiDoc>,
32+
docs_path: Option<String>,
2733
}
2834

2935
impl RustApi {
@@ -39,6 +45,8 @@ impl RustApi {
3945

4046
Self {
4147
router: Router::new(),
48+
openapi: None,
49+
docs_path: None,
4250
}
4351
}
4452

@@ -100,16 +108,40 @@ impl RustApi {
100108
self
101109
}
102110

111+
/// Configure OpenAPI documentation
112+
///
113+
/// # Example
114+
///
115+
/// ```rust,ignore
116+
/// use rustapi_openapi::OpenApiDoc;
117+
///
118+
/// RustApi::new()
119+
/// .openapi(OpenApiDoc::new("My API", "1.0.0")
120+
/// .description("A sample API")
121+
/// .server("http://localhost:8080"))
122+
/// ```
123+
pub fn openapi(mut self, doc: OpenApiDoc) -> Self {
124+
self.openapi = Some(doc);
125+
self
126+
}
127+
103128
/// Enable Swagger UI at the specified path
104129
///
130+
/// Also enables `/openapi.json` endpoint automatically.
131+
///
105132
/// # Example
106133
///
107134
/// ```rust,ignore
108135
/// RustApi::new()
109-
/// .docs("/docs") // Swagger UI at /docs
136+
/// .openapi(OpenApiDoc::new("My API", "1.0.0"))
137+
/// .docs("/docs") // Swagger UI at /docs, spec at /openapi.json
110138
/// ```
111-
pub fn docs(self, _path: &str) -> Self {
112-
// TODO: Implement OpenAPI + Swagger UI
139+
pub fn docs(mut self, path: &str) -> Self {
140+
self.docs_path = Some(path.to_string());
141+
// Create default OpenAPI doc if not set
142+
if self.openapi.is_none() {
143+
self.openapi = Some(OpenApiDoc::new("RustAPI", "1.0.0"));
144+
}
113145
self
114146
}
115147

@@ -123,7 +155,42 @@ impl RustApi {
123155
/// .run("127.0.0.1:8080")
124156
/// .await
125157
/// ```
126-
pub async fn run(self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
158+
pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
159+
// Add OpenAPI endpoints if configured
160+
if let Some(doc) = self.openapi.take() {
161+
let doc = Arc::new(doc);
162+
let docs_path = self.docs_path.take();
163+
164+
// Add /openapi.json endpoint
165+
let doc_for_json = doc.clone();
166+
self.router = self.router.route(
167+
"/openapi.json",
168+
get(move || {
169+
let doc = doc_for_json.clone();
170+
async move {
171+
OpenApiJsonResponse(doc.to_json())
172+
}
173+
}),
174+
);
175+
176+
// Add Swagger UI endpoint if docs path is set
177+
if let Some(path) = docs_path {
178+
let title = doc.title().to_string();
179+
self.router = self.router.route(
180+
&path,
181+
get(move || {
182+
let title = title.clone();
183+
async move {
184+
Html(swagger_ui_html("/openapi.json", &title))
185+
}
186+
}),
187+
);
188+
tracing::info!("📚 Swagger UI available at http://{}{}", addr, path);
189+
}
190+
191+
tracing::info!("📄 OpenAPI spec at http://{}/openapi.json", addr);
192+
}
193+
127194
let server = Server::new(self.router);
128195
server.run(addr).await
129196
}
@@ -139,3 +206,20 @@ impl Default for RustApi {
139206
Self::new()
140207
}
141208
}
209+
210+
/// Response type for OpenAPI JSON
211+
struct OpenApiJsonResponse(String);
212+
213+
impl crate::response::IntoResponse for OpenApiJsonResponse {
214+
fn into_response(self) -> Response {
215+
use bytes::Bytes;
216+
use http::{header, StatusCode};
217+
use http_body_util::Full;
218+
219+
http::Response::builder()
220+
.status(StatusCode::OK)
221+
.header(header::CONTENT_TYPE, "application/json")
222+
.body(Full::new(Bytes::from(self.0)))
223+
.unwrap()
224+
}
225+
}

crates/rustapi-core/src/error.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use http::StatusCode;
44
use serde::Serialize;
5+
use std::collections::HashMap;
56
use std::fmt;
67

78
/// Result type alias for RustAPI operations
@@ -33,6 +34,36 @@ pub struct FieldError {
3334
pub code: String,
3435
/// Human-readable message
3536
pub message: String,
37+
/// Optional parameters (e.g., min/max values)
38+
#[serde(skip_serializing_if = "Option::is_none")]
39+
pub params: Option<HashMap<String, serde_json::Value>>,
40+
}
41+
42+
impl FieldError {
43+
/// Create a new field error
44+
pub fn new(field: impl Into<String>, code: impl Into<String>, message: impl Into<String>) -> Self {
45+
Self {
46+
field: field.into(),
47+
code: code.into(),
48+
message: message.into(),
49+
params: None,
50+
}
51+
}
52+
53+
/// Create a field error with parameters
54+
pub fn with_params(
55+
field: impl Into<String>,
56+
code: impl Into<String>,
57+
message: impl Into<String>,
58+
params: HashMap<String, serde_json::Value>,
59+
) -> Self {
60+
Self {
61+
field: field.into(),
62+
code: code.into(),
63+
message: message.into(),
64+
params: Some(params),
65+
}
66+
}
3667
}
3768

3869
impl ApiError {
@@ -88,6 +119,43 @@ impl ApiError {
88119
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
89120
}
90121

122+
/// Create from validator::ValidationErrors
123+
pub fn from_validation_errors(errors: validator::ValidationErrors) -> Self {
124+
let mut field_errors = Vec::new();
125+
126+
for (field, error_kinds) in errors.field_errors() {
127+
for error in error_kinds {
128+
let code = error.code.to_string();
129+
let message = error
130+
.message
131+
.as_ref()
132+
.map(|m| m.to_string())
133+
.unwrap_or_else(|| format!("Validation failed for field '{}'", field));
134+
135+
let params = if error.params.is_empty() {
136+
None
137+
} else {
138+
let mut map = HashMap::new();
139+
for (key, value) in &error.params {
140+
if let Ok(json_value) = serde_json::to_value(value) {
141+
map.insert(key.to_string(), json_value);
142+
}
143+
}
144+
Some(map)
145+
};
146+
147+
field_errors.push(FieldError {
148+
field: field.to_string(),
149+
code,
150+
message,
151+
params,
152+
});
153+
}
154+
}
155+
156+
Self::validation(field_errors)
157+
}
158+
91159
/// Add internal details (for logging, hidden from response in prod)
92160
pub fn with_internal(mut self, details: impl Into<String>) -> Self {
93161
self.internal = Some(details.into());

crates/rustapi-core/src/extract.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use serde::Serialize;
1313
use std::future::Future;
1414
use std::ops::{Deref, DerefMut};
1515
use std::str::FromStr;
16+
use validator::Validate;
1617

1718
/// Trait for extracting data from request parts (headers, path, query)
1819
///
@@ -287,4 +288,80 @@ impl_from_request_parts_for_primitives!(
287288
String
288289
);
289290

291+
/// Validated JSON body extractor
292+
///
293+
/// Parses the request body as JSON, deserializes into type `T`,
294+
/// and validates using the `validator` crate.
295+
/// Returns 422 Unprocessable Entity on validation failure.
296+
///
297+
/// # Example
298+
///
299+
/// ```rust,ignore
300+
/// use validator::Validate;
301+
///
302+
/// #[derive(Deserialize, Validate)]
303+
/// struct CreateUser {
304+
/// #[validate(email)]
305+
/// email: String,
306+
/// #[validate(length(min = 3, max = 50))]
307+
/// name: String,
308+
/// }
309+
///
310+
/// async fn create_user(ValidatedJson(body): ValidatedJson<CreateUser>) -> impl IntoResponse {
311+
/// // body is already deserialized AND validated
312+
/// }
313+
/// ```
314+
#[derive(Debug, Clone, Copy, Default)]
315+
pub struct ValidatedJson<T>(pub T);
316+
317+
impl<T: DeserializeOwned + Validate + Send> FromRequest for ValidatedJson<T> {
318+
async fn from_request(req: &mut Request) -> Result<Self> {
319+
let body = req.take_body().ok_or_else(|| {
320+
ApiError::internal("Body already consumed")
321+
})?;
322+
323+
let value: T = serde_json::from_slice(&body)?;
324+
325+
// Validate the deserialized value
326+
value.validate().map_err(|e| ApiError::from_validation_errors(e))?;
327+
328+
Ok(ValidatedJson(value))
329+
}
330+
}
331+
332+
impl<T> Deref for ValidatedJson<T> {
333+
type Target = T;
334+
335+
fn deref(&self) -> &Self::Target {
336+
&self.0
337+
}
338+
}
339+
340+
impl<T> DerefMut for ValidatedJson<T> {
341+
fn deref_mut(&mut self) -> &mut Self::Target {
342+
&mut self.0
343+
}
344+
}
345+
346+
impl<T> From<T> for ValidatedJson<T> {
347+
fn from(value: T) -> Self {
348+
ValidatedJson(value)
349+
}
350+
}
351+
352+
// IntoResponse for ValidatedJson - same as Json
353+
impl<T: Serialize> IntoResponse for ValidatedJson<T> {
354+
fn into_response(self) -> crate::response::Response {
355+
match serde_json::to_vec(&self.0) {
356+
Ok(body) => http::Response::builder()
357+
.status(StatusCode::OK)
358+
.header(header::CONTENT_TYPE, "application/json")
359+
.body(Full::new(Bytes::from(body)))
360+
.unwrap(),
361+
Err(err) => ApiError::internal(format!("Failed to serialize response: {}", err))
362+
.into_response(),
363+
}
364+
}
365+
}
366+
290367
// Re-export Json from response for extraction (they share the type)

crates/rustapi-core/src/lib.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ mod server;
1515

1616
// Public API
1717
pub use app::RustApi;
18-
pub use error::{ApiError, Result};
19-
pub use extract::{Body, FromRequest, FromRequestParts, Json, Path, Query, State};
18+
pub use error::{ApiError, FieldError, Result};
19+
pub use extract::{Body, FromRequest, FromRequestParts, Json, Path, Query, State, ValidatedJson};
2020
pub use handler::{Handler, HandlerService};
2121
pub use request::Request;
22-
pub use response::{Created, Html, IntoResponse, NoContent, Redirect, Response};
22+
pub use response::{created, json, no_content, text, Created, Html, IntoResponse, NoContent, Redirect, Response};
2323
pub use router::{delete, get, patch, post, put, MethodRouter, Router};
24+
25+
// Re-export validator traits for users
26+
pub use validator::Validate;
27+
28+
// Re-export OpenAPI types
29+
pub use rustapi_openapi::OpenApiDoc;

0 commit comments

Comments
 (0)