Skip to content

Commit 87f05a2

Browse files
authored
Merge pull request #38 from Tuntii/feature/framework-ergonomics-update
Add TypedPath and ApiError derive macros with typed routing
2 parents 0e36205 + efe762b commit 87f05a2

File tree

30 files changed

+554
-633
lines changed

30 files changed

+554
-633
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,5 @@ jobs:
154154
env:
155155
RUSTDOCFLAGS: -D warnings
156156

157-
semver:
158-
name: SemVer Checks
159-
runs-on: ubuntu-latest
160-
steps:
161-
- uses: actions/checkout@v4
162-
- name: Install Rust
163-
uses: dtolnay/rust-toolchain@stable
164-
- name: Install cargo-semver-checks
165-
uses: taiki-e/install-action@cargo-semver-checks
166-
- name: Check for breaking changes
167-
run: cargo semver-checks check-release
157+
168158

Cargo.lock

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

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,31 @@ rustapi-rs = { version = "0.1.9", features = ["jwt", "cors", "toon", "ws", "view
211211
| `audit` | GDPR/SOC2 audit logging |
212212
| `full` | All features enabled |
213213

214+
215+
### ✨ New Ergonomic Features
216+
217+
**Declarative Error Handling:**
218+
```rust
219+
#[derive(ApiError)]
220+
pub enum UserError {
221+
#[error(status = 404, message = "User not found")]
222+
NotFound(i32),
223+
#[error(status = 400, code = "validation_error")]
224+
InvalidInput(String),
225+
}
226+
```
227+
228+
**Fluent Testing:**
229+
```rust
230+
let client = TestClient::new(app);
231+
client.get("/users").await
232+
.assert_status(200)
233+
.assert_json(&expected_users);
234+
```
235+
214236
---
215237

238+
216239
## 📂 Examples
217240

218241
All examples are production-ready and follow best practices.

crates/rustapi-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ rustapi-openapi = { workspace = true, default-features = false }
7070
[dev-dependencies]
7171
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
7272
proptest = "1.4"
73+
rustapi-testing = { workspace = true }
7374
[features]
7475
default = ["swagger-ui", "tracing"]
7576
swagger-ui = ["rustapi-openapi/swagger-ui"]

crates/rustapi-core/src/app.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,11 @@ impl RustApi {
390390
self
391391
}
392392

393+
/// Add a typed route
394+
pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
395+
self.route(P::PATH, method_router)
396+
}
397+
393398
/// Mount a handler (convenience method)
394399
///
395400
/// Alias for `.route(path, method_router)` for a single handler.

crates/rustapi-core/src/extract.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,50 @@ impl<T> Deref for Path<T> {
340340
}
341341
}
342342

343+
/// Typed path extractor
344+
///
345+
/// Extracts path parameters and deserializes them into a struct implementing `Deserialize`.
346+
/// This is similar to `Path<T>`, but supports complex structs that can be deserialized
347+
/// from a map of parameter names to values (e.g. via `serde_json`).
348+
///
349+
/// # Example
350+
///
351+
/// ```rust,ignore
352+
/// #[derive(Deserialize)]
353+
/// struct UserParams {
354+
/// id: u64,
355+
/// category: String,
356+
/// }
357+
///
358+
/// async fn get_user(Typed(params): Typed<UserParams>) -> impl IntoResponse {
359+
/// // params.id, params.category
360+
/// }
361+
/// ```
362+
#[derive(Debug, Clone)]
363+
pub struct Typed<T>(pub T);
364+
365+
impl<T: DeserializeOwned + Send> FromRequestParts for Typed<T> {
366+
fn from_request_parts(req: &Request) -> Result<Self> {
367+
let params = req.path_params();
368+
let mut map = serde_json::Map::new();
369+
for (k, v) in params.iter() {
370+
map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
371+
}
372+
let value = serde_json::Value::Object(map);
373+
let parsed: T = serde_json::from_value(value)
374+
.map_err(|e| ApiError::bad_request(format!("Invalid path parameters: {}", e)))?;
375+
Ok(Typed(parsed))
376+
}
377+
}
378+
379+
impl<T> Deref for Typed<T> {
380+
type Target = T;
381+
382+
fn deref(&self) -> &Self::Target {
383+
&self.0
384+
}
385+
}
386+
343387
/// State extractor
344388
///
345389
/// Extracts shared application state.
@@ -851,6 +895,13 @@ impl<T> OperationModifier for Path<T> {
851895
}
852896
}
853897

898+
// Typed - Same as Path, parameters are documented by route pattern
899+
impl<T> OperationModifier for Typed<T> {
900+
fn update_operation(_op: &mut Operation) {
901+
// No-op, managed by route registration
902+
}
903+
}
904+
854905
// Query - Extracts query params using IntoParams
855906
impl<T: IntoParams> OperationModifier for Query<T> {
856907
fn update_operation(op: &mut Operation) {

crates/rustapi-core/src/interceptor.rs

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -333,37 +333,6 @@ mod tests {
333333
}
334334
}
335335

336-
/// A request interceptor that modifies a header
337-
#[derive(Clone)]
338-
struct HeaderModifyingRequestInterceptor {
339-
header_name: &'static str,
340-
header_value: String,
341-
}
342-
343-
impl HeaderModifyingRequestInterceptor {
344-
fn new(header_name: &'static str, header_value: impl Into<String>) -> Self {
345-
Self {
346-
header_name,
347-
header_value: header_value.into(),
348-
}
349-
}
350-
}
351-
352-
impl RequestInterceptor for HeaderModifyingRequestInterceptor {
353-
fn intercept(&self, mut request: Request) -> Request {
354-
// Store the value in extensions since we can't modify headers directly
355-
// In a real implementation, we'd need mutable header access
356-
request
357-
.extensions_mut()
358-
.insert(format!("{}:{}", self.header_name, self.header_value));
359-
request
360-
}
361-
362-
fn clone_box(&self) -> Box<dyn RequestInterceptor> {
363-
Box::new(self.clone())
364-
}
365-
}
366-
367336
/// A response interceptor that modifies a header
368337
#[derive(Clone)]
369338
struct HeaderModifyingResponseInterceptor {

crates/rustapi-core/src/lib.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,9 @@ mod server;
7070
pub mod sse;
7171
pub mod static_files;
7272
pub mod stream;
73+
pub mod typed_path;
7374
#[macro_use]
7475
mod tracing_macros;
75-
#[cfg(any(test, feature = "test-utils"))]
76-
mod test_client;
7776

7877
/// Private module for macro internals - DO NOT USE DIRECTLY
7978
///
@@ -94,25 +93,25 @@ pub use error::{get_environment, ApiError, Environment, FieldError, Result};
9493
pub use extract::Cookies;
9594
pub use extract::{
9695
Body, BodyStream, ClientIp, Extension, FromRequest, FromRequestParts, HeaderValue, Headers,
97-
Json, Path, Query, State, ValidatedJson,
96+
Json, Path, Query, State, Typed, ValidatedJson,
9897
};
9998
pub use handler::{
10099
delete_route, get_route, patch_route, post_route, put_route, Handler, HandlerService, Route,
101100
RouteHandler,
102101
};
103102
pub use health::{HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthStatus};
103+
pub use http::StatusCode;
104104
pub use interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
105105
#[cfg(feature = "compression")]
106106
pub use middleware::CompressionLayer;
107107
pub use middleware::{BodyLimitLayer, RequestId, RequestIdLayer, TracingLayer, DEFAULT_BODY_LIMIT};
108108
#[cfg(feature = "metrics")]
109109
pub use middleware::{MetricsLayer, MetricsResponse};
110110
pub use multipart::{Multipart, MultipartConfig, MultipartField, UploadedFile};
111-
pub use request::Request;
111+
pub use request::{BodyVariant, Request};
112112
pub use response::{Created, Html, IntoResponse, NoContent, Redirect, Response, WithStatus};
113-
pub use router::{delete, get, patch, post, put, MethodRouter, Router};
113+
pub use router::{delete, get, patch, post, put, MethodRouter, RouteMatch, Router};
114114
pub use sse::{sse_response, KeepAlive, Sse, SseEvent};
115115
pub use static_files::{serve_dir, StaticFile, StaticFileConfig};
116116
pub use stream::{StreamBody, StreamingBody, StreamingConfig};
117-
#[cfg(any(test, feature = "test-utils"))]
118-
pub use test_client::{TestClient, TestRequest, TestResponse};
117+
pub use typed_path::TypedPath;

crates/rustapi-core/src/request.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ use hyper::body::Incoming;
4747
use std::sync::Arc;
4848

4949
/// Internal representation of the request body state
50-
pub(crate) enum BodyVariant {
50+
pub enum BodyVariant {
5151
Buffered(Bytes),
5252
Streaming(Incoming),
5353
Consumed,
@@ -65,7 +65,7 @@ pub struct Request {
6565

6666
impl Request {
6767
/// Create a new request from parts
68-
pub(crate) fn new(
68+
pub fn new(
6969
parts: Parts,
7070
body: BodyVariant,
7171
state: Arc<Extensions>,

crates/rustapi-core/src/router.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
4444
use crate::handler::{into_boxed_handler, BoxedHandler, Handler};
4545
use crate::path_params::PathParams;
46+
use crate::typed_path::TypedPath;
4647
use http::{Extensions, Method};
4748
use matchit::Router as MatchitRouter;
4849
use rustapi_openapi::Operation;
@@ -331,6 +332,11 @@ impl Router {
331332
}
332333
}
333334

335+
/// Add a typed route using a TypedPath
336+
pub fn typed<P: TypedPath>(self, method_router: MethodRouter) -> Self {
337+
self.route(P::PATH, method_router)
338+
}
339+
334340
/// Add a route
335341
pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
336342
// Convert {param} style to :param for matchit
@@ -573,7 +579,7 @@ impl Router {
573579
}
574580

575581
/// Match a request and return the handler + params
576-
pub(crate) fn match_route(&self, path: &str, method: &Method) -> RouteMatch<'_> {
582+
pub fn match_route(&self, path: &str, method: &Method) -> RouteMatch<'_> {
577583
match self.inner.at(path) {
578584
Ok(matched) => {
579585
let method_router = matched.value;
@@ -598,7 +604,7 @@ impl Router {
598604
}
599605

600606
/// Get shared state
601-
pub(crate) fn state_ref(&self) -> Arc<Extensions> {
607+
pub fn state_ref(&self) -> Arc<Extensions> {
602608
self.state.clone()
603609
}
604610

@@ -620,7 +626,7 @@ impl Default for Router {
620626
}
621627

622628
/// Result of route matching
623-
pub(crate) enum RouteMatch<'a> {
629+
pub enum RouteMatch<'a> {
624630
Found {
625631
handler: &'a BoxedHandler,
626632
params: PathParams,
@@ -1024,7 +1030,7 @@ mod tests {
10241030
#[test]
10251031
fn test_state_tracking() {
10261032
#[derive(Clone)]
1027-
struct MyState(String);
1033+
struct MyState(#[allow(dead_code)] String);
10281034

10291035
let router = Router::new().state(MyState("test".to_string()));
10301036

@@ -1088,7 +1094,7 @@ mod tests {
10881094
#[test]
10891095
fn test_state_type_ids_merged_on_nest() {
10901096
#[derive(Clone)]
1091-
struct NestedState(String);
1097+
struct NestedState(#[allow(dead_code)] String);
10921098

10931099
async fn handler() -> &'static str {
10941100
"handler"
@@ -1905,7 +1911,7 @@ mod property_tests {
19051911
has_nested_state in any::<bool>(),
19061912
) {
19071913
#[derive(Clone)]
1908-
struct TestState(i32);
1914+
struct TestState(#[allow(dead_code)] i32);
19091915

19101916
async fn handler() -> &'static str { "handler" }
19111917

0 commit comments

Comments
 (0)