Skip to content

Commit e3d9dcc

Browse files
committed
Add zero-config auto route and schema registration
Introduces auto-registration of routes and OpenAPI schemas using linkme distributed slices. Adds `auto_route` and `auto_schema` modules, updates macros to register handlers and schemas automatically, and provides `RustApi::auto()` and `RustApiConfig` for zero-config app setup. Also updates OpenAPI spec registration, router, and middleware layer handling to support the new features, and adds tests for auto-registration.
1 parent 406273f commit e3d9dcc

File tree

15 files changed

+763
-9
lines changed

15 files changed

+763
-9
lines changed

Cargo.lock

Lines changed: 22 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ syn = { version = "2.0", features = ["full", "parsing", "extra-traits"] }
6262
quote = "1.0"
6363
proc-macro2 = "1.0"
6464
inventory = "0.3"
65+
linkme = "0.3"
6566

6667
# Validation
6768
validator = { version = "0.18", features = ["derive"] }

crates/rustapi-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ thiserror = { workspace = true }
4040
tracing = { workspace = true }
4141
tracing-subscriber = { workspace = true }
4242
inventory = { workspace = true }
43+
linkme = { workspace = true }
4344
uuid = { workspace = true }
4445
base64 = "0.22"
4546

crates/rustapi-core/src/app.rs

Lines changed: 270 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::error::Result;
44
use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
55
use crate::router::{MethodRouter, Router};
66
use crate::server::Server;
7+
use std::collections::HashMap;
78
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
89

910
/// Main application builder for RustAPI
@@ -53,6 +54,65 @@ impl RustApi {
5354
}
5455
}
5556

57+
/// Create a zero-config RustAPI application.
58+
///
59+
/// All routes decorated with `#[rustapi::get]`, `#[rustapi::post]`, etc.
60+
/// are automatically registered. Swagger UI is enabled at `/docs` by default.
61+
///
62+
/// # Example
63+
///
64+
/// ```rust,ignore
65+
/// use rustapi_rs::prelude::*;
66+
///
67+
/// #[rustapi::get("/users")]
68+
/// async fn list_users() -> Json<Vec<User>> {
69+
/// Json(vec![])
70+
/// }
71+
///
72+
/// #[rustapi::main]
73+
/// async fn main() -> Result<()> {
74+
/// // Zero config - routes are auto-registered!
75+
/// RustApi::auto()
76+
/// .run("0.0.0.0:8080")
77+
/// .await
78+
/// }
79+
/// ```
80+
#[cfg(feature = "swagger-ui")]
81+
pub fn auto() -> Self {
82+
// Build app with grouped auto-routes and auto-schemas, then enable docs.
83+
Self::new().mount_auto_routes_grouped().docs("/docs")
84+
}
85+
86+
/// Create a zero-config RustAPI application (without swagger-ui feature).
87+
///
88+
/// All routes decorated with `#[rustapi::get]`, `#[rustapi::post]`, etc.
89+
/// are automatically registered.
90+
#[cfg(not(feature = "swagger-ui"))]
91+
pub fn auto() -> Self {
92+
Self::new().mount_auto_routes_grouped()
93+
}
94+
95+
/// Create a configurable RustAPI application with auto-routes.
96+
///
97+
/// Provides builder methods for customization while still
98+
/// auto-registering all decorated routes.
99+
///
100+
/// # Example
101+
///
102+
/// ```rust,ignore
103+
/// use rustapi_rs::prelude::*;
104+
///
105+
/// RustApi::config()
106+
/// .docs_path("/api-docs")
107+
/// .body_limit(5 * 1024 * 1024) // 5MB
108+
/// .openapi_info("My API", "2.0.0", Some("API Description"))
109+
/// .run("0.0.0.0:8080")
110+
/// .await?;
111+
/// ```
112+
pub fn config() -> RustApiConfig {
113+
RustApiConfig::new()
114+
}
115+
56116
/// Set the global body size limit for request bodies
57117
///
58118
/// This protects against denial-of-service attacks via large payloads.
@@ -166,10 +226,58 @@ impl RustApi {
166226

167227
/// Configure OpenAPI info (title, version, description)
168228
pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
169-
self.openapi_spec = rustapi_openapi::OpenApiSpec::new(title, version);
170-
if let Some(desc) = description {
171-
self.openapi_spec = self.openapi_spec.description(desc);
229+
// NOTE: Do not reset the spec here; doing so would drop collected paths/schemas.
230+
// This is especially important for `RustApi::auto()` and `RustApi::config()`.
231+
self.openapi_spec.info.title = title.to_string();
232+
self.openapi_spec.info.version = version.to_string();
233+
self.openapi_spec.info.description = description.map(|d| d.to_string());
234+
self
235+
}
236+
237+
/// Get the current OpenAPI spec (for advanced usage/testing).
238+
pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
239+
&self.openapi_spec
240+
}
241+
242+
fn mount_auto_routes_grouped(mut self) -> Self {
243+
let routes = crate::auto_route::collect_auto_routes();
244+
let mut by_path: HashMap<String, MethodRouter> = HashMap::new();
245+
246+
for route in routes {
247+
let method_enum = match route.method {
248+
"GET" => http::Method::GET,
249+
"POST" => http::Method::POST,
250+
"PUT" => http::Method::PUT,
251+
"DELETE" => http::Method::DELETE,
252+
"PATCH" => http::Method::PATCH,
253+
_ => http::Method::GET,
254+
};
255+
256+
let path = if route.path.starts_with('/') {
257+
route.path.to_string()
258+
} else {
259+
format!("/{}", route.path)
260+
};
261+
262+
let entry = by_path.entry(path).or_insert_with(MethodRouter::new);
263+
entry.insert_boxed_with_operation(method_enum, route.handler, route.operation);
172264
}
265+
266+
let route_count = by_path
267+
.values()
268+
.map(|mr| mr.allowed_methods().len())
269+
.sum::<usize>();
270+
let path_count = by_path.len();
271+
272+
for (path, method_router) in by_path {
273+
self = self.route(&path, method_router);
274+
}
275+
276+
tracing::info!(paths = path_count, routes = route_count, "Auto-registered routes");
277+
278+
// Apply any auto-registered schemas.
279+
crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
280+
173281
self
174282
}
175283

@@ -186,7 +294,9 @@ impl RustApi {
186294
pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
187295
// Register operations in OpenAPI spec
188296
for (method, op) in &method_router.operations {
189-
self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op.clone());
297+
let mut op = op.clone();
298+
add_path_params_to_operation(path, &mut op);
299+
self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
190300
}
191301

192302
self.router = self.router.route(path, method_router);
@@ -229,9 +339,9 @@ impl RustApi {
229339
};
230340

231341
// Register operation in OpenAPI spec
232-
self.openapi_spec = self
233-
.openapi_spec
234-
.path(route.path, route.method, route.operation);
342+
let mut op = route.operation;
343+
add_path_params_to_operation(route.path, &mut op);
344+
self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
235345

236346
self.route_with_method(route.path, method_enum, route.handler)
237347
}
@@ -526,6 +636,57 @@ impl RustApi {
526636
}
527637
}
528638

639+
fn add_path_params_to_operation(path: &str, op: &mut rustapi_openapi::Operation) {
640+
let mut params: Vec<String> = Vec::new();
641+
let mut in_brace = false;
642+
let mut current = String::new();
643+
644+
for ch in path.chars() {
645+
match ch {
646+
'{' => {
647+
in_brace = true;
648+
current.clear();
649+
}
650+
'}' => {
651+
if in_brace {
652+
in_brace = false;
653+
if !current.is_empty() {
654+
params.push(current.clone());
655+
}
656+
}
657+
}
658+
_ => {
659+
if in_brace {
660+
current.push(ch);
661+
}
662+
}
663+
}
664+
}
665+
666+
if params.is_empty() {
667+
return;
668+
}
669+
670+
let op_params = op.parameters.get_or_insert_with(Vec::new);
671+
672+
for name in params {
673+
let already = op_params
674+
.iter()
675+
.any(|p| p.location == "path" && p.name == name);
676+
if already {
677+
continue;
678+
}
679+
680+
op_params.push(rustapi_openapi::Parameter {
681+
name,
682+
location: "path".to_string(),
683+
required: true,
684+
description: None,
685+
schema: rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" })),
686+
});
687+
}
688+
}
689+
529690
impl Default for RustApi {
530691
fn default() -> Self {
531692
Self::new()
@@ -557,3 +718,105 @@ fn unauthorized_response() -> crate::Response {
557718
)))
558719
.unwrap()
559720
}
721+
722+
/// Configuration builder for RustAPI with auto-routes
723+
pub struct RustApiConfig {
724+
docs_path: Option<String>,
725+
docs_enabled: bool,
726+
api_title: String,
727+
api_version: String,
728+
api_description: Option<String>,
729+
body_limit: Option<usize>,
730+
layers: LayerStack,
731+
}
732+
733+
impl RustApiConfig {
734+
pub fn new() -> Self {
735+
Self {
736+
docs_path: Some("/docs".to_string()),
737+
docs_enabled: true,
738+
api_title: "RustAPI".to_string(),
739+
api_version: "1.0.0".to_string(),
740+
api_description: None,
741+
body_limit: None,
742+
layers: LayerStack::new(),
743+
}
744+
}
745+
746+
/// Set the docs path (default: "/docs")
747+
pub fn docs_path(mut self, path: impl Into<String>) -> Self {
748+
self.docs_path = Some(path.into());
749+
self
750+
}
751+
752+
/// Enable or disable docs (default: true)
753+
pub fn docs_enabled(mut self, enabled: bool) -> Self {
754+
self.docs_enabled = enabled;
755+
self
756+
}
757+
758+
/// Set OpenAPI info
759+
pub fn openapi_info(
760+
mut self,
761+
title: impl Into<String>,
762+
version: impl Into<String>,
763+
description: Option<impl Into<String>>,
764+
) -> Self {
765+
self.api_title = title.into();
766+
self.api_version = version.into();
767+
self.api_description = description.map(|d| d.into());
768+
self
769+
}
770+
771+
/// Set body size limit
772+
pub fn body_limit(mut self, limit: usize) -> Self {
773+
self.body_limit = Some(limit);
774+
self
775+
}
776+
777+
/// Add a middleware layer
778+
pub fn layer<L>(mut self, layer: L) -> Self
779+
where
780+
L: MiddlewareLayer,
781+
{
782+
self.layers.push(Box::new(layer));
783+
self
784+
}
785+
786+
/// Build the RustApi instance
787+
pub fn build(self) -> RustApi {
788+
let mut app = RustApi::new().mount_auto_routes_grouped();
789+
790+
// Apply configuration
791+
if let Some(limit) = self.body_limit {
792+
app = app.body_limit(limit);
793+
}
794+
795+
app = app.openapi_info(
796+
&self.api_title,
797+
&self.api_version,
798+
self.api_description.as_deref(),
799+
);
800+
801+
#[cfg(feature = "swagger-ui")]
802+
if self.docs_enabled {
803+
if let Some(path) = self.docs_path {
804+
app = app.docs(&path);
805+
}
806+
}
807+
808+
// Apply layers
809+
// Note: layers are applied in reverse order in RustApi::layer logic (pushing to vec)
810+
app.layers.extend(self.layers.into_iter());
811+
812+
app
813+
}
814+
815+
/// Build and run the server
816+
pub async fn run(
817+
self,
818+
addr: impl AsRef<str>,
819+
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
820+
self.build().run(addr.as_ref()).await
821+
}
822+
}

0 commit comments

Comments
 (0)