Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,5 @@ jobs:
env:
RUSTDOCFLAGS: -D warnings

semver:
name: SemVer Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-semver-checks
uses: taiki-e/install-action@cargo-semver-checks
- name: Check for breaking changes
run: cargo semver-checks check-release


3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,31 @@ rustapi-rs = { version = "0.1.9", features = ["jwt", "cors", "toon", "ws", "view
| `audit` | GDPR/SOC2 audit logging |
| `full` | All features enabled |


### ✨ New Ergonomic Features

**Declarative Error Handling:**
```rust
#[derive(ApiError)]
pub enum UserError {
#[error(status = 404, message = "User not found")]
NotFound(i32),
#[error(status = 400, code = "validation_error")]
InvalidInput(String),
}
```

**Fluent Testing:**
```rust
let client = TestClient::new(app);
client.get("/users").await
.assert_status(200)
.assert_json(&expected_users);
```

---


## 📂 Examples

All examples are production-ready and follow best practices.
Expand Down
1 change: 1 addition & 0 deletions crates/rustapi-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ rustapi-openapi = { workspace = true, default-features = false }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
proptest = "1.4"
rustapi-testing = { workspace = true }
[features]
default = ["swagger-ui", "tracing"]
swagger-ui = ["rustapi-openapi/swagger-ui"]
Expand Down
5 changes: 5 additions & 0 deletions crates/rustapi-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,11 @@ impl RustApi {
self
}

/// Add a typed route
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typed method lacks documentation explaining its purpose and providing a usage example. Users may not understand how it differs from the regular route method or how to use it with the TypedPath trait. Add a doc comment with an example showing how to use it with a TypedPath-derived struct.

Suggested change
/// Add a typed route
/// Add a typed route
///
/// This is a convenience wrapper around [`RustApi::route`] that uses a path
/// type implementing [`crate::typed_path::TypedPath`] instead of a raw `&str`.
/// The route path is taken from the associated constant [`crate::typed_path::TypedPath::PATH`]
/// on the supplied type `P`.
///
/// This allows you to keep the path definition and its extracted parameters in one
/// strongly-typed struct, which can then be used as an argument to your handler.
///
/// # Example
///
/// ```rust,ignore
/// use rustapi_rs::prelude::*;
/// use rustapi_rs::typed_path::TypedPath;
///
/// #[derive(TypedPath)]
/// #[typed_path("/users/{id}")]
/// struct UserPath {
/// id: i32,
/// }
///
/// async fn get_user(path: UserPath) -> impl IntoResponse {
/// // `path.id` is automatically parsed from the URL segment `{id}`
/// // ...
/// }
///
/// #[tokio::main]
/// async fn main() -> Result<()> {
/// RustApi::new()
/// // Equivalent to: .route("/users/{id}", get(get_user))
/// .typed::<UserPath>(get(get_user))
/// .run("127.0.0.1:8080")
/// .await
/// }
/// ```

Copilot uses AI. Check for mistakes.
pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
self.route(P::PATH, method_router)
}

/// Mount a handler (convenience method)
///
/// Alias for `.route(path, method_router)` for a single handler.
Expand Down
51 changes: 51 additions & 0 deletions crates/rustapi-core/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,50 @@ impl<T> Deref for Path<T> {
}
}

/// Typed path extractor
///
/// Extracts path parameters and deserializes them into a struct implementing `Deserialize`.
/// This is similar to `Path<T>`, but supports complex structs that can be deserialized
/// from a map of parameter names to values (e.g. via `serde_json`).
///
/// # Example
///
/// ```rust,ignore
/// #[derive(Deserialize)]
/// struct UserParams {
/// id: u64,
/// category: String,
/// }
///
/// async fn get_user(Typed(params): Typed<UserParams>) -> impl IntoResponse {
/// // params.id, params.category
/// }
/// ```
#[derive(Debug, Clone)]
pub struct Typed<T>(pub T);

impl<T: DeserializeOwned + Send> FromRequestParts for Typed<T> {
fn from_request_parts(req: &Request) -> Result<Self> {
let params = req.path_params();
let mut map = serde_json::Map::new();
for (k, v) in params.iter() {
map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
}
let value = serde_json::Value::Object(map);
let parsed: T = serde_json::from_value(value)
.map_err(|e| ApiError::bad_request(format!("Invalid path parameters: {}", e)))?;
Ok(Typed(parsed))
}
}

impl<T> Deref for Typed<T> {
type Target = T;

fn deref(&self) -> &Self::Target {
&self.0
}
}

/// State extractor
///
/// Extracts shared application state.
Expand Down Expand Up @@ -851,6 +895,13 @@ impl<T> OperationModifier for Path<T> {
}
}

// Typed - Same as Path, parameters are documented by route pattern
impl<T> OperationModifier for Typed<T> {
fn update_operation(_op: &mut Operation) {
// No-op, managed by route registration
}
}

// Query - Extracts query params using IntoParams
impl<T: IntoParams> OperationModifier for Query<T> {
fn update_operation(op: &mut Operation) {
Expand Down
31 changes: 0 additions & 31 deletions crates/rustapi-core/src/interceptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,37 +333,6 @@ mod tests {
}
}

/// A request interceptor that modifies a header
#[derive(Clone)]
struct HeaderModifyingRequestInterceptor {
header_name: &'static str,
header_value: String,
}

impl HeaderModifyingRequestInterceptor {
fn new(header_name: &'static str, header_value: impl Into<String>) -> Self {
Self {
header_name,
header_value: header_value.into(),
}
}
}

impl RequestInterceptor for HeaderModifyingRequestInterceptor {
fn intercept(&self, mut request: Request) -> Request {
// Store the value in extensions since we can't modify headers directly
// In a real implementation, we'd need mutable header access
request
.extensions_mut()
.insert(format!("{}:{}", self.header_name, self.header_value));
request
}

fn clone_box(&self) -> Box<dyn RequestInterceptor> {
Box::new(self.clone())
}
}

/// A response interceptor that modifies a header
#[derive(Clone)]
struct HeaderModifyingResponseInterceptor {
Expand Down
13 changes: 6 additions & 7 deletions crates/rustapi-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,9 @@ mod server;
pub mod sse;
pub mod static_files;
pub mod stream;
pub mod typed_path;
#[macro_use]
mod tracing_macros;
#[cfg(any(test, feature = "test-utils"))]
mod test_client;

/// Private module for macro internals - DO NOT USE DIRECTLY
///
Expand All @@ -94,25 +93,25 @@ pub use error::{get_environment, ApiError, Environment, FieldError, Result};
pub use extract::Cookies;
pub use extract::{
Body, BodyStream, ClientIp, Extension, FromRequest, FromRequestParts, HeaderValue, Headers,
Json, Path, Query, State, ValidatedJson,
Json, Path, Query, State, Typed, ValidatedJson,
};
pub use handler::{
delete_route, get_route, patch_route, post_route, put_route, Handler, HandlerService, Route,
RouteHandler,
};
pub use health::{HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthStatus};
pub use http::StatusCode;
pub use interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
#[cfg(feature = "compression")]
pub use middleware::CompressionLayer;
pub use middleware::{BodyLimitLayer, RequestId, RequestIdLayer, TracingLayer, DEFAULT_BODY_LIMIT};
#[cfg(feature = "metrics")]
pub use middleware::{MetricsLayer, MetricsResponse};
pub use multipart::{Multipart, MultipartConfig, MultipartField, UploadedFile};
pub use request::Request;
pub use request::{BodyVariant, Request};
pub use response::{Created, Html, IntoResponse, NoContent, Redirect, Response, WithStatus};
pub use router::{delete, get, patch, post, put, MethodRouter, Router};
pub use router::{delete, get, patch, post, put, MethodRouter, RouteMatch, Router};
pub use sse::{sse_response, KeepAlive, Sse, SseEvent};
pub use static_files::{serve_dir, StaticFile, StaticFileConfig};
pub use stream::{StreamBody, StreamingBody, StreamingConfig};
#[cfg(any(test, feature = "test-utils"))]
pub use test_client::{TestClient, TestRequest, TestResponse};
pub use typed_path::TypedPath;
4 changes: 2 additions & 2 deletions crates/rustapi-core/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ use hyper::body::Incoming;
use std::sync::Arc;

/// Internal representation of the request body state
pub(crate) enum BodyVariant {
pub enum BodyVariant {
Buffered(Bytes),
Streaming(Incoming),
Consumed,
Expand All @@ -65,7 +65,7 @@ pub struct Request {

impl Request {
/// Create a new request from parts
pub(crate) fn new(
pub fn new(
parts: Parts,
body: BodyVariant,
state: Arc<Extensions>,
Expand Down
18 changes: 12 additions & 6 deletions crates/rustapi-core/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

use crate::handler::{into_boxed_handler, BoxedHandler, Handler};
use crate::path_params::PathParams;
use crate::typed_path::TypedPath;
use http::{Extensions, Method};
use matchit::Router as MatchitRouter;
use rustapi_openapi::Operation;
Expand Down Expand Up @@ -331,6 +332,11 @@ impl Router {
}
}

/// Add a typed route using a TypedPath
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typed method lacks documentation explaining its purpose and providing a usage example. Users may not understand how it differs from the regular route method or how to use it with the TypedPath trait. Add a doc comment with an example showing how to use it with a TypedPath-derived struct.

Suggested change
/// Add a typed route using a TypedPath
/// Add a typed route using a [`TypedPath`] implementation.
///
/// This is a convenience for registering routes whose path is defined by a
/// type that implements [`TypedPath`]. Instead of writing the path string
/// manually, the route path is taken from `P::PATH`, which helps keep your
/// handler and the path definition in sync.
///
/// Internally this is equivalent to calling [`Router::route`] with
/// `P::PATH`:
///
/// ```ignore
/// router.route(P::PATH, method_router);
/// ```
///
/// # Examples
///
/// ```no_run
/// use http::Method;
/// use rustapi_core::router::{Router, MethodRouter};
/// use rustapi_core::typed_path::TypedPath;
///
/// // Define a typed path for a user resource.
/// //
/// // The derive macro provides a `TypedPath` implementation with
/// // `PATH = "/users/{id}"`.
/// #[derive(TypedPath)]
/// #[typed_path("/users/{id}")]
/// struct UserPath {
/// id: String,
/// }
///
/// // Register a GET handler for `/users/{id}` using the typed path.
/// let router = Router::new().typed::<UserPath>(
/// MethodRouter::new().on(Method::GET, |req| async move {
/// // handle GET /users/{id}
/// }),
/// );
///
/// // This is equivalent to:
/// //
/// // let router = Router::new().route(
/// // "/users/{id}",
/// // MethodRouter::new().on(Method::GET, |req| async move {
/// // // handle GET /users/{id}
/// // }),
/// // );
/// ```

Copilot uses AI. Check for mistakes.
pub fn typed<P: TypedPath>(self, method_router: MethodRouter) -> Self {
self.route(P::PATH, method_router)
}

/// Add a route
pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
// Convert {param} style to :param for matchit
Expand Down Expand Up @@ -573,7 +579,7 @@ impl Router {
}

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

/// Get shared state
pub(crate) fn state_ref(&self) -> Arc<Extensions> {
pub fn state_ref(&self) -> Arc<Extensions> {
self.state.clone()
}

Expand All @@ -620,7 +626,7 @@ impl Default for Router {
}

/// Result of route matching
pub(crate) enum RouteMatch<'a> {
pub enum RouteMatch<'a> {
Found {
handler: &'a BoxedHandler,
params: PathParams,
Expand Down Expand Up @@ -1024,7 +1030,7 @@ mod tests {
#[test]
fn test_state_tracking() {
#[derive(Clone)]
struct MyState(String);
struct MyState(#[allow(dead_code)] String);

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

Expand Down Expand Up @@ -1088,7 +1094,7 @@ mod tests {
#[test]
fn test_state_type_ids_merged_on_nest() {
#[derive(Clone)]
struct NestedState(String);
struct NestedState(#[allow(dead_code)] String);

async fn handler() -> &'static str {
"handler"
Expand Down Expand Up @@ -1905,7 +1911,7 @@ mod property_tests {
has_nested_state in any::<bool>(),
) {
#[derive(Clone)]
struct TestState(i32);
struct TestState(#[allow(dead_code)] i32);

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

Expand Down
20 changes: 10 additions & 10 deletions crates/rustapi-core/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,15 @@ mod property_tests {
use futures_util::StreamExt;
use proptest::prelude::*;

/// **Feature: v1-features-roadmap, Property 23: Streaming memory bounds**
/// **Validates: Requirements 11.2**
///
/// For streaming request bodies:
/// - Memory usage SHALL never exceed configured limit
/// - Streams exceeding limit SHALL be rejected with 413 Payload Too Large
/// - Bytes read counter SHALL accurately track consumed bytes
/// - Limit of None SHALL allow unlimited streaming
/// - Multiple chunks SHALL be correctly aggregated for limit checking
// Feature: v1-features-roadmap, Property 23: Streaming memory bounds
// Validates: Requirements 11.2
//
// For streaming request bodies:
// - Memory usage SHALL never exceed configured limit
// - Streams exceeding limit SHALL be rejected with 413 Payload Too Large
// - Bytes read counter SHALL accurately track consumed bytes
// - Limit of None SHALL allow unlimited streaming
// - Multiple chunks SHALL be correctly aggregated for limit checking

proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
Expand Down Expand Up @@ -294,7 +294,7 @@ mod property_tests {
num_chunks in 3usize..6,
) {
tokio::runtime::Runtime::new().unwrap().block_on(async {
let total_size = chunk_size * num_chunks;
let _total_size = chunk_size * num_chunks;
let limit = chunk_size + 50; // Less than total

let chunks: Vec<Result<Bytes, crate::error::ApiError>> = (0..num_chunks)
Expand Down
13 changes: 13 additions & 0 deletions crates/rustapi-core/src/typed_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use serde::{de::DeserializeOwned, Serialize};

/// Trait for defining typed paths
///
/// This trait allows structs to define their own path pattern and URL generation logic.
/// It is usually implemented via `#[derive(TypedPath)]`.
pub trait TypedPath: Serialize + DeserializeOwned + Send + Sync + 'static {
/// The URL path pattern (e.g., "/users/{id}")
const PATH: &'static str;

/// Convert the struct fields to a path string
fn to_uri(&self) -> String;
}
Comment on lines +3 to +13
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for the TypedPath trait lacks an example showing how to use it with the derive macro and the Typed extractor. While it mentions the trait is "usually implemented via #[derive(TypedPath)]", including a complete example would make this more helpful. Consider adding an example similar to those in the derive macros or test files.

Copilot uses AI. Check for mistakes.
Loading
Loading