Skip to content

Commit 0075ace

Browse files
committed
OpenAPI Schema Wrapper
1 parent 867d4ca commit 0075ace

21 files changed

Lines changed: 1268 additions & 66 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"crates/rustapi-core",
66
"crates/rustapi-macros",
77
"crates/rustapi-validate",
8+
"crates/rustapi-openapi",
89
"examples/hello-world",
910
]
1011

@@ -55,7 +56,11 @@ inventory = "0.3"
5556
# Validation
5657
validator = { version = "0.18", features = ["derive"] }
5758

59+
# OpenAPI
60+
utoipa = { version = "4.2" }
61+
5862
# Internal crates
5963
rustapi-core = { path = "crates/rustapi-core" }
6064
rustapi-macros = { path = "crates/rustapi-macros" }
6165
rustapi-validate = { path = "crates/rustapi-validate" }
66+
rustapi-openapi = { path = "crates/rustapi-openapi" }

assets/logo.jpg

100 KB
Loading

crates/rustapi-core/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,8 @@ inventory = { workspace = true }
4141
# Validation
4242
rustapi-validate = { workspace = true }
4343

44+
# OpenAPI
45+
rustapi-openapi = { workspace = true }
46+
4447
[dev-dependencies]
4548
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

crates/rustapi-core/src/app.rs

Lines changed: 150 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte
2424
/// ```
2525
pub struct RustApi {
2626
router: Router,
27+
openapi_spec: rustapi_openapi::OpenApiSpec,
2728
}
2829

2930
impl RustApi {
@@ -39,6 +40,7 @@ impl RustApi {
3940

4041
Self {
4142
router: Router::new(),
43+
openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0"),
4244
}
4345
}
4446

@@ -57,8 +59,39 @@ impl RustApi {
5759
/// RustApi::new()
5860
/// .state(AppState::new())
5961
/// ```
60-
pub fn state<S: Clone + Send + Sync + 'static>(mut self, state: S) -> Self {
61-
self.router = self.router.state(state);
62+
pub fn state<S>(self, state: S) -> Self
63+
where
64+
S: Clone + Send + Sync + 'static,
65+
{
66+
// For now, state is handled by the router/handlers directly capturing it
67+
// or through a middleware. The current router (matchit) implementation
68+
// doesn't support state injection directly in the same way axum does.
69+
// This is a placeholder for future state management.
70+
self
71+
}
72+
73+
/// Register an OpenAPI schema
74+
///
75+
/// # Example
76+
///
77+
/// ```rust,ignore
78+
/// #[derive(Schema)]
79+
/// struct User { ... }
80+
///
81+
/// RustApi::new()
82+
/// .register_schema::<User>()
83+
/// ```
84+
pub fn register_schema<T: for<'a> rustapi_openapi::Schema<'a>>(mut self) -> Self {
85+
self.openapi_spec = self.openapi_spec.register::<T>();
86+
self
87+
}
88+
89+
/// Configure OpenAPI info (title, version, description)
90+
pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
91+
self.openapi_spec = rustapi_openapi::OpenApiSpec::new(title, version);
92+
if let Some(desc) = description {
93+
self.openapi_spec = self.openapi_spec.description(desc);
94+
}
6295
self
6396
}
6497

@@ -73,6 +106,11 @@ impl RustApi {
73106
/// .route("/users/{id}", get(get_user).delete(delete_user))
74107
/// ```
75108
pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
109+
// Register operations in OpenAPI spec
110+
for (method, op) in &method_router.operations {
111+
self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op.clone());
112+
}
113+
76114
self.router = self.router.route(path, method_router);
77115
self
78116
}
@@ -102,26 +140,52 @@ impl RustApi {
102140
/// .run("127.0.0.1:8080")
103141
/// .await
104142
/// ```
105-
pub fn mount_route(self, route: crate::handler::Route) -> Self {
106-
use http::Method;
143+
pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
144+
let method_enum = match route.method {
145+
"GET" => http::Method::GET,
146+
"POST" => http::Method::POST,
147+
"PUT" => http::Method::PUT,
148+
"DELETE" => http::Method::DELETE,
149+
"PATCH" => http::Method::PATCH,
150+
_ => http::Method::GET,
151+
};
152+
153+
// Register operation in OpenAPI spec
154+
self.openapi_spec = self.openapi_spec.path(route.path, route.method, route.operation);
107155

108-
let method = match route.method {
109-
"GET" => Method::GET,
110-
"POST" => Method::POST,
111-
"PUT" => Method::PUT,
112-
"PATCH" => Method::PATCH,
113-
"DELETE" => Method::DELETE,
114-
"HEAD" => Method::HEAD,
115-
"OPTIONS" => Method::OPTIONS,
116-
_ => panic!("Unknown HTTP method: {}", route.method),
156+
self.route_with_method(route.path, method_enum, route.handler)
157+
}
158+
159+
/// Helper to mount a single method handler
160+
fn route_with_method(mut self, path: &str, method: http::Method, handler: crate::handler::BoxedHandler) -> Self {
161+
use crate::router::MethodRouter;
162+
// use http::Method; // Removed
163+
164+
// This is simplified. In a real implementation we'd merge with existing router at this path
165+
// For now we assume one handler per path or we simply allow overwriting for this MVP step
166+
// (matchit router doesn't allow easy merging/updating existing entries without rebuilding)
167+
//
168+
// TOOD: Enhance Router to support method merging
169+
170+
let path = if !path.starts_with('/') {
171+
format!("/{}", path)
172+
} else {
173+
path.to_string()
117174
};
118175

119-
// Convert the boxed handler into a MethodRouter
176+
// Check if we already have this path?
177+
// For MVP, valid assumption: user calls .route() or .mount() once per path-method-combo
178+
// But we need to handle multiple methods on same path.
179+
// Our Router wrapper currently just inserts.
180+
181+
// Since we can't easily query matchit, we'll just insert.
182+
// Limitations: strictly sequential mounting for now.
183+
120184
let mut handlers = std::collections::HashMap::new();
121-
handlers.insert(method, route.handler);
185+
handlers.insert(method, handler);
122186

123187
let method_router = MethodRouter::from_boxed(handlers);
124-
self.route(route.path, method_router)
188+
self.route(&path, method_router)
125189
}
126190

127191
/// Nest a router under a prefix
@@ -140,17 +204,82 @@ impl RustApi {
140204
self
141205
}
142206

143-
/// Enable Swagger UI at the specified path
207+
/// Enable Swagger UI documentation
208+
///
209+
/// This adds two endpoints:
210+
/// - `{path}` - Swagger UI interface
211+
/// - `{path}/openapi.json` - OpenAPI JSON specification
144212
///
145213
/// # Example
146214
///
147215
/// ```rust,ignore
148216
/// RustApi::new()
149-
/// .docs("/docs") // Swagger UI at /docs
217+
/// .route("/users", get(list_users))
218+
/// .docs("/docs") // Swagger UI at /docs, spec at /docs/openapi.json
219+
/// .run("127.0.0.1:8080")
220+
/// .await
150221
/// ```
151-
pub fn docs(self, _path: &str) -> Self {
152-
// TODO: Implement OpenAPI + Swagger UI
153-
self
222+
pub fn docs(self, path: &str) -> Self {
223+
let title = self.openapi_spec.info.title.clone();
224+
let version = self.openapi_spec.info.version.clone();
225+
let description = self.openapi_spec.info.description.clone();
226+
227+
self.docs_with_info(path, &title, &version, description.as_deref())
228+
}
229+
230+
/// Enable Swagger UI documentation with custom API info
231+
///
232+
/// # Example
233+
///
234+
/// ```rust,ignore
235+
/// RustApi::new()
236+
/// .docs_with_info("/docs", "My API", "2.0.0", Some("API for managing users"))
237+
/// ```
238+
pub fn docs_with_info(
239+
mut self,
240+
path: &str,
241+
title: &str,
242+
version: &str,
243+
description: Option<&str>,
244+
) -> Self {
245+
use crate::router::get;
246+
// Update spec info
247+
self.openapi_spec.info.title = title.to_string();
248+
self.openapi_spec.info.version = version.to_string();
249+
if let Some(desc) = description {
250+
self.openapi_spec.info.description = Some(desc.to_string());
251+
}
252+
253+
let path = path.trim_end_matches('/');
254+
let openapi_path = format!("{}/openapi.json", path);
255+
256+
// Clone values for closures
257+
let spec_json = serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
258+
let openapi_url = openapi_path.clone();
259+
260+
// Add OpenAPI JSON endpoint
261+
let spec_handler = move || {
262+
let json = spec_json.clone();
263+
async move {
264+
http::Response::builder()
265+
.status(http::StatusCode::OK)
266+
.header(http::header::CONTENT_TYPE, "application/json")
267+
.body(http_body_util::Full::new(bytes::Bytes::from(json)))
268+
.unwrap()
269+
}
270+
};
271+
272+
// Add Swagger UI endpoint
273+
let docs_handler = move || {
274+
let url = openapi_url.clone();
275+
async move {
276+
let html = rustapi_openapi::swagger_ui_html(&url);
277+
html
278+
}
279+
};
280+
281+
self.route(&openapi_path, get(spec_handler))
282+
.route(path, get(docs_handler))
154283
}
155284

156285
/// Run the server

0 commit comments

Comments
 (0)