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
129 changes: 129 additions & 0 deletions .cursor/rules/rust-api-design.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
alwaysApply: true
---

# rust-api Framework Design Rules

This project is a Rust REST API framework wrapping Axum, similar in intent to NestJS (wrapping Express). These rules encode the invariants that keep the design clean. Violations should be called out and corrected.

## Module Responsibilities

| Module | Responsibility | Must NOT contain |
|---|---|---|
| [controller.rs](mdc:crates/rust-api/src/controller.rs) | `Controller` trait — pure route descriptor | Auth logic, middleware, routing infrastructure |
| [middleware.rs](mdc:crates/rust-api/src/middleware.rs) | Request-time Tower layer factories | Route registration, controller logic |
| [pipeline.rs](mdc:crates/rust-api/src/pipeline.rs) | Build-time Kleisli route composition | Business logic, per-request concerns |
| [router.rs](mdc:crates/rust-api/src/router.rs) | `Router` re-export + verb enforcement helpers | Business logic |
| [di.rs](mdc:crates/rust-api/src/di.rs) | Optional DI container (advanced use) | Primary app flow (prefer direct `Arc<S>`) |

## Invariant 1: Controllers Are Pure

A controller file contains **only**:
- Handler functions (`async fn`, annotated with `#[get]`, `#[post]`, etc.)
- The controller marker struct (`pub struct FooController;`)
- `mount_handlers!(...)` macro call

Controllers must **never** contain:
- Auth logic or token validation
- `FromRequestParts` implementations for security enforcement
- Imports of `axum` directly (use `rust_api::prelude::*`)
- Knowledge of `Router`, `RouteSet`, or any DI container

```rust
// CORRECT — pure controller
#[get("/users")]
pub async fn list_users(State(svc): State<Arc<UserService>>) -> Json<Vec<User>> {
Json(svc.list())
}
pub struct UserController;
mount_handlers!(UserController, UserService, [(__list_users_route, list_users)]);

// WRONG — auth leaking into controller
pub async fn list_users(_auth: ApiToken, State(svc): ...) -> ... { ... }
```

## Invariant 2: Auth Is a Tower Layer on a Route Group

Authentication/authorization is a **cross-cutting concern**. It belongs in `middleware.rs` as a `Router -> Router` transform applied via `.map()` in the pipeline — **never** as an extractor in a handler signature for security enforcement.

```rust
// CORRECT — auth applied as a layer to the route group
RouterPipeline::new()
.mount_guarded::<AdminController, _>(admin_svc, || { /* config check */ })
.map(require_bearer(admin_key)) // layer applied after mount
.build()?

// WRONG — auth baked into the handler
async fn admin_status(_: AdminToken, State(svc): ...) -> ... { ... }
```

This mirrors NestJS Guards and Next.js middleware: auth is applied at the routing layer, not inside handler logic.

## Invariant 3: No Direct `axum` Dependency in User Crates

`axum` is an **implementation detail** of `rust-api`. Client crates (examples, user apps) must never add `axum` as a direct dependency. All surface types needed to write handlers, services, and middleware are re-exported from `rust_api::prelude::*`.

```toml
# CORRECT — Cargo.toml
[dependencies]
rust-api = { path = "../../crates/rust-api" }

# WRONG — exposes implementation detail
[dependencies]
axum = "0.8"
rust-api = { path = "../../crates/rust-api" }
```

If a type is missing from the prelude, add it to `crates/rust-api/src/lib.rs` — do not add `axum` to the user crate.

## Invariant 4: `mount_guarded` vs `mount_if`

| Method | Semantics | Server behaviour on failure |
|---|---|---|
| `mount_if(condition, svc)` | Optional feature toggle | Silently skips, server starts normally |
| `mount_guarded(svc, guard)` | Hard requirement — guard must pass | Server **refuses to start** (`build()` returns `Err`) |

Use `mount_guarded` when the feature is **required** (e.g., admin routes need a configured key to be safe). Use `mount_if` when the feature is **optional** (e.g., metrics endpoint toggled by env var).

## Invariant 5: Services Hold Business Logic Only

Services must **never** contain:
- Auth tokens or API keys
- `&mut self` methods (use `Atomic*` primitives or channels for mutable state)
- `Mutex<T>` on the service struct itself
- Any concept of HTTP (headers, status codes, requests)

```rust
// CORRECT — immutable service, atomic counter
pub struct EchoService { count: AtomicU64 }
impl EchoService { pub fn echo(&self, msg: &str) -> EchoResponse { ... } }

// WRONG — service holds auth secret
pub struct AdminService { pub(crate) api_key: String }
```

## Invariant 6: Primary DI Is `Arc<S>`, Not the Container

The `Container` (in `di.rs`) is an **optional** advanced utility. The primary DI mechanism is direct `Arc<Service>` construction and passing to `pipeline.mount::<C>(svc)`. The `Container` is not in the prelude and should not appear in typical application code.

```rust
// CORRECT
let svc = Arc::new(MyService::new());
RouterPipeline::new().mount::<MyController>(svc).build()?

// AVOID for simple cases
let mut container = Container::new();
container.register::<MyService>(...);
```

## Invariant 7: `require_bearer` Usage Pattern

`require_bearer(token)` returns a `Fn(Router<()>) -> Router<()>` — pass it directly to `.map()`, not to `.layer()`:

```rust
// CORRECT
.map(require_bearer(admin_key))

// WRONG — require_bearer is already a Router->Router transform
.map(|r| r.layer(require_bearer(admin_key)))
```
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@nightly
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt

- name: Check formatting
run: cargo +nightly fmt --all -- --check
run: cargo fmt --all -- --check

clippy:
name: Clippy
Expand Down
53 changes: 53 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Multi-stage build — final image contains only the binary, not the Rust toolchain.
#
# Build: docker build -t rust-api .
# Run: docker run -p 3000:3000 -e ADMIN_API_KEY=secret rust-api

# ---------------------------------------------------------------------------
# Stage 1: builder
# ---------------------------------------------------------------------------
FROM rust:slim AS builder

WORKDIR /app

# Copy manifests first so dependency layers are cached separately from source.
COPY Cargo.toml Cargo.lock ./
COPY crates/rust-api/Cargo.toml crates/rust-api/Cargo.toml
COPY crates/rust-api-macros/Cargo.toml crates/rust-api-macros/Cargo.toml
COPY examples/basic-api/Cargo.toml examples/basic-api/Cargo.toml

# Stub out source so Cargo can resolve and cache dependencies without full source.
RUN mkdir -p crates/rust-api/src \
crates/rust-api-macros/src \
examples/basic-api/src \
&& echo "fn main() {}" > examples/basic-api/src/main.rs \
&& echo "" > crates/rust-api/src/lib.rs \
&& echo "" > crates/rust-api-macros/src/lib.rs

RUN cargo build --release -p basic-api 2>/dev/null || true

# Now copy the real source and build for real.
COPY crates/ crates/
COPY examples/ examples/

# Touch all real source files to bust the cached stub build artifacts.
# Using find so new crates are automatically included without Dockerfile changes.
RUN find crates/ examples/ -name "*.rs" -exec touch {} + \
&& cargo build --release -p basic-api

# ---------------------------------------------------------------------------
# Stage 2: runtime
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/basic-api /usr/local/bin/basic-api

EXPOSE 3000

ENV RUST_LOG=info

CMD ["basic-api"]
20 changes: 14 additions & 6 deletions crates/rust-api-macros/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ impl Parse for RouteArgs {
/// async fn get_user(Path(id): Path<String>) -> Json<User> { ... }
/// ```
///
/// Into the original function plus a route path constant:
/// Into the original function plus a route info tuple constant:
/// ```ignore
/// async fn get_user(Path(id): Path<String>) -> Json<User> { ... }
/// const __get_user_route: &str = "/users/{id}";
/// const __get_user_route: (&'static str, &'static str) = ("/users/{id}", "GET");
/// ```
///
/// The tuple encodes both path and HTTP method, making the annotation a
/// binding contract — the verb from the annotation is the sole authority.
pub fn expand_route_macro(
_method: HttpMethod,
method: HttpMethod,
args: TokenStream,
input: TokenStream,
) -> TokenStream {
Expand All @@ -85,16 +88,21 @@ pub fn expand_route_macro(
let func_name = &func.sig.ident;
let func_vis = &func.vis;

// generate route registration helper
// generate route registration helper name
let route_helper_name = syn::Ident::new(&format!("__{}_route", func_name), func_name.span());

// encode the HTTP method as a string literal
let method_str = method.as_str();

let expanded = quote! {
//original handler function
#func

//route path constant - stores just the path for registration
// Route info tuple: (path, HTTP method).
// The method comes from the macro annotation — this is the enforcement contract.
// Use with Router::api_route() or RouterPipeline::route() to register correctly.
#[allow(non_upper_case_globals)]
#func_vis const #route_helper_name: &str = #path;
#func_vis const #route_helper_name: (&'static str, &'static str) = (#path, #method_str);
};

TokenStream::from(expanded)
Expand Down
2 changes: 2 additions & 0 deletions crates/rust-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ thiserror = { workspace = true }
[dev-dependencies]
tokio-test = "0.4"
tracing-subscriber = { workspace = true }
tower = { workspace = true, features = ["util"] }
http-body-util = "0.1"
71 changes: 71 additions & 0 deletions crates/rust-api/src/controller.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Controller trait — pure descriptor for composable route groups.
//!
//! A `Controller` is a zero-knowledge marker type. It knows only two things:
//! 1. `type State` — the service it needs (`Arc<State>` is resolved and
//! passed in)
//! 2. `fn mount` — a Kleisli arrow `Arc<State> -> (Router -> Result<Router>)`
//!
//! Controllers have **no dependency on `Router`, `RouteSet`, or any DI
//! container**. The `mount_handlers!` macro generates the `mount`
//! implementation from a simple list of `(route_constant, handler_fn)` pairs.
//! Users only write handlers.
//!
//! # Kleisli Composition
//!
//! `mount` returns `impl FnOnce(Router<()>) -> Result<Router<()>>` — a Kleisli
//! arrow in the `Result` monad. The `RouterPipeline` composes these arrows with
//! `and_then` (`>>=`), threading the router through each controller in
//! sequence. A failed arrow short-circuits the rest.
//!
//! # Immutability Contract
//!
//! State is passed as `Arc<S>` — shared, immutable after construction. Services
//! must not expose `&mut self` methods. All state mutation goes through
//! `Atomic*` primitives or channels (never `Mutex<T>` on the service struct).
//!
//! # Example
//!
//! ```ignore
//! pub struct HealthController;
//!
//! #[get("/health")]
//! pub async fn health_check(State(svc): State<Arc<HealthService>>) -> Json<HealthResponse> {
//! Json(svc.health_check())
//! }
//!
//! // mount_handlers! generates the full Controller impl — user never writes Router.
//! mount_handlers!(HealthController, HealthService, [
//! (__health_check_route, health_check),
//! ]);
//! ```

use std::sync::Arc;

use crate::{error::Result, router::Router};

/// A pure descriptor for a group of HTTP routes sharing a common service
/// dependency.
///
/// Do not implement this trait manually. Use the [`mount_handlers!`] macro,
/// which generates the correct Kleisli arrow from your handler list.
///
/// The trait's only concern is: given `Arc<State>`, produce a Kleisli arrow
/// that registers this controller's routes into a `Router<()>`.
pub trait Controller: Sized + 'static {
/// The service this controller depends on.
///
/// The framework wraps it in `Arc<State>` — `Clone` is not required on the
/// service itself. Use `Atomic*` primitives or channels for any state that
/// changes after construction.
type State: Send + Sync + 'static;

/// Returns the Kleisli arrow for this controller.
///
/// Signature: `Arc<State> -> (Router<()> -> Result<Router<()>>)`
///
/// Generated by `mount_handlers!`. The returned closure:
/// 1. Builds a scoped `Router<Arc<State>>` with this controller's routes.
/// 2. Provides state via `.with_state(state)` → `Router<()>`.
/// 3. Merges into the outer `router` and returns `Ok(merged)`.
fn mount(state: Arc<Self::State>) -> impl FnOnce(Router<()>) -> Result<Router<()>>;
}
Loading