Caelix
Caelix is a Rust backend framework built around modules, dependency injection, controllers, guards, interceptors, lifecycle hooks, cookies, validated uploads, OpenAPI, structured logging, domain events, WebSockets, typed NATS/Redis microservices, and explicit service-level caching.
The public package is:
caelix: the facade exporting traits, macros, optional HTTP runtimes, and microservice transports.
The fastest way to start an app is:
cargo install caelix-cli
caelix new demo-api
cd demo-api
cargo run
Generated applications depend on caelix = "0.0.32" from crates.io. The generator refuses to overwrite existing files, so it is safe to run against a feature name and stop when a generated file already exists.
Overview
Caelix applications are built from a root module. Modules describe imports, injectable providers, controllers, gateways, event handlers, and microservice handlers. The selected runtime builds the container, registers its transport handlers, and runs lifecycle hooks.
The public caelix package exports the HTTP runtimes, framework traits and
macros, extractors, cookies, uploads, OpenAPI, logging, events, gateways, cache,
and NATS/Redis microservices behind feature flags.
Start A Project
Install the CLI from crates.io:
cargo install caelix-cli
Create and run an application:
caelix new demo-api
cd demo-api
caelix run
The generated application uses caelix = "0.0.32" from crates.io:
demo-api/
Cargo.toml
src/
app.rs
lib.rs
main.rs
src/main.rs starts the Actix adapter. src/app.rs contains the root AppModule.
Build A Blog Feature
Generate a feature module:
cargo add sqlx --features runtime-tokio-rustls,postgres,macros
caelix g module posts
The command creates:
src/posts/
controller.rs
mod.rs
service.rs
Add application config as a normal provider. It can own both settings and application resources such as a SQLx pool.
#![allow(unused)]
fn main() {
// src/config.rs
use caelix::{
BoxFuture, Container, Injectable, ProviderDependency, Result,
ServiceUnavailableException, provider_dependencies,
};
use sqlx::PgPool;
pub struct AppConfig {
pub database_url: String,
pub pool: PgPool,
}
impl Injectable for AppConfig {
fn dependencies() -> Vec<ProviderDependency> {
provider_dependencies![]
}
fn create(_container: &Container) -> BoxFuture<'_, Result<Self>> {
Box::pin(async {
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| ServiceUnavailableException::new("DATABASE_URL must be set"))?;
let pool = PgPool::connect(&database_url)
.await?;
Ok(Self { database_url, pool })
})
}
}
}
Generated feature files are not wired into the root app automatically. Register the config and blog module in src/lib.rs and src/app.rs:
#![allow(unused)]
fn main() {
// src/lib.rs
pub mod app;
pub mod config;
pub mod posts;
pub use app::AppModule;
}
#![allow(unused)]
fn main() {
// src/app.rs
use caelix::{Module, ModuleMetadata};
use crate::{config::AppConfig, posts::PostsModule};
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.provider::<AppConfig>()
.import::<PostsModule>()
}
}
}
Create a table for posts:
create table posts (
id bigserial primary key,
title text not null,
body text not null
);
Now replace the generated placeholder with DTOs, SQLx writes, and a post-created event emitted after the insert succeeds.
#![allow(unused)]
fn main() {
// src/posts/service.rs
use std::sync::Arc;
use caelix::{EventBus, NotFoundException, Result, injectable};
use serde::{Deserialize, Serialize};
use crate::config::AppConfig;
#[derive(Clone, Serialize, sqlx::FromRow)]
pub struct PostDto {
pub id: i64,
pub title: String,
pub body: String,
}
#[derive(Deserialize)]
pub struct CreatePostDto {
pub title: String,
pub body: String,
}
#[derive(Deserialize)]
pub struct ListPostsQuery {
pub limit: Option<i64>,
}
#[derive(Clone)]
pub struct PostCreated {
pub id: i64,
pub title: String,
}
#[injectable]
pub struct PostsService {
config: Arc<AppConfig>,
events: Arc<EventBus>,
}
impl PostsService {
pub async fn list(&self, limit: Option<i64>) -> Result<Vec<PostDto>> {
let posts = sqlx::query_as::<_, PostDto>(
"select id, title, body from posts order by id desc limit $1",
)
.bind(limit.unwrap_or(20))
.fetch_all(&self.config.pool)
.await?;
Ok(posts)
}
pub async fn find(&self, id: i64) -> Result<PostDto> {
let post = sqlx::query_as::<_, PostDto>(
"select id, title, body from posts where id = $1",
)
.bind(id)
.fetch_optional(&self.config.pool)
.await?;
post.ok_or_else(|| NotFoundException::new(format!("post {id} not found")))
}
pub async fn create(&self, input: CreatePostDto) -> Result<PostDto> {
let post = sqlx::query_as::<_, PostDto>(
"insert into posts (title, body) values ($1, $2) returning id, title, body",
)
.bind(input.title)
.bind(input.body)
.fetch_one(&self.config.pool)
.await?;
self.events
.emit(PostCreated {
id: post.id,
title: post.title.clone(),
})
.await?;
Ok(post)
}
}
}
Then add controller routes using path params, query params, JSON bodies, typed responses, and typed errors:
#![allow(unused)]
fn main() {
// src/posts/controller.rs
use std::sync::Arc;
use caelix::StatusCode;
use caelix::{Response, Result, controller, injectable};
use super::{CreatePostDto, ListPostsQuery, PostDto, PostsService};
#[injectable]
pub struct PostsController {
posts: Arc<PostsService>,
}
#[controller("/posts")]
impl PostsController {
#[get("")]
pub async fn list(&self, #[query] query: ListPostsQuery) -> Result<Vec<PostDto>> {
self.posts.list(query.limit).await
}
#[get("/{id}")]
pub async fn find(&self, #[param] id: i64) -> Result<PostDto> {
self.posts.find(id).await
}
#[post("")]
pub async fn create(&self, #[body] input: CreatePostDto) -> Result<Response<PostDto>> {
let post = self.posts.create(input).await?;
Ok(Response::WithStatus(StatusCode::CREATED, post))
}
}
}
Register event support explicitly with EventModule. Import it before PostsService, because the service injects Arc<EventBus>.
#![allow(unused)]
fn main() {
// src/posts/mod.rs
mod controller;
mod service;
pub use controller::PostsController;
pub use service::{CreatePostDto, ListPostsQuery, PostCreated, PostDto, PostsService};
use caelix::{BoxFuture, EventHandler, EventModule, Module, ModuleMetadata, RegisterableEventHandler, Result, injectable};
#[injectable]
pub struct LogPostCreated;
impl EventHandler<PostCreated> for LogPostCreated {
fn handle(&self, event: PostCreated) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
println!("created post {}: {}", event.id, event.title);
Ok(())
})
}
}
impl RegisterableEventHandler for LogPostCreated {
type Event = PostCreated;
}
pub struct PostsModule;
impl Module for PostsModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.import::<EventModule>()
.provider::<PostsService>()
.provider::<LogPostCreated>()
.controller::<PostsController>()
.event_handler::<LogPostCreated>()
}
}
}
Run the app and try the feature:
cargo run
curl -i -X POST http://127.0.0.1:8080/posts \
-H 'content-type: application/json' \
-d '{"title":"First post","body":"Hello from Caelix"}'
HTTP/1.1 201 Created
content-type: application/json
{"id":1,"title":"First post","body":"Hello from Caelix"}
curl -i 'http://127.0.0.1:8080/posts?limit=10'
curl -i http://127.0.0.1:8080/posts/1
curl -i http://127.0.0.1:8080/posts/404
Missing posts return Caelix’s standard error shape:
{
"status": 404,
"error": "Not Found",
"message": "post 404 not found"
}
Minimal Application
A minimal app needs a module and an entry point for the selected backend (Actix is the default; Axum is enabled with the axum feature).
#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
}
}
}
use caelix::Application;
use demo_api::AppModule;
#[caelix::main]
async fn main() -> std::io::Result<()> {
Application::new::<AppModule>()
.await
.map_err(|err| std::io::Error::other(err.message))?
.listen("127.0.0.1:8080")
.await
}
Application::new::<AppModule>() builds the dependency container, validates module metadata, registers controller routes, runs startup lifecycle hooks, and logs the route table.
Startup errors are returned from Application::new, so application code decides whether to propagate or handle them.
Project Layout
The generator creates a small root module. Feature code usually lives under src/<feature>/.
src/
app.rs
lib.rs
main.rs
users/
mod.rs
service.rs
controller.rs
Feature modules typically export their service, controller, and module types from mod.rs, then the root app imports the feature module:
#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};
use crate::users::UsersModule;
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().import::<UsersModule>()
}
}
}
Feature names are normalized by the CLI. users maps to src/users; auth-session maps to src/auth_session, route path /auth-session, and types such as AuthSessionModule, AuthSessionService, and AuthSessionController.
CLI Guide
The CLI binary is named caelix.
Install it from crates.io:
cargo install caelix-cli
After installation, run commands with the caelix binary.
Run An Application
caelix run
This resets the terminal, clears its viewport and scrollback buffer, then
delegates to cargo run in the current project. Use --watch to restart the
application when files under src/ or Cargo.toml change:
caelix run --watch
Arguments after -- are passed directly to the application:
caelix run --watch -- --port 4000 --verbose
Validate An Application
caelix doctor
This runs the normal application binary as cargo run -- --doctor. Caelix completes application construction, module validation, provider construction, lifecycle startup hooks, and route/runtime setup, then runs shutdown hooks and exits without binding an HTTP port.
It returns a non-zero status when compilation, configuration, startup, or shutdown fails. Doctor mode intentionally permits normal startup side effects because it verifies the same initialization path as caelix run; the only skipped step is network binding and request serving.
Create An Application
caelix new demo-api
Pass --backend axum to generate a project that opts into Caelix’s Axum adapter and includes tower-http for native Tower layers:
caelix new demo-api --backend axum
The command creates:
Cargo.tomlAGENTS.mdsrc/main.rssrc/lib.rssrc/app.rs
The default generated Cargo.toml uses caelix = "0.0.32" from crates.io and the Actix backend. The Axum option disables default features and enables axum instead.
The generated AGENTS.md gives AI coding agents the app-level Caelix conventions: explicit module registration, provider/controller registration, injectable field shape, service-level cache behavior, and the usual cargo test check.
Generated src/main.rs starts the selected adapter:
use caelix::Application;
use demo_api::AppModule;
#[caelix::main]
async fn main() -> std::io::Result<()> {
Application::new::<AppModule>()
.await
.map_err(|err| std::io::Error::other(err.message))?
.listen("127.0.0.1:8080")
.await
}
Generated src/app.rs defines an empty root module:
#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
}
}
}
Update Caelix
caelix update
This command reads the current caelix dependency from Cargo.toml, fetches the latest published caelix version from crates.io, and updates only that dependency when a newer version exists. It preserves existing Cargo.toml comments, formatting, dependency order, and feature settings.
After editing Cargo.toml, the CLI runs:
cargo update -p caelix
caelix update does not regenerate or re-sync scaffolded source files.
Generate A Feature Module
caelix g module users
This is equivalent to:
caelix generate module users
It creates:
src/users/mod.rssrc/users/service.rssrc/users/controller.rs
The generated module registers the service as a provider and the controller as a controller. The CLI prints manual registration steps for adding the module to the app.
Add the generated module to src/lib.rs:
#![allow(unused)]
fn main() {
pub mod users;
}
Then import it in the root module:
#![allow(unused)]
fn main() {
use crate::users::UsersModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().import::<UsersModule>()
}
}
}
Generate A Service
caelix g service users
This creates src/users/service.rs. If src/users/mod.rs does not exist, it creates a feature mod.rs that exports the service.
Generate A Controller
caelix g controller users
This creates src/users/controller.rs. If src/users/service.rs exists, the controller injects Arc<UsersService> and calls it. If the service does not exist, the controller is generated without that dependency and the CLI prints a note.
Generated controller with a service:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{Result, controller, injectable};
use super::UsersService;
#[injectable]
pub struct UsersController {
service: Arc<UsersService>,
}
#[controller("/users")]
impl UsersController {
#[get("")]
pub async fn hello(&self) -> Result<String> {
Ok(self.service.hello())
}
}
}
Name Normalization
| Input | Directory | Route | Types |
|---|---|---|---|
users | src/users | /users | UsersModule, UsersService, UsersController |
auth-session | src/auth_session | /auth-session | AuthSessionModule, AuthSessionService, AuthSessionController |
Admin Users | src/admin_users | /admin-users | AdminUsersModule, AdminUsersService, AdminUsersController |
Overwrite Behavior
The CLI refuses to overwrite existing generated files. If any target file already exists, the command returns an error like:
src/users/service.rs already exists; refusing to overwrite
For caelix g module users, all three target files must be missing: mod.rs, service.rs, and controller.rs.
Command Reference
caelix new
caelix new <name>
Pass --backend axum to generate an Axum-backed app. The default is Actix.
Creates a new Caelix application directory named <name>.
The command creates Cargo.toml, AGENTS.md, src/main.rs, src/lib.rs, and src/app.rs. The package name comes from the target directory and is converted to kebab case.
caelix doctor
caelix doctor
Runs cargo run -- --doctor in the current Cargo project. The normal application binary completes full startup validation, runtime route setup, and shutdown without binding a network socket. It returns a non-zero exit status when compilation, configuration, startup, or shutdown fails.
caelix generate module
caelix generate module <name>
caelix g module <name>
Creates a feature module, service, and controller below src/<normalized-name>/.
Generation requires a Cargo.toml in the current directory. It refuses to create files when run outside a Cargo project.
caelix generate service
caelix generate service <name>
caelix g service <name>
Creates service.rs and creates mod.rs only when missing.
Generation requires a Cargo.toml in the current directory.
caelix generate controller
caelix generate controller <name>
caelix g controller <name>
Creates controller.rs and creates mod.rs only when missing. A matching service is injected only when src/<feature>/service.rs already exists.
Generation requires a Cargo.toml in the current directory.
If the service does not exist, the controller is generated without a service dependency and the CLI prints a note.
Name Rules
Names are trimmed and cannot be empty or contain / or \. The normalized Rust module name cannot start with an ASCII digit.
Examples:
| Input | Directory | Route | Types |
|---|---|---|---|
users | src/users | /users | UsersModule, UsersService, UsersController |
auth-session | src/auth_session | /auth-session | AuthSessionModule, AuthSessionService, AuthSessionController |
admin users | src/admin_users | /admin-users | AdminUsersModule, AdminUsersService, AdminUsersController |
Invalid names include an empty string, names containing / or \, and names whose normalized Rust module name starts with an ASCII digit.
Overwrite Rules
The CLI refuses to overwrite files. Typical errors:
src/users/service.rs already exists; refusing to overwrite
Registration
Generation does not edit src/app.rs or src/lib.rs. Register generated modules manually:
#![allow(unused)]
fn main() {
pub mod users;
}
#![allow(unused)]
fn main() {
use crate::users::UsersModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().import::<UsersModule>()
}
}
}
Modules
A module implements Module and returns ModuleMetadata. Metadata is the only thing Caelix needs to build the dependency graph and route table.
#![allow(unused)]
fn main() {
use caelix::{CacheModule, Module, ModuleMetadata};
pub struct UsersModule;
impl Module for UsersModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.import::<CacheModule>()
.provider::<UsersService>()
.controller::<UsersController>()
}
}
}
ModuleMetadata is a builder:
#![allow(unused)]
fn main() {
pub struct ModuleMetadata {
pub imports: Vec<ModuleDef>,
pub providers: Vec<ProviderDef>,
pub controllers: Vec<ControllerDef>,
pub event_handlers: Vec<EventHandlerDef>,
}
impl ModuleMetadata {
pub fn new() -> Self;
pub fn import<M: Module + 'static>(self) -> Self;
pub fn provider<T: Injectable>(self) -> Self;
pub fn global() -> Self;
pub fn export<T: Send + Sync + 'static>(self) -> Self;
pub fn provider_async_factory<T, Fut, E>(self, dependencies: Vec<ProviderDependency>, factory: impl Fn(Arc<Container>) -> Fut + Send + Sync + 'static) -> Self;
pub fn controller<C: Controller + Injectable + 'static>(self) -> Self;
pub fn event_handler<H>(self) -> Self;
pub fn event_handler_for<E, H>(self) -> Self;
}
}
The common registrations are:
.import::<OtherModule>().provider::<T>().provider_async_factory::<T, _, _>(provider_dependencies![...], factory).controller::<C>().event_handler::<H>().event_handler_for::<Event, H>()
Visibility and Registration Order
Imported modules are registered before the module that imports them. Inside a module, providers are registered before controllers because controllers are providers too. Event handlers are registered after their provider instances exist. Modules that emit events or register handlers must import EventModule before providers that inject Arc<EventBus>.
An import does not make every provider public. Export the provider from its owning module, then import that module where it is consumed:
#![allow(unused)]
fn main() {
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.import::<DatabaseModule>()
.import::<UsersModule>()
}
}
}
If DatabaseModule registers DatabasePool, it must call
.export::<DatabasePool>() before a provider in UsersModule can resolve
Arc<DatabasePool>. A module may re-export an export from one of its direct
imports. Global modules use ModuleMetadata::global() and make only their
explicit exports visible application-wide after they are imported somewhere in
the reachable graph.
Each module and provider type has one owner. Repeated module imports are deduplicated, while duplicate production provider registrations are startup errors rather than overwrite order.
Controllers Are Providers
.controller::<UsersController>() adds a controller definition and a provider definition for the same type. Controller dependencies are resolved from the container exactly like service dependencies.
#![allow(unused)]
fn main() {
#[injectable]
pub struct UsersController {
users: Arc<UsersService>,
}
}
Do not also register the same controller with .provider::<UsersController>(); .controller::<UsersController>() already constructs it.
Startup Failures
Application startup fails when metadata references a provider that was not registered into the container. Common causes are:
- A controller depends on
Arc<UsersService>, butUsersServiceis not registered with.provider::<UsersService>(). - An event handler is listed with
.event_handler::<SendWelcomeEmail>(), but the handler was not also registered as a provider. - A provider injects
Arc<EventBus>or a module registers event handlers, but the module did not importEventModule. - A provider uses a private or non-exported dependency from another module.
- A factory provider returns an error.
- A lifecycle hook returns an
Err(HttpException).
Application::new::<AppModule>() returns these startup errors as caelix::Result<Application>.
Providers
Providers implement Injectable. Caelix constructs providers during application startup and stores them as Arc<T> in the container.
Macro Providers
The #[injectable] macro implements Injectable for unit structs and named-field structs.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{Logger, injectable};
#[injectable]
pub struct UsersService {
logger: Arc<Logger>,
repository: Arc<UsersRepository>,
}
}
Every named field must be Arc<T>. The macro resolves each field from the container. Arc<Logger> is special: it receives a logger scoped to the struct name instead of resolving the default application logger.
Unit structs work well for stateless services:
#![allow(unused)]
fn main() {
#[injectable]
pub struct HealthService;
impl HealthService {
pub fn status(&self) -> &'static str {
"ok"
}
}
}
Tuple structs and non-struct items are rejected by the macro. Named fields that are not Arc<T> are also rejected.
Manual Injectable
Implement Injectable manually when construction needs owned state, custom
initialization, or lifecycle hooks. A manual implementation has two parts:
dependencies()declares every provider type resolved withcontainer.resolve::<T>()increate.create()performs the actual resolution and construction.
Use provider_dependencies![...] for the declaration. It is required: omitting
dependencies() is a compile error, including for a dependency-free provider.
This is also enforced at construction time. Caelix gives create a scoped
container and rejects container.resolve::<T>() when T is absent from the
declaration. The list therefore cannot be used to bypass module visibility.
Caelix uses the declaration before construction to check module visibility,
report missing dependencies, arrange startup order, and reject dependency
cycles. A scoped logger from container.resolve_logger(...) is not a provider
dependency, so do not list Logger.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{
BoxFuture, Container, Injectable, Logger, ProviderDependency, Result,
provider_dependencies,
};
pub struct UsersService {
repository: Arc<UsersRepository>,
logger: Arc<Logger>,
}
impl Injectable for UsersService {
fn dependencies() -> Vec<ProviderDependency> {
provider_dependencies![UsersRepository]
}
fn create(container: &Container) -> BoxFuture<'_, Result<Self>> {
Box::pin(async move {
Ok(Self {
repository: container.resolve::<UsersRepository>()?,
logger: container.resolve_logger("UsersService"),
})
})
}
fn on_module_init(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(async { Ok(()) })
}
}
}
Keep the declaration in sync with create. If a provider resolves two
services, it declares both:
#![allow(unused)]
fn main() {
impl Injectable for ReportService {
fn dependencies() -> Vec<ProviderDependency> {
provider_dependencies![UsersRepository, AuditService]
}
fn create(container: &Container) -> BoxFuture<'_, Result<Self>> {
Box::pin(async move {
Ok(Self {
repository: container.resolve::<UsersRepository>()?,
audit: container.resolve::<AuditService>()?,
})
})
}
}
}
For a dependency-free manual provider, return provider_dependencies![].
Dependencies must also be visible to the module: declare them locally or
import a module that explicitly exports them. The declaration applies only
during construction; resolving application services later from a request or
runtime-owned container is not part of this provider-construction contract.
Async Factories
Use an async factory when construction needs fallible async work, such as
opening a database pool. Its first argument is always a dependency declaration,
including provider_dependencies![] when the factory resolves nothing. It has
the same scheduling and visibility role as Injectable::dependencies().
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{Container, Module, ModuleMetadata, provider_dependencies};
pub struct DatabasePool;
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.provider_async_factory::<DatabasePool, _, _>(
provider_dependencies![],
|_container: Arc<Container>| async move {
DatabasePool::connect("postgres://localhost/app").await
},
)
}
}
}
The factory receives an Arc<Container>. Every provider type resolved from it
must appear in the first argument; closures cannot be inspected to infer this
list. Caelix applies the same scoped-resolution check used for handwritten
Injectable implementations. A scoped logger obtained through
container.resolve_logger(...) is not a provider dependency.
#![allow(unused)]
fn main() {
ModuleMetadata::new()
.provider::<Config>()
.provider_async_factory::<DatabasePool, _, _>(
provider_dependencies![Config],
|container: Arc<Container>| async move {
let config = container.resolve::<Config>()?;
DatabasePool::connect(&config.database_url).await
},
)
}
Factory dependencies follow normal module visibility rules. If Config is
owned by ConfigModule, ConfigModule must export Config and the module
declaring the factory must import ConfigModule. Declaring Config in the
factory list does not make it public or register it.
Async factory providers are construction-only. They use no-op lifecycle callbacks, so providers that need on_module_init, on_bootstrap, or on_shutdown should implement Injectable directly and use .provider::<T>().
Owned Resources
An application-owned provider can store external resources directly:
#![allow(unused)]
fn main() {
pub struct AppConfig {
pub database_url: String,
pub pool: PgPool,
}
}
When AppConfig is registered with .provider::<AppConfig>(), services can inject Arc<AppConfig> and use config.pool. This does not register PgPool as its own provider. Injecting Arc<PgPool> only works if PgPool itself is registered separately.
Application crates usually cannot write impl Injectable for PgPool because Rust’s orphan rules prevent implementing a foreign trait for a foreign type. For fallible startup errors instead of expect failures, keep using .provider_async_factory::<PgPool, _, _>(...) or wrap the pool in an application-owned newtype and implement Injectable for that wrapper.
Provider Visibility
Providers are visible only within their declaring module, through explicit exports from direct imports, or through explicit exports of global modules. Imports are not registration-order visibility.
#[injectable] records Arc<T> fields automatically. Manual implementations
and async factories must declare each resolved dependency with
provider_dependencies![T, ...]. The declaration is mandatory for handwritten
providers, and Caelix rejects container.resolve::<T>() during construction
when T is absent from it.
Controllers
Controllers are injectable providers with route metadata generated by #[controller].
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{Response, Result, StatusCode, controller, injectable};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct ListUsersQuery {
pub limit: Option<usize>,
}
#[derive(Deserialize)]
pub struct CreateUserDto {
pub email: String,
pub name: String,
}
#[derive(Serialize)]
pub struct UserDto {
pub id: i64,
pub email: String,
pub name: String,
}
#[injectable]
pub struct UsersController {
users: Arc<UsersService>,
}
#[controller("/users")]
impl UsersController {
#[get("")]
pub async fn list(&self, #[query] query: ListUsersQuery) -> Result<Vec<UserDto>> {
self.users.list(query.limit).await
}
#[get("/{id}")]
pub async fn get_user(&self, #[param] id: i64) -> Result<UserDto> {
self.users.find(id).await
}
#[post("")]
pub async fn create_user(&self, #[body] input: CreateUserDto) -> Result<Response<UserDto>> {
let user = self.users.create(input).await?;
Ok(Response::WithStatus(StatusCode::CREATED, user))
}
}
}
Supported route attributes are #[get], #[post], #[patch], #[put], and #[delete].
Controller parameters use #[param], #[query], #[body], #[file],
#[files], #[multipart], #[user], and #[cookie("name")].
#[validate] validates a decoded value. See the canonical Extractors
guide for accepted Rust types, feature requirements, combinations, order, and
error behavior. Extractor arguments must be simple identifiers.
Routing example
A single path segment can be extracted directly:
#![allow(unused)]
fn main() {
#[get("/{id}")]
pub async fn find(&self, #[param] id: i64) -> Result<UserDto> {
self.users.find(id).await
}
}
For multiple path params, use a tuple or a Serde-deserializable struct:
#![allow(unused)]
fn main() {
#[get("/{org_id}/users/{user_id}")]
pub async fn find_in_org(&self, #[param] ids: (i64, i64)) -> Result<UserDto> {
let (org_id, user_id) = ids;
self.users.find_in_org(org_id, user_id).await
}
}
Generated Route Metadata
The macro implements Controller for the type. Registered routes are exposed through Controller::routes() and logged at application startup. Display paths use :id style in logs, even though route attributes use Actix’s {id} syntax.
Extractors
Controller extractors turn HTTP request data into typed method arguments. Their behavior is shared by Actix and Axum; applications do not use backend extractor types in controller signatures.
| Attribute | Accepted Rust type | Feature |
|---|---|---|
#[param] | any Serde-deserializable scalar, tuple, or struct | runtime |
#[query] | any Serde-deserializable type | runtime |
#[body] | any Serde-deserializable DTO | runtime; uploads for multipart |
#[user] | a cloneable concrete type stored in RequestContext | runtime |
#[cookie("name")] | String or Option<String> | runtime |
#[file] | UploadedFile or Option<UploadedFile> | uploads |
#[files] | Vec<UploadedFile> | uploads |
#[multipart] | MultipartForm | uploads |
#[validate] | a value implementing validator::Validate | validator |
Arguments must be simple identifiers. Each extracted parameter has exactly one
extractor marker; #[validate] is an additional marker on a decoded value.
Path, query, body, user, and cookie values
#![allow(unused)]
fn main() {
#[post("/{account_id}")]
async fn update(
&self,
#[param] account_id: i64,
#[query] options: UpdateOptions,
#[body] #[validate] input: UpdateAccount,
#[user] actor: CurrentUser,
#[cookie("session")] session: String,
) -> Result<Response<Account>> {
self.accounts.update(account_id, options, input, actor, session).await
}
}
Path and query types use Serde conversion. A body accepts JSON when
Content-Type is application/json or omitted. With uploads, it also accepts
multipart text fields using Serde form semantics. #[user] clones a typed value
previously inserted by a guard or interceptor. A missing user returns 401 Unauthorized with Not authenticated.
Cookie extraction is described fully in Cookies and Sessions.
A required cookie is String; an optional cookie is Option<String>.
Upload combinations
#[body] may be combined with #[file] or #[files]. A required file makes
multipart mandatory. If every file is optional, JSON remains valid and supplies
None. Vec<UploadedFile> collects repeated parts and requires multipart.
#[multipart] MultipartForm owns the complete form and cannot be combined with
#[body], #[file], or #[files].
File checks run in request order: declared size, declared or detected MIME type,
then the async controller validator named by validate = method. DTO
#[validate] runs after successful decoding. See Multipart Uploads
for file types, lifecycle, and #[upload(limit = bytes)].
Failure normalization and order
Extraction finishes before the controller method runs. Caelix normalizes failures:
400 Bad Request: malformed path/query/body/multipart data, missing required cookie or file, duplicate single-file parts, validation failure, or rejected MIME.401 Unauthorized: missing#[user]context value.413 Payload Too Large: application/route body limit or file-size violation.415 Unsupported Media Type: a declared content type the route cannot accept.
Malformed input cannot reach validation because no typed value exists. After decoding, DTO validation and then file validation complete before method invocation. A failed multipart request drops all staged files.
With openapi, body, path, query, file, and cookie declarations contribute
their corresponding operation parameters or request body. Runtime errors keep
the standard error response shape.
Cookies and Sessions
Caelix provides runtime-neutral incoming cookie lookup and secure response-cookie construction. It deliberately does not own session storage or cryptography.
Read cookies
#![allow(unused)]
fn main() {
#[get("/me")]
async fn me(
&self,
#[cookie("session")] session: String,
#[cookie("theme")] theme: Option<String>,
) -> Result<Response<User>> {
Ok(Response::Body(self.sessions.user(session, theme).await?))
}
}
#[cookie] requires a non-empty string literal. String is required and a
missing value returns 400 Bad Request; Option<String> becomes None.
Guards and interceptors can use ctx.cookie("session") -> Option<&str>.
Caelix combines repeated Cookie headers, percent-decodes names and values, and
keeps the first value when a name occurs more than once. Parsing happens once
when RequestContext is created. With openapi, the macro emits a string cookie
parameter whose required flag matches the Rust type.
Set and remove cookies
#![allow(unused)]
fn main() {
use caelix::{Cookie, Response, SameSite};
use std::time::Duration;
let response = Response::Body(user)
.with_cookie(
Cookie::new("session", token)
.same_site(SameSite::Strict)
.max_age(Duration::from_secs(60 * 60)),
)
.with_cookie(Cookie::new("theme", "dark").http_only(false));
}
Cookie::new defaults to HttpOnly, Secure, SameSite=Lax, and Path=/.
Builder methods are http_only, secure, same_site, path, domain,
max_age, and expires. Response::with_cookie and
HttpResponse::with_cookie append in call order; adapters preserve every cookie
as a separate Set-Cookie header.
Remove a stored cookie with matching scope:
#![allow(unused)]
fn main() {
Response::no_content().with_cookie(
Cookie::removal("session").path("/auth").domain("example.com"),
)
}
removal uses an empty value, Max-Age=0, and the Unix epoch. Its path and
domain must match the original cookie or the browser will retain the original.
Local HTTP and security boundaries
Browsers do not send Secure cookies over plain local HTTP. For local-only
development, explicitly use .secure(false); keep the default behind HTTPS.
Choose SameSite according to the request flow and add application-level CSRF
protection wherever cookies authenticate cross-site requests.
Cookie values are opaque to Caelix. A session provider owns lookup, rotation, expiry, revocation, and server-side state. Caelix does not sign or encrypt values. Use an audited signing/encryption library in an injectable session service, keep keys outside source control, and do not confuse signing (integrity) with encryption (confidentiality).
Test the complete exchange
Send incoming cookies with .header("cookie", "..."). Inspect response headers
when asserting Set-Cookie; multiple cookies are multiple header values, not a
comma-separated field. Test required/optional extraction, percent decoding,
secure attributes, and removal scope. The repository’s cookie integration tests
exercise the same controller expansion on both runtimes.
OpenAPI and Swagger UI
Enable the openapi feature. Caelix re-exports utoipa, including ToSchema, so no separate dependency is required:
caelix = { version = "0.0.32", features = ["openapi"] }
Opt in when building the application. Caelix serves OpenAPI 3.1 JSON at /openapi.json and Swagger UI at /docs.
#![allow(unused)]
fn main() {
use caelix::openapi::OpenApiConfig;
let app = Application::new::<AppModule>()
.await?
.with_openapi(OpenApiConfig::new("Payments API", "1.0.0"))?;
}
OpenApiConfig::json_path(...) and OpenApiConfig::ui_path(...) customize these paths. They must not collide with controller routes.
Document DTOs with utoipa::ToSchema. The controller macro infers JSON request
bodies from #[body], multipart request bodies from upload routes, and
successful 200 responses from Result<T> and Result<Response<T>>.
For a route with #[body] and #[file], OpenAPI adds
multipart/form-data: the DTO fields remain schema-backed and file fields are
binary properties. Required single files are listed as required; optional file
properties are not. Routes whose files are all optional retain their JSON
request content type as well. A direct #[multipart] MultipartForm route is
documented as a free-form multipart request body.
#![allow(unused)]
fn main() {
use caelix::openapi::{ToSchema, errors, request_header, response, utoipa};
#[derive(ToSchema)]
struct PaymentDto {
id: String,
}
#[controller("/payments")]
impl PaymentController {
#[post("")]
#[request_header(name = "Idempotency-Key", schema = String, required)]
#[response(status = 201, body = PaymentDto, headers(("Location", String, "Payment URL")))]
#[errors(BadRequestException, ConflictException)]
async fn create(&self, #[body] input: PaymentDto) -> Result<Response<PaymentDto>> {
// ...
}
}
}
#[request_header] only adds documentation; it does not extract or authenticate request headers. #[response(BodyType)] overrides inferred response schema, while content_type and optional headers(...) can document raw or streaming output without inventing a body schema. #[errors(...)] documents only the exception markers listed, using Caelix’s shared error envelope. Custom error marker types can implement caelix::openapi::OpenApiError.
Security schemes
Security schemes are documentation-only. Register reusable schemes on OpenApiConfig, then add an imported #[security(...)] marker to the routes that require them. Caelix adds the resulting components.securitySchemes and operation-level security objects to /openapi.json, which makes Swagger UI expose its Authorize controls.
This does not add guards, extract credentials, verify tokens, read cookies, or alter authorization. Keep runtime authorization explicit with #[use_guard(...)], #[user], and your own authentication services.
Standard schemes
Caelix reserves four standard security-scheme names. Register the builder that corresponds to the route marker you plan to use:
| Builder | OpenAPI component | Route requirement |
|---|---|---|
.bearer_auth() | BearerAuth: HTTP bearer, JWT format | Security::BearerAuth |
.api_key_auth("X-API-Key") | ApiKeyAuth: API key in a header | Security::ApiKeyAuth |
.cookie_auth("session") | CookieAuth: API key in a cookie | Security::CookieAuth |
.oauth2(...) | OAuth2: supplied OAuth2 flows | Security::OAuth2(&[...]) |
For example, this configures bearer, header API-key, cookie, and OAuth2 client-credentials components in Swagger UI:
#![allow(unused)]
fn main() {
use caelix::openapi::{OpenApiConfig, security};
let openapi = OpenApiConfig::new("Payments API", "1.0.0")
.bearer_auth()
.api_key_auth("X-API-Key")
.cookie_auth("session")
.oauth2(security::OAuth2::new([
security::Flow::ClientCredentials(security::ClientCredentials::new(
"https://identity.example.com/oauth/token",
security::Scopes::from_iter([
("payments:read", "Read payments"),
("payments:write", "Create and update payments"),
]),
)),
]));
}
Pass that configuration to the normal fallible application builder:
#![allow(unused)]
fn main() {
use caelix::openapi::{OpenApiConfig, Security, security};
let app = Application::new::<AppModule>()
.await?
.with_openapi(openapi)?;
#[controller("/account")]
impl AccountController {
#[security(Security::BearerAuth)]
#[get("/me")]
async fn me(&self, #[user] user: CurrentUser) -> Result<UserDto> {
// ...
}
}
}
OAuth2 scopes and combined requirements
Security::OAuth2(&["payments:read"]) requests those OAuth2 scopes for one operation. Keep the scope names aligned with the OAuth2 flow you registered. API-key, bearer, and cookie requirements always carry an empty OpenAPI scope list.
Multiple #[security(...)] attributes on the same method produce one OpenAPI Security Requirement Object, so they are combined as AND. This is useful when an endpoint requires both a user token and a tenant key:
#![allow(unused)]
fn main() {
#[security(Security::BearerAuth)]
#[security(Security::ApiKeyAuth)]
#[security(Security::OAuth2(&["payments:write"]))]
#[use_guard(WritePaymentGuard)]
#[post("")]
async fn create(&self, #[body] input: CreatePaymentDto) -> Result<Response<PaymentDto>> {
// Runtime guard behavior is unchanged by the documentation attributes.
}
}
OpenAPI also supports OR alternatives, but Caelix’s current marker syntax intentionally documents the common AND case only. Model a route with optional or alternative authentication explicitly in your application and register its custom documentation scheme as appropriate.
Application-defined schemes
Use .security_scheme(...) and Security::Custom when the standard names do not fit—for example, a tenant key, a signed request, or an OpenID Connect component:
#![allow(unused)]
fn main() {
use caelix::openapi::{OpenApiConfig, Security, security};
let openapi = OpenApiConfig::new("Payments API", "1.0.0").security_scheme(
"TenantAuth",
security::SecurityScheme::ApiKey(security::ApiKey::Header(
security::ApiKeyValue::new("X-Tenant-Key"),
)),
);
#[security(Security::Custom {
name: "TenantAuth",
scopes: &[],
})]
#[get("/tenant/payments")]
async fn list(&self) -> Result<Vec<PaymentDto>> {
// ...
}
}
Security::Custom may include scopes for an application-defined OAuth2 scheme. Its name must exactly match the component name registered with .security_scheme(...).
Configuration validation
with_openapi(...) returns caelix::Result<Self>. Caelix validates the completed document before the application starts and returns an OpenAPI Configuration Error when:
- a route names a scheme that has not been registered;
BearerAuth,ApiKeyAuth,CookieAuth, orOAuth2is registered under the standard name with the wrong builder; or- the OpenAPI JSON or Swagger UI paths collide with controller routes.
This catches documentation drift early while keeping security configuration and runtime authentication intentionally separate. Use #[request_header(...)] for ordinary request metadata such as idempotency keys and tenant identifiers; do not use it to describe bearer tokens, API keys, or session cookies.
Responses And Errors
Handlers usually return caelix::Result<T>, where T implements IntoCaelixResponse. The controller macro converts successful values and HttpException errors into adapter responses.
#![allow(unused)]
fn main() {
#[get("")]
pub async fn list(&self) -> Result<Vec<UserDto>> {
Ok(self.users.list().await?)
}
}
Common response forms:
#![allow(unused)]
fn main() {
use http::StatusCode;
use caelix::Response;
Response::Body(value)
Response::WithStatus(StatusCode::CREATED, value)
Response::no_content()
Response::text(StatusCode::OK, "plain text")
Response::bytes(StatusCode::OK, bytes)
}
Handlers can also return String, &'static str, Response<T>, raw HttpResponse, or any Result<T> where T: IntoCaelixResponse.
JSON Responses
Response::Body(value) serializes value as JSON with status 200 OK.
#![allow(unused)]
fn main() {
#[get("/{id}")]
pub async fn find(&self, #[param] id: i64) -> Result<Response<UserDto>> {
Ok(Response::Body(self.users.find(id).await?))
}
}
Response::WithStatus(status, value) serializes JSON with a custom status:
#![allow(unused)]
fn main() {
#[post("")]
pub async fn create(&self, #[body] input: CreateUserDto) -> Result<Response<UserDto>> {
let user = self.users.create(input).await?;
Ok(Response::WithStatus(StatusCode::CREATED, user))
}
}
If JSON serialization fails, Caelix returns a generic 500 Internal Server Error JSON body.
Actix adapter errors for supported controller extractors are also returned in the Caelix JSON error shape. Invalid route params, such as a malformed Uuid passed to a #[param] argument, invalid #[query] values, and invalid JSON bodies return 400 Bad Request JSON responses. Missing required body, query, or path fields use the validation error shape with an errors object. Requests that do not match any registered route return a 404 Not Found JSON response.
Empty, Text, Bytes, And Raw
Use Response<()> helpers for non-JSON response bodies:
#![allow(unused)]
fn main() {
#[delete("/{id}")]
pub async fn delete(&self, #[param] id: i64) -> Result<Response<()>> {
self.users.delete(id).await?;
Ok(Response::no_content())
}
#[get("/health")]
pub async fn health(&self) -> Result<Response<()>> {
Ok(Response::text(StatusCode::OK, "ok"))
}
}
Response::bytes uses application/octet-stream. HttpResponse::new, HttpResponse::json, HttpResponse::text, and HttpResponse::bytes are available when you need the fully materialized response type.
Streaming Responses
HttpResponse holds a ResponseBody that is either fully buffered (Vec<u8>) or a streaming async sequence of Bytes chunks. Use streaming when the body is not ready as one blob — large exports, live feeds, or files on disk.
Migration note (breaking in 0.0.x): HttpResponse.body is no longer Vec<u8>. Use response.body_bytes() / response.body.as_buffered() / as_buffered_mut() for buffered bodies. Direct response.body.extend(...) or == b"..." comparisons need updating. Optional response headers live in response.headers as owned (String, String) pairs (with_header(name, value) accepts dynamic values) and are applied by the Actix adapter.
Streaming helpers return HttpResponse directly. Handlers typically return Result<HttpResponse> (which already implements IntoCaelixResponse as identity):
#![allow(unused)]
fn main() {
use caelix::{Bytes, HttpResponse, Response, Result, StreamExt};
#[get("/export")]
async fn export_csv(&self) -> Result<HttpResponse> {
let rows = self.repo.stream_all_users();
let bytes_stream = rows.map(|row| {
row.map(|r| Bytes::from(format!("{},{}\n", r.id, r.name)))
});
Ok(Response::stream("text/csv", bytes_stream))
}
}
StreamExt (for .map / .filter on streams) is re-exported from caelix so you do not need a direct futures-util dependency for the common helpers.
Server-Sent Events
Response::sse frames each stream item as JSON in SSE wire format (data: …\n\n) with content type text/event-stream, and sets Cache-Control: no-cache plus X-Accel-Buffering: no. It does not yet implement the full SSE protocol (id:, event:, retry:, Last-Event-ID resume):
#![allow(unused)]
fn main() {
#[get("/live-orders")]
async fn live_orders(&self) -> Result<HttpResponse> {
let stream = self.events.subscribe::<OrderPlacedEvent>();
Ok(Response::sse(stream))
}
}
File streaming
Response::file opens a path asynchronously and streams disk chunks (not the whole file in memory). Open errors are mapped by kind: missing path → 404 Not Found, permission denied → 403 Forbidden, other IO failures → 500 Internal Server Error.
#![allow(unused)]
fn main() {
#[get("/report.pdf")]
async fn report(&self) -> Result<HttpResponse> {
Response::file("/var/data/report.pdf", "application/pdf").await
}
}
The Actix adapter maps buffered bodies with .body(...) and streaming bodies with .streaming(...) (chunked transfer encoding), and applies HttpResponse.headers. Mid-stream errors cannot change the already-sent status line; they close the stream after logging.
Exceptions
Use typed exception constructors for client errors:
#![allow(unused)]
fn main() {
return Err(NotFoundException::new("user not found"));
}
Most HTTP client and server status families have a matching exception constructor, for example BadRequestException, UnauthorizedException, ForbiddenException, ConflictException, UnprocessableEntityException, TooManyRequestsException, and ServiceUnavailableException.
Validation errors can include field details:
#![allow(unused)]
fn main() {
use std::collections::BTreeMap;
let mut errors = BTreeMap::new();
errors.insert("email".to_string(), vec!["must be a valid email".to_string()]);
return Err(BadRequestException::new("Validation failed").with_errors(errors));
}
Server error responses are production-safe: if an HttpException has a 5xx status, the response body message is Internal Server Error rather than the internal error text. Generated controller routes log returned 5xx exceptions through the ExceptionHandler logger, including the internal source when one is attached.
#![allow(unused)]
fn main() {
let user = repository.find(id).await?;
}
caelix::Result<T> accepts ? from any error convertible into
anyhow::Error. This includes anyhow::Result, standard errors such as
std::io::Error, and conventional third-party errors that are Send, Sync,
and 'static. Unexpected errors become 500 Internal Server Error exceptions.
Use a typed Caelix exception when the failure should produce a deliberate
non-500 response.
#![allow(unused)]
fn main() {
async fn load_config() -> Result<String> {
let config = tokio::fs::read_to_string("config.toml").await?;
Ok(config)
}
}
The original error and its context chain are retained on
HttpException::source, but the client receives only:
{
"status": 500,
"error": "Internal Server Error",
"message": "Internal Server Error"
}
Cookies
Cookie::new defaults to HttpOnly, Secure, SameSite::Lax, and Path=/.
These defaults reduce script access, accidental cleartext transport, and common
cross-site request risks. Local plain-HTTP development can explicitly use
.secure(false); production cookies should normally retain Secure.
#![allow(unused)]
fn main() {
let response = Response::Body(user)
.with_cookie(Cookie::new("session", opaque_session_token))
.with_cookie(Cookie::new("theme", "dark").http_only(false));
}
Cookies can also be attached to raw, file, streaming, and SSE HttpResponse
values with .with_cookie(...). Every cookie becomes its own Set-Cookie
header and call order is preserved.
To log out, match the original path and domain:
#![allow(unused)]
fn main() {
Response::no_content().with_cookie(
Cookie::removal("session").path("/").domain("example.com"),
)
}
Caelix does not maintain a cookie jar or create, persist, rotate, sign, encrypt, or resolve sessions. Applications should use opaque random session tokens and validate them in their own session service. CSRF token generation and verification are separate concerns.
Guards And Interceptors
Guards decide whether a request may continue.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{BoxFuture, Guard, RequestContext, Result, UnauthorizedException, guard};
#[guard]
pub struct AuthGuard {
auth: Arc<AuthService>,
}
impl Guard for AuthGuard {
fn can_activate<'a>(&'a self, ctx: &'a RequestContext) -> BoxFuture<'a, Result<bool>> {
Box::pin(async move {
let Some(token) = ctx.bearer_token() else {
return Ok(false);
};
let user = self.auth
.verify(token)
.await
.map_err(|_| UnauthorizedException::new("invalid token"))?;
ctx.set(CurrentUser { id: user.id });
Ok(true)
})
}
}
}
#[guard] uses the same expansion as #[injectable], so named fields must be Arc<T>.
Attach guards at the controller or method level:
#![allow(unused)]
fn main() {
use caelix::{Result, controller};
#[controller("/admin")]
#[use_guard(AuthGuard)]
impl AdminController {
#[get("")]
async fn index(&self) -> Result<&'static str> {
Ok("ok")
}
}
}
Method-level guards are appended after controller-level guards. Guards run before extractors are passed to the controller method. If a guard returns Ok(false), the generated wrapper returns 403 Forbidden with message Access denied. If it returns Err(HttpException), that exception is returned to the client.
Guards and interceptors follow normal module visibility rules. Register them as providers, export them from the module that owns them, and import that module wherever a controller uses them. A global module makes only its explicitly exported guards and interceptors available everywhere.
#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};
impl Module for AuthModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.provider::<AuthGuard>()
.export::<AuthGuard>()
}
}
impl Module for AdminModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.import::<AuthModule>()
.controller::<AdminController>()
}
}
}
Request Context Enrichment
Guards are a good place to authenticate and attach typed request state:
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct CurrentUser {
pub id: i64,
}
impl Guard for AuthGuard {
fn can_activate<'a>(&'a self, ctx: &'a RequestContext) -> BoxFuture<'a, Result<bool>> {
Box::pin(async move {
let Some(token) = ctx.bearer_token() else {
return Ok(false);
};
let claims = self.auth.verify(token).await?;
ctx.set(CurrentUser { id: claims.sub })?;
Ok(true)
})
}
}
}
Controllers can then request CurrentUser with #[user].
Interceptors wrap the converted HttpResponse.
#![allow(unused)]
fn main() {
use caelix::{BoxFuture, HttpResponse, Interceptor, Next, RequestContext, Result, injectable};
#[injectable]
pub struct AuditInterceptor;
impl Interceptor for AuditInterceptor {
fn intercept<'a>(&'a self, ctx: &'a RequestContext, next: Next<'a>) -> BoxFuture<'a, Result<HttpResponse>> {
Box::pin(async move {
let response = next.run().await?;
println!("{} {} -> {}", ctx.method(), ctx.path(), response.status);
Ok(response)
})
}
}
}
Interceptors can also transform the response:
#![allow(unused)]
fn main() {
use caelix::HttpResponse;
#[injectable]
pub struct HeaderInterceptor;
impl Interceptor for HeaderInterceptor {
fn intercept<'a>(
&'a self,
_ctx: &'a RequestContext,
next: Next<'a>,
) -> BoxFuture<'a, Result<HttpResponse>> {
Box::pin(async move {
let mut response = next.run().await?;
if response.content_type == "application/json" {
if let Some(body) = response.body.as_buffered_mut() {
body.extend_from_slice(b"\n");
}
}
Ok(response)
})
}
}
}
Body transforms apply only to buffered responses. Streaming bodies (ResponseBody::Streaming) are opaque after the handler returns — interceptors can still change status or content type, but should not assume body_bytes() is present.
HttpResponse stores status, body, content type, and a simple list of owned header name/value pairs (headers: Vec<(String, String)>). Use with_header when chaining builders, or insert_header to mutate an existing response (typical in interceptors). That is enough for dynamic values such as Content-Disposition filenames or X-Request-Id. It is not a full typed HeaderMap (no multi-value merge helpers, no typed header enums). Use native Actix middleware when you need richer header-level response rewriting.
#![allow(unused)]
fn main() {
let mut response = next.run().await?;
response.insert_header("X-Request-Id", request_id);
Ok(response)
}
Execution Order
For each request, the generated wrapper:
- Builds
RequestContextfrom method, path, and headers. - Runs controller-level guards in listed order.
- Runs method-level guards in listed order.
- Resolves the controller.
- Builds the interceptor chain from controller-level then method-level interceptors.
- Extracts handler arguments and calls the method inside the innermost
Next. - Converts the handler return value into
HttpResponse. - Runs interceptors back outward.
Interceptors run in onion order. The first listed interceptor sees the request first and the response last.
Request Context
RequestContext contains the request method, path, correlation identifiers,
headers, bearer token helper, and typed extensions.
#![allow(unused)]
fn main() {
let method = ctx.method();
let path = ctx.path();
let request_id = ctx.request_id();
let trace_id = ctx.trace_id();
let token = ctx.bearer_token();
}
Header lookup is case-insensitive. Caelix lowercases header names when the context is created.
An incoming X-Request-Id is retained when valid; otherwise Caelix generates a
UUID. trace_id() reads the W3C traceparent trace ID, then X-Trace-Id, and
finally falls back to the request ID. Generated controller responses include
both identifiers as response headers.
bearer_token() reads the Authorization header and strips the exact Bearer prefix:
Authorization: Bearer eyJhbGciOi...
Guards and interceptors can attach typed values:
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct CurrentUser {
pub id: i64,
}
ctx.set(CurrentUser { id: 42 })?;
}
Typed values are stored by concrete Rust type. A later ctx.set::<CurrentUser>(...) replaces the earlier value for that type.
Guards and interceptors can read values back:
#![allow(unused)]
fn main() {
if let Some(user) = ctx.get::<CurrentUser>()? {
tracing::info!("user {}", user.id);
}
}
Controllers can read a typed value with #[user]:
#![allow(unused)]
fn main() {
#[get("/me")]
pub async fn me(&self, #[user] user: CurrentUser) -> Result<String> {
Ok(user.id.to_string())
}
}
If the typed value is missing, the generated wrapper returns 401 Unauthorized with message Not authenticated.
#[user] clones the value out of the context, so user types must be cloneable when used this way.
Cookies
Incoming cookies are available with ctx.cookie("session"). Parsing,
controller extraction, response cookies, and session security boundaries are
documented in Cookies and Sessions.
Lifecycle Hooks
Injectable has default lifecycle hooks:
#![allow(unused)]
fn main() {
fn on_module_init(&self) -> BoxFuture<'_, Result<()>>
fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>>
fn on_shutdown(&self) -> BoxFuture<'_, Result<()>>
}
The #[injectable] macro uses the default no-op hooks. Implement Injectable manually when a provider needs custom lifecycle behavior.
#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicBool, Ordering};
use caelix::{BoxFuture, Container, Injectable, ProviderDependency, Result, provider_dependencies};
pub struct Worker {
started: AtomicBool,
}
impl Injectable for Worker {
fn dependencies() -> Vec<ProviderDependency> {
provider_dependencies![]
}
fn create(_container: &Container) -> BoxFuture<'_, Result<Self>> {
Box::pin(async {
Ok(Self {
started: AtomicBool::new(false),
})
})
}
fn on_module_init(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
// Run after this provider is constructed, during registration.
Ok(())
})
}
fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
self.started.store(true, Ordering::SeqCst);
Ok(())
})
}
fn on_shutdown(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
self.started.store(false, Ordering::SeqCst);
Ok(())
})
}
}
}
When Hooks Run
on_module_initruns when an injectable provider is registered.on_bootstrapruns after module provider validation and event handler registration.on_shutdownruns whenApplication::listenexits or when startup reaches a bind failure after the application was built.
Controllers are providers, so controller types can define lifecycle hooks when they implement Injectable manually.
Failures
Lifecycle failures are converted into startup or shutdown errors that include the hook name and provider type. Application::new returns startup failures as caelix::Result<Application>.
#![allow(unused)]
fn main() {
fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(async {
Err(caelix::ServiceUnavailableException::new("worker failed to start"))
})
}
}
For 5xx exceptions, the client response body is sanitized. Startup errors are returned to the caller, and generated controller routes log returned 5xx exceptions server-side before sending the sanitized response.
Async Factory Limitation
Providers registered with .provider_async_factory::<T, _, _>(provider_dependencies![...], ...) are construction-only. Caelix stores no lifecycle callbacks for the concrete type, so factory providers use no-op on_module_init, on_bootstrap, and on_shutdown. If a provider needs lifecycle hooks, implement Injectable and register it with .provider::<T>().
Events
EventBus is opt-in. Import EventModule in a module that emits events or registers event handlers. Event handlers are normal injectable providers and must be registered as providers before being registered as event handlers.
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct UserCreated {
pub id: i64,
}
use std::sync::Arc;
use caelix::{
BoxFuture, EventBus, EventHandler, EventModule, Module, ModuleMetadata,
RegisterableEventHandler, Result, injectable,
};
#[injectable]
pub struct SendWelcomeEmail;
impl EventHandler<UserCreated> for SendWelcomeEmail {
fn handle(&self, event: UserCreated) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
println!("created user {}", event.id);
Ok(())
})
}
}
impl RegisterableEventHandler for SendWelcomeEmail {
type Event = UserCreated;
}
#[injectable]
pub struct UsersService {
events: Arc<EventBus>,
}
impl UsersService {
pub async fn create(&self, input: CreateUserDto) -> Result<UserDto> {
let user = UserDto {
id: 1,
email: input.email,
};
self.events.emit(UserCreated { id: user.id }).await?;
Ok(user)
}
}
pub struct UsersModule;
impl Module for UsersModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.import::<EventModule>()
.provider::<UsersService>()
.provider::<SendWelcomeEmail>()
.event_handler::<SendWelcomeEmail>()
}
}
}
Use .event_handler_for::<Event, Handler>() when explicit event registration is clearer.
#![allow(unused)]
fn main() {
ModuleMetadata::new()
.import::<EventModule>()
.provider::<SendWelcomeEmail>()
.event_handler_for::<UserCreated, SendWelcomeEmail>()
}
Emit events by resolving EventBus from a provider and calling emit.
#![allow(unused)]
fn main() {
let events = container.resolve::<EventBus>()?;
events.emit(UserCreated { id: 42 }).await?;
}
emit runs handlers registered for the event type in registration order. If a handler returns an error, emit stops and returns that error; later handlers for the same event are not run, and live subscribers do not receive the event. Only after all handlers succeed does emit fan out to stream subscribers.
Event payloads must be Clone + Send + Sync + 'static because the same event value may be passed to multiple handlers and broadcast subscribers.
Live subscriptions
EventBus::subscribe::<E>() returns an async Stream of events of type E and creates the broadcast channel for that type. Events emitted before the subscription are not replayed. Slow consumers may lag; lagged events are dropped (with a warning log) rather than blocking emit.
emit alone never allocates a broadcast channel — it only sends when someone has already called subscribe for that event type.
#![allow(unused)]
fn main() {
use caelix::{EventBus, HttpResponse, Response, Result};
#[get("/live-orders")]
async fn live_orders(&self) -> Result<HttpResponse> {
let stream = self.events.subscribe::<OrderPlacedEvent>();
Ok(Response::sse(stream))
}
}
Handlers registered with .event_handler / .event_handler_for still run on every emit. Live subscribers are an additional fan-out after successful handler processing.
Registration Forms
.event_handler::<H>() requires:
#![allow(unused)]
fn main() {
impl EventHandler<UserCreated> for SendWelcomeEmail { /* ... */ }
impl RegisterableEventHandler for SendWelcomeEmail {
type Event = UserCreated;
}
}
.event_handler_for::<UserCreated, SendWelcomeEmail>() avoids the RegisterableEventHandler implementation:
#![allow(unused)]
fn main() {
ModuleMetadata::new()
.provider::<SendWelcomeEmail>()
.event_handler_for::<UserCreated, SendWelcomeEmail>()
}
Both forms still require the handler to be registered as a provider.
Microservices
Caelix uses the same dependency-injected module graph for HTTP applications and
broker consumers. Handler metadata is transport-neutral; a running
MicroserviceApplication selects NATS or Redis.
Select a transport
[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["microservices-nats"] }
serde = { version = "1", features = ["derive"] }
Use microservices-redis for Redis, or enable both when one binary needs both
client option types. These features do not select an HTTP runtime.
NATS commands use Core NATS queue groups and events use JetStream. Redis commands and events use Streams; temporary Pub/Sub channels carry command replies. Both provide competing command consumers, durable at-least-once events, typed JSON envelopes, end-to-end deadlines, bounded concurrency, and graceful shutdown.
Complete service
use caelix::{
MessageContext, MicroserviceApplication, Module, ModuleMetadata,
NatsTransportOptions, Result,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Sum { left: i64, right: i64 }
#[derive(Serialize)]
struct Total { value: i64 }
#[derive(Deserialize)]
struct AuditEvent { order_id: i64 }
#[caelix::injectable]
struct MathMessages;
#[caelix::microservice]
impl MathMessages {
#[caelix::message_pattern("math.sum")]
async fn sum(&self, #[caelix::payload] input: Sum) -> Result<Total> {
Ok(Total { value: input.left + input.right })
}
#[caelix::event_pattern("audit.created")]
async fn audit(
&self,
#[caelix::context] context: MessageContext,
#[caelix::payload] event: AuditEvent,
) -> Result<()> {
// Persist `(context.event_id(), event.order_id)` atomically in real code.
let _ = (context.event_id(), event.order_id);
Ok(())
}
}
struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().microservice::<MathMessages>()
}
}
#[caelix::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let options = NatsTransportOptions::new("nats://127.0.0.1:4222")
.service_name("math")
.jetstream_stream("CAELIX_EVENTS");
MicroserviceApplication::<AppModule>::new(options).await?.run().await?;
Ok(())
}
.microservice::<T>() registers T as a normal provider, so constructor
injection and lifecycle hooks work unchanged. Startup builds the container,
collects handler definitions, validates topology, connects, and subscribes.
Continue with Handlers and Client, then the NATS or Redis transport guide. Production concerns and tests are in Operations, Testing, and Interoperability.
Microservice Handlers and Client
#[microservice] applies to an inherent impl of an injectable class.
#[message_pattern("subject")] declares request/reply; #[event_pattern]
declares an event. Subjects are non-empty dot-separated tokens. Commands cannot
use wildcards; events may use a whole-token * or terminal >.
Handlers must be non-generic async fn methods with &self, exactly one named
#[payload] parameter, and at most one #[context] MessageContext in either
argument order. Payloads implement DeserializeOwned + Send. Commands return
Result<T> where T: Serialize + Send; events return Result<()>.
MessageContext exposes subject, application headers, command
correlation_id, propagated deadline, shutdown cancellation_token, durable
delivery metadata, one-based delivery_attempt, and stable event event_id.
Use event_id as a deduplication key.
Typed client
#![allow(unused)]
fn main() {
let client = MicroserviceClient::connect(
NatsTransportOptions::new("nats://127.0.0.1:4222")
.service_name("orders"),
).await?;
let order: Order = client.request("orders.create", input).await?;
client.emit("orders.created", event).await?;
}
MicroserviceClient is also registered in the microservice container, so
providers can inject Arc<MicroserviceClient>. request<P, R> serializes a
typed payload, applies rpc_timeout as an end-to-end deadline, and decodes a
typed response. emit<P> publishes a durable event and returns after broker
acceptance, not handler completion.
Current public request methods create the envelope headers internally; handlers
can read propagated headers from MessageContext, but the typed client does not
currently expose a per-request header builder.
Client failures are categorized as Timeout, NoResponder, Decode,
Protocol, Transport, or Remote(RemoteError). A remote error contains a
stable code, safe message, optional safe JSON details, and a retryable flag.
Internal exception sources and unsafe server messages never cross the broker.
NATS Microservices
Commands subscribe through Core NATS queue groups. All replicas with the same
service_name compete for each command. Events publish to JetStream and use a
durable consumer per service, so every service group receives an event and one
replica in each group handles it.
#![allow(unused)]
fn main() {
let options = NatsTransportOptions::new("nats://127.0.0.1:4222")
.service_name("billing")
.jetstream_stream("CAELIX_EVENTS")
.dead_letter_subject("caelix.dead");
}
service_name is required when command handlers exist. jetstream_stream is
required for event topology and names an existing stream whose subjects cover
the event patterns.
Options
| Method | Default | Meaning |
|---|---|---|
new(server) | required | NATS server URL |
service_name(name) | none | stable command queue group and event consumer identity |
rpc_timeout(duration) | 5 s | command request deadline |
jetstream_stream(name) | none | durable event stream |
dead_letter_subject(subject) | none | final-failure event destination |
max_request_bytes(n) | 1 MiB | encoded command envelope limit; minimum 1 |
max_response_bytes(n) | 1 MiB | encoded response limit; minimum 256 |
max_event_bytes(n) | 1 MiB | encoded event envelope limit; minimum 1 |
max_handler_concurrency(n) | 64 | simultaneous invocations per subscription; minimum 1 |
shutdown_timeout(duration) | 10 s | wait for transport tasks |
max_event_deliveries(n) | 5 | attempt that dead-letters; minimum 1 |
event_retry_delay(duration) | 1 s | delayed NAK interval |
event_ack_wait(duration) | 30 s | unacknowledged redelivery wait; minimum 10 ms |
Commands are ordinary NATS request/reply messages and require an active responder. Events are acknowledged only after a successful handler. Retryable failures are negatively acknowledged with the configured delay. At the delivery limit, a configured dead-letter subject receives the event and safe failure metadata; otherwise the transport terminates the retry cycle according to its consumer handling.
Keep the JetStream stream durable and size/age-limited according to broker
operations. During shutdown, Caelix cancels intake, waits up to
shutdown_timeout for tasks, then runs module shutdown hooks.
Redis Microservices
Redis 6.2 or newer is required because recovery uses Streams consumer-group commands including automatic claiming. Each command subject maps to a deterministic Stream. The client subscribes to a unique temporary Pub/Sub reply channel before appending the command. Events share a configured Stream.
Replicas with the same service_name share consumer groups. One replica handles
each command; each service group receives every event, with one replica handling
it inside that group.
#![allow(unused)]
fn main() {
let options = RedisTransportOptions::new("redis://127.0.0.1/")
.service_name("billing")
.event_stream("caelix:events")
.dead_letter_stream("caelix:dead");
}
Options
| Method | Default | Meaning |
|---|---|---|
new(server) | required | Redis URL |
service_name(name) | none | required stable consumer-group identity |
event_stream(key) | caelix:events | shared event Stream |
dead_letter_stream(key) | none | distinct final-failure Stream |
rpc_timeout(duration) | 5 s | complete request/reply deadline |
max_request_bytes(n) | 1 MiB | command envelope limit; minimum 1 |
max_response_bytes(n) | 1 MiB | response envelope limit; minimum 256 |
max_event_bytes(n) | 1 MiB | event envelope limit; minimum 1 |
max_handler_concurrency(n) | 64 | concurrent handler bound; minimum 1 |
shutdown_timeout(duration) | 10 s | graceful task deadline |
max_event_deliveries(n) | 5 | final delivery attempt; minimum 1 |
event_retry_delay(duration) | 1 s | idle interval before event reclaim; minimum 10 ms |
command_recovery_delay(duration) | 30 s | idle interval before abandoned command reclaim; minimum 10 ms |
approximate_max_event_entries(n) | unlimited | opt-in MAXLEN ~ event trimming |
An expired command is acknowledged without invoking user code. Pending commands
from a failed replica are reclaimed after command_recovery_delay; handlers
must therefore be idempotent even though a healthy request normally executes
once. Events are at least once and retry through pending-entry recovery.
Event retention is unlimited unless trimming is enabled. Approximate trimming can delete entries still pending in a slower service group; set it only with a retention policy covering the slowest consumer. A dead-letter Stream must be non-empty and differ from the event Stream. Shutdown stops intake, drains within the configured timeout, and runs lifecycle hooks.
Microservice Operations, Testing, and Interoperability
Reliability
Broker delivery is at least once after consumer failure. Make command writes and
event effects idempotent. Store MessageContext::event_id() with the business
transaction and reject an already-committed ID. For commands, use a business
idempotency key in the payload because correlation IDs identify attempts, not
business operations.
Concurrency is bounded per subscription by max_handler_concurrency; also size
database pools and downstream limits. Respect the propagated deadline and
cancellation_token() in long-running work. Shutdown stops intake, waits for
tasks, then runs normal provider/module hooks.
Remote failures are sanitized. Log internal exception sources on the service; return stable public codes and safe details to callers. Monitor broker connectivity, request latency/timeouts, no-responder errors, pending/redelivered events, delivery attempts, dead-letter destinations, handler saturation, and shutdown timeouts.
Tests
Handler metadata and invocation can be tested without a broker:
#![allow(unused)]
fn main() {
let container = caelix::build_container::<AppModule>().await?;
let handler = OrdersMessages::definition()
.handlers()
.into_iter()
.find(|handler| handler.pattern == "orders.create")
.unwrap();
let result = handler.invoke(&container, context, serde_json::json!({"id": 1})).await?;
}
Use broker-backed integration tests for envelopes, queue/consumer competition,
deadlines, reconnection, retry, dead letters, and shutdown. Run isolated NATS
with JetStream or Redis 6.2+, use unique stream/subject prefixes, start
MicroserviceRuntime, create a real MicroserviceClient, and always call
shutdown(). The repository integration tests and event probes cover the
supported transports.
NestJS interoperability
Maintained executable examples live in
examples/nestjs-nats-interoperability
and
examples/nestjs-redis-interoperability.
They are the authoritative cross-framework demonstrations.
Caelix uses its own versioned JSON envelope for correlation IDs, deadlines, headers, typed payloads, safe failures, and event IDs. NestJS’s stock transport serializer is therefore not wire-compatible by itself; the examples install the matching custom envelope adapter. Keep that adapter aligned whenever the protocol version changes.
WebSockets
Caelix supports two different real-time protocols:
- RFC 6455 WebSockets, exposed through
caelix::websocketand available with both the Actix and Axum runtimes. - Socket.IO, exposed through
caelix::socket_ioand available only when the Axum-selectingsocketiofeature is enabled.
They share Caelix’s gateway registration model, but they are not interchangeable. An RFC 6455 client speaks raw WebSocket frames and receives text or binary messages. A Socket.IO client speaks the Socket.IO protocol and can use events, acknowledgements, namespaces, and rooms.
RFC 6455 WebSocket gateways
The gateway API below is runtime-neutral. The same WebSocketGateway
implementation works with Actix and Axum; each runtime mounts the registered
#[gateway] path as a WebSocket upgrade route. Runtime-specific setup is
covered in the Axum WebSocket support section
below.
Define a gateway
A WebSocket gateway is an injectable provider that implements
WebSocketGateway. The #[gateway("/path")] attribute supplies the route;
the module explicitly registers the gateway just like a controller or service.
This gateway echoes text and binary messages and sends a greeting after the handshake:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{
gateway, injectable, BoxFuture, Bytes, Module, ModuleMetadata, Result,
};
use caelix::websocket::{WebSocketGateway, WebSocketRequest, WebSocketSession};
#[injectable]
struct ChatGateway;
#[gateway("/chat")]
impl WebSocketGateway for ChatGateway {
fn on_connect(
&self,
session: Arc<WebSocketSession>,
request: WebSocketRequest,
) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
println!("client {} connected to {}", session.id(), request.path());
session.send_text("connected").await
})
}
fn on_text(
&self,
session: Arc<WebSocketSession>,
text: String,
) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
session.send_text(format!("received text: {text}")).await
})
}
fn on_binary(
&self,
session: Arc<WebSocketSession>,
data: Bytes,
) -> BoxFuture<'_, Result<()>> {
Box::pin(async move { session.send_binary(data).await })
}
}
struct ChatModule;
impl Module for ChatModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().gateway::<ChatGateway>()
}
}
}
The BoxFuture return type is part of the framework-neutral callback trait.
For a callback that does not need to perform asynchronous work, return a
boxed async block:
#![allow(unused)]
fn main() {
fn on_text(
&self,
_session: Arc<WebSocketSession>,
_text: String,
) -> BoxFuture<'_, Result<()>> {
Box::pin(async { Ok(()) })
}
}
Gateways are ordinary injectable providers. Constructor injection, imported modules, provider lifecycle hooks, and explicit module registration work the same way as they do for the rest of Caelix:
#![allow(unused)]
fn main() {
struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().import::<ChatModule>()
}
}
}
Register a gateway in either an application module or an imported module, but do not register the same gateway path twice in the module tree.
Callback lifecycle
WebSocketGateway provides five callbacks. All except on_error and
on_close can return a caelix::Result<()> through BoxFuture.
| Callback | When it runs | Typical use |
|---|---|---|
on_connect | After the WebSocket handshake | Inspect the request, initialize state, send a welcome message |
on_text | For each complete text message | Decode commands or JSON text and send a response |
on_binary | For each complete binary message | Process bytes or forward a binary payload |
on_error | When a callback fails or the transport reports a protocol error | Log the failure and collect metrics |
on_close | Once when the connection ends | Release application-level resources |
Fragmented frames are reassembled before on_text or on_binary runs. Ping
frames receive an automatic pong reply; application code normally only needs
to use session.ping(...) when it wants an explicit heartbeat.
A gateway can send messages at any time while the session is open:
#![allow(unused)]
fn main() {
fn send_heartbeat(
&self,
session: Arc<WebSocketSession>,
) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
session.ping(b"heartbeat").await?;
session.send_text("server heartbeat").await
})
}
}
WebSocketSession is cloneable and safe to share with tasks owned by the
gateway. Use session.id() for a per-connection identifier and
session.is_open() before scheduling work that may outlive a callback.
Reading request metadata
on_connect receives a WebSocketRequest containing the path, raw query
string, optional peer address, and case-insensitive request headers:
#![allow(unused)]
fn main() {
fn on_connect(
&self,
session: Arc<WebSocketSession>,
request: WebSocketRequest,
) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
let token = request
.header("authorization")
.or_else(|| request.header("x-api-key"));
println!(
"{} connected with query {:?} and token present: {}",
session.id(),
request.query_string(),
token.is_some()
);
Ok(())
})
}
}
Browser WebSocket clients cannot set arbitrary handshake headers. For browser
authentication, use a short-lived token in the query string or authenticate
the normal HTTP request before issuing the upgrade. Server-side clients can
usually send an Authorization header.
Closing and handling failures
Use WebSocketCloseFrame and WebSocketCloseCode for an application-initiated
close:
#![allow(unused)]
fn main() {
use caelix::websocket::{WebSocketCloseCode, WebSocketCloseFrame};
fn stop_session(
&self,
session: Arc<WebSocketSession>,
) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
session
.close(Some(WebSocketCloseFrame::new(
WebSocketCloseCode::GoingAway,
"server is shutting down",
)))
.await
})
}
}
The most useful close codes are Normal (1000), GoingAway (1001),
Policy (1008), MessageTooBig (1009), and Internal (1011). A
callback error invokes on_error and closes the connection with 1011. A
malformed frame invokes on_error and closes with Protocol (1002).
on_close receives an optional close frame when the runtime has one (for
example, for a peer close or a handler/protocol close). It also runs when the
transport disappears, even if there is no close frame. Keep cleanup in
on_close rather than in only one of the message callbacks.
#![allow(unused)]
fn main() {
fn on_error(
&self,
session: Arc<WebSocketSession>,
error: caelix::websocket::WebSocketError,
) -> BoxFuture<'_, ()> {
Box::pin(async move {
eprintln!("websocket {} failed: {error}", session.id());
})
}
fn on_close(
&self,
session: Arc<WebSocketSession>,
frame: Option<WebSocketCloseFrame>,
) -> BoxFuture<'_, ()> {
Box::pin(async move {
println!("websocket {} closed: {frame:?}", session.id());
})
}
}
Limit message size
The default maximum complete message size is 1 MiB. Configure it before
calling listen or into_router:
#![allow(unused)]
fn main() {
let app = Application::new::<ChatModule>()
.await?
.websocket_max_message_size(8 * 1024 * 1024)
.into_router();
}
The limit applies to the assembled text or binary message, not just one fragment. Keep it proportional to the memory available to the service, and prefer application-level limits for JSON fields or uploads that should be smaller than the transport limit.
Browser client
The browser’s built-in WebSocket client speaks RFC 6455 directly:
<script>
const socket = new WebSocket(
"ws://localhost:3000/chat?token=temporary-token"
);
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
socket.send("hello");
socket.send(new Uint8Array([0, 1, 2, 255]));
});
socket.addEventListener("message", ({ data }) => {
if (typeof data === "string") {
console.log("text from server:", data);
} else {
console.log("binary from server:", new Uint8Array(data));
}
});
socket.addEventListener("close", ({ code, reason }) => {
console.log("closed", code, reason);
});
</script>
The server’s on_text callback receives hello and on_binary receives the
four bytes. WebSocket itself does not define event names or acknowledgements;
if an application needs those, define a message format explicitly or use
Socket.IO.
Axum WebSocket support
This section covers the Axum runtime adapter only. The gateway callbacks, session methods, close frames, and message semantics are the runtime-neutral API described above.
Enable Axum
Axum is selected explicitly because Actix is Caelix’s default runtime:
[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }
The axum feature and the actix feature are mutually exclusive. With this
configuration, the gateway source above is mounted by Caelix’s Axum adapter.
If an application selects the Actix feature instead, the same gateway source
is mounted by the Actix adapter; it does not use Axum types.
Start an Axum WebSocket application
Application::new builds the container and validates gateway metadata.
into_router mounts the regular HTTP routes and the WebSocket upgrade routes;
listen does both steps for a production server:
use caelix::Application;
#[caelix::main]
async fn main() -> std::io::Result<()> {
Application::new::<ChatModule>()
.await
.map_err(|error| std::io::Error::other(error.message))?
.listen("127.0.0.1:3000")
.await
}
The /chat route from the gateway example is now available as an RFC 6455
upgrade endpoint. The browser client connects to it with ws:// or wss://;
it does not use Socket.IO framing.
Mount the Axum router yourself
If another Axum server owns the listener, obtain the fully configured router:
#![allow(unused)]
fn main() {
let app = Application::new::<ChatModule>()
.await?
.into_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
}
into_router includes Caelix controller routes, the WebSocket gateway routes,
the configured body limit, and the fallback response. Calling it is the
Axum-specific escape hatch for embedding Caelix in a larger Axum application.
Add Axum and Tower middleware
The application exposes Axum’s Tower-layer boundary. Middleware added before
into_router applies to the HTTP upgrade request and to the resulting Axum
router:
#![allow(unused)]
fn main() {
use tower_http::trace::TraceLayer;
let app = Application::new::<ChatModule>()
.await?
.layer(TraceLayer::new_for_http())
.into_router();
}
Use Axum/Tower middleware for concerns such as tracing, compression of normal HTTP responses, or request-level policy. WebSocket message authorization and connection cleanup belong in the gateway callbacks because they occur after the HTTP upgrade.
Configure the Axum message limit
Axum applies a default maximum complete WebSocket message size of 1 MiB. Set the limit before building the router:
#![allow(unused)]
fn main() {
let app = Application::new::<ChatModule>()
.await?
.websocket_max_message_size(8 * 1024 * 1024)
.into_router();
}
The limit is for the assembled message, including fragmented messages. A
message that exceeds it is rejected by the WebSocket transport rather than
being delivered to on_text or on_binary as a partial payload.
Socket.IO support (Axum only)
Socket.IO is a higher-level protocol built on top of Engine.IO. It is useful when the client needs named events, acknowledgement callbacks, namespaces, rooms, and Socket.IO’s connection behavior. It is not a drop-in replacement for a raw WebSocket endpoint.
The Caelix integration is backed by
socketioxide and selects Axum
transitively:
[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["socketio"] }
This feature cannot be combined with the default Actix backend. It exposes
caelix::socket_io::{SocketIoHandle, SocketRef, Data, AckSender} and the
Application::with_socket_io::<AppModule>() method.
Define namespaces and events
For Socket.IO, put #[gateway("/namespace")] on an inherent implementation
and annotate each async event method with #[on_message("event")]. A handler
accepts either payload: T or socket: SocketRef, payload: T and returns
Result<Reply>.
The payload type must be deserializable by Socket.IO. It can be a scalar such
as String, or a structured type:
#![allow(unused)]
fn main() {
use caelix::{gateway, injectable, Module, ModuleMetadata, Result};
use caelix::socket_io::SocketRef;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ChatMessage {
text: String,
}
#[injectable]
struct ChatGateway;
#[gateway("/chat")]
impl ChatGateway {
#[on_message("echo")]
async fn echo(&self, message: ChatMessage) -> Result<ChatMessage> {
Ok(message)
}
#[on_message("join")]
async fn join(&self, socket: SocketRef, room: String) -> Result<String> {
socket.join(room);
Ok("joined".to_owned())
}
#[on_message("announce")]
async fn announce(&self, socket: SocketRef, message: ChatMessage) -> Result<String> {
let _ = socket
.within("general")
.emit("chat-message", &message)
.await;
Ok("sent".to_owned())
}
}
struct ChatModule;
impl Module for ChatModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().gateway::<ChatGateway>()
}
}
}
The gateway path is a Socket.IO namespace, so this example listens on
/chat. Add another gateway with #[gateway("/admin")] to register another
namespace. Namespace paths must be distinct from one another, just as raw
WebSocket gateway paths must be distinct.
If the server does not need the socket itself, omit the SocketRef argument:
#![allow(unused)]
fn main() {
#[on_message("ping")]
async fn ping(&self, value: String) -> Result<String> {
Ok(format!("pong: {value}"))
}
}
Handlers must be async and must take one payload argument. The macro registers
the Socket.IO Data<T> extractor for that argument and supplies an
AckSender internally.
Start Socket.IO
Socket.IO is created during Application::new, but it is mounted only when
with_socket_io is called. Call it before listen or into_router:
use caelix::Application;
#[caelix::main]
async fn main() -> std::io::Result<()> {
Application::new::<ChatModule>()
.await
.map_err(|error| std::io::Error::other(error.message))?
.with_socket_io::<ChatModule>()
.listen("127.0.0.1:3000")
.await
}
with_socket_io::<ChatModule>() visits the module tree, registers every
Socket.IO gateway with its namespace, and attaches the Socket.IO Tower layer.
Calling it twice on the same application is an error. A Socket.IO gateway is
not mounted by the raw RFC 6455 route builder; it needs this explicit step.
Acknowledgements and errors
When a client supplies an acknowledgement callback, a successful handler return is sent to that callback. The return value is still useful without an acknowledgement callback: the handler runs normally, but there is no reply to deliver.
For a handler that returns an error, Caelix serializes this shape when an ack
was requested and emits the same value through the socket’s generic error
event:
{
"error": "Bad Request",
"message": "invalid chat message"
}
For example, a validation handler can return a normal Caelix exception:
#![allow(unused)]
fn main() {
#[on_message("validate")]
async fn validate(&self, message: ChatMessage) -> Result<String> {
if message.text.trim().is_empty() {
return Err(caelix::BadRequestException::new("message is empty"));
}
Ok("valid".to_owned())
}
}
The error is also emitted as an event named error, so clients should attach
an error listener when they need to observe failures independently of an ack.
Rooms and broadcasts
SocketRef exposes Socket.IO’s native room and namespace operations. Joining
a room is synchronous; emitting to a room is asynchronous:
#![allow(unused)]
fn main() {
#[on_message("subscribe")]
async fn subscribe(&self, socket: SocketRef, room: String) -> Result<String> {
socket.join(room.clone());
Ok(format!("subscribed to {room}"))
}
#[on_message("broadcast")]
async fn broadcast(&self, socket: SocketRef, message: String) -> Result<String> {
let _ = socket
.within("announcements")
.emit("announcement", &message)
.await;
Ok("broadcast complete".to_owned())
}
}
Use the room name supplied by the client only after applying the application’s authorization rules. A room is not an authorization boundary by itself.
Inject the Socket.IO server handle
SocketIoHandle is registered in the Caelix container before application
providers are built. Services can inject it with the normal Arc<T> provider
pattern:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{injectable, Module, ModuleMetadata};
use caelix::socket_io::SocketIoHandle;
#[injectable]
struct NotificationService {
socket_io: Arc<SocketIoHandle>,
}
impl NotificationService {
fn server(&self) -> &caelix::socket_io::SocketIo {
self.socket_io.io()
}
}
struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.provider::<NotificationService>()
.gateway::<ChatGateway>()
}
}
}
Use SocketRef inside a handler when the operation belongs to the current
connection. Inject SocketIoHandle when a long-lived service needs access to
the Socket.IO server while processing application events. Keep those services
independent of a particular client socket where possible.
JavaScript client
Install a Socket.IO client, then connect to the namespace that matches the gateway path:
npm install socket.io-client
import { io } from "socket.io-client";
const socket = io("http://localhost:3000/chat", {
transports: ["websocket"],
});
socket.on("connect", () => {
socket.emit("echo", { text: "hello" }, (reply) => {
console.log("echo acknowledgement:", reply);
});
socket.timeout(3000).emit("join", "general", (error, reply) => {
if (error) console.error("join timed out", error);
else console.log(reply);
});
});
socket.on("chat-message", (message) => {
console.log("message from the general room:", message);
});
socket.on("error", (error) => {
console.error("server handler error:", error);
});
The io URL’s /chat suffix is the Socket.IO namespace and must match the
Rust gateway path. It is not merely an HTTP route prefix. Socket.IO may use
polling and then upgrade by default; forcing transports: ["websocket"] is
optional and is useful when the deployment only permits WebSocket transport.
Test the integration
The Socket.IO integration is compiled only with its feature. Run its Rust compatibility test with:
cargo test -p caelix --no-default-features --features socketio --test socketio
The repository test uses the official socket.io-client package to verify
namespace connections, acknowledgements, room broadcasts, and the error
event. For an application test, start Application::new::<AppModule>(), call
with_socket_io, use into_router() with an ephemeral listener, and connect
with a real Socket.IO client so the transport and protocol are exercised
together.
Choosing between the integrations
Use raw WebSockets when the protocol should be small and explicit, clients
already use the browser WebSocket API, or the application only needs text
and binary messages. Use Socket.IO when named events, acknowledgements, room
broadcasts, or Socket.IO client compatibility are central requirements.
Both integrations use injectable gateways and explicit module registration.
Only Socket.IO requires with_socket_io, and only Socket.IO gateways use
#[on_message].
Service-Level Cache
Caelix cache support is explicit service-level caching. It does not add automatic HTTP response caching.
Import CacheModule and inject Cache into a service:
#![allow(unused)]
fn main() {
use std::{sync::Arc, time::Duration};
use caelix::{Cache, CacheModule, Module, ModuleMetadata, Result, injectable};
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.import::<CacheModule>()
.import::<UsersModule>()
}
}
#[injectable]
pub struct UsersService {
cache: Arc<Cache>,
}
impl UsersService {
pub async fn find_cached(&self, id: i64) -> Result<Option<UserDto>> {
let key = format!("users:{id}");
if let Some(user) = self.cache.get(&key).await? {
return Ok(Some(user));
}
let user = self.find(id).await?;
if let Some(user) = &user {
self.cache.set_with_ttl(&key, user, Duration::from_secs(60)).await?;
}
Ok(user)
}
}
}
Available methods:
#![allow(unused)]
fn main() {
cache.get::<UserDto>("users:1").await?;
cache.set("users:1", &user).await?;
cache.set_with_ttl("users:1", &user, Duration::from_secs(60)).await?;
cache.set_with_optional_ttl("users:1", &user, Some(Duration::from_secs(60))).await?;
cache.delete("users:1").await?;
cache.clear().await?;
}
Values are serialized to serde_json::Value before storage and deserialized on get.
CacheModule registers MemoryCacheStore and Cache. MemoryCacheStore supports maximum entries, maximum serialized value size, and an optional default TTL through MemoryCacheOptions.
#![allow(unused)]
fn main() {
use std::time::Duration;
use caelix::{Cache, MemoryCacheOptions, MemoryCacheStore, Module, ModuleMetadata, provider_dependencies};
pub struct CacheConfigModule;
impl Module for CacheConfigModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.provider_async_factory::<MemoryCacheStore, _, _>(provider_dependencies![], |_container| async {
Ok::<_, std::convert::Infallible>(
MemoryCacheStore::with_options(MemoryCacheOptions {
max_entries: 10_000,
max_value_bytes: 2 * 1024 * 1024,
default_ttl: Some(Duration::from_secs(300)),
}),
)
})
.provider::<Cache>()
}
}
}
When the cache reaches max_entries, the memory store removes the oldest inserted entries. Expired entries are removed during cache writes and missed on reads.
To use a custom backend, implement CacheStore and register Cache with a factory that constructs Cache::new(Arc<dyn CacheStore>).
#![allow(unused)]
fn main() {
use serde_json::Value;
use std::{sync::Arc, time::Duration};
use caelix::{BoxFuture, Cache, CacheStore, Module, ModuleMetadata, Result};
pub struct RedisCacheStore;
impl CacheStore for RedisCacheStore {
fn get(&self, key: String) -> BoxFuture<'_, Result<Option<Value>>> {
Box::pin(async move {
// load JSON value by key
Ok(None)
})
}
fn set(&self, key: String, value: Value, ttl: Option<Duration>) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
// write JSON value with optional TTL
Ok(())
})
}
fn delete(&self, key: String) -> BoxFuture<'_, Result<()>> {
Box::pin(async move { Ok(()) })
}
fn clear(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(async move { Ok(()) })
}
}
pub struct RedisCacheModule;
impl Module for RedisCacheModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.provider_async_factory::<Cache, _, _>(provider_dependencies![], |_container| async move {
let store: Arc<dyn CacheStore> = Arc::new(RedisCacheStore);
Ok::<_, std::convert::Infallible>(Cache::new(store))
})
}
}
}
Application Runtime
caelix provides Application for production and TestApplication for
in-process HTTP tests on both the Actix and Axum backends (see
Testing). The Axum adapter is documented in Axum and
Tower.
#![allow(unused)]
fn main() {
use caelix::Application;
Application::new::<AppModule>()
.await
?
.listen("127.0.0.1:8080")
.await
}
Application::new returns startup failures as caelix::Result<Application>.
#![allow(unused)]
fn main() {
let app = Application::new::<AppModule>().await?;
app.listen("127.0.0.1:8080").await?;
}
Startup builds the container from module metadata, logs controller routes, runs bootstrap hooks, and stores the adapter route registration function.
Body Limits and Upload Storage
The default JSON body limit is 1 MiB. Override it before listen:
#![allow(unused)]
fn main() {
Application::new::<AppModule>()
.await
?
.body_limit(2 * 1024 * 1024)
.listen("127.0.0.1:8080")
.await
}
The same limit applies to multipart controller routes. Oversized JSON or
multipart bodies return 413 Payload Too Large; malformed JSON or multipart
data returns 400 Bad Request. Configure the isolated directory used to stage
multipart file parts with upload_temp_dir:
#![allow(unused)]
fn main() {
Application::new::<AppModule>()
.await?
.body_limit(10 * 1024 * 1024)
.upload_temp_dir("/var/tmp/my-service/uploads")
.listen("127.0.0.1:8080")
.await?;
}
See Multipart Uploads for route-level upload limits, file ownership, and typed form binding.
Workers
Application starts the Actix server with one worker by default. Increase it before listen when the process should run multiple Actix workers:
#![allow(unused)]
fn main() {
Application::new::<AppModule>()
.await
?
.workers(4)
.listen("127.0.0.1:8080")
.await
}
Logging
The runtime logs startup, route mapping, listening, and shutdown. Application logging, levels, Actix access formats, buffering, and Axum differences are covered in the dedicated Logging guide.
Shutdown
Application::listen runs provider on_shutdown hooks after the Actix server exits. If binding the socket fails after startup has completed, shutdown hooks are also attempted before returning the bind error.
#![allow(unused)]
fn main() {
Application::new::<AppModule>()
.await
?
.listen("0.0.0.0:8080")
.await
}
Shutdown hook errors are converted into std::io::Error after the server stops.
Actix Adapter Behavior
The adapter installs:
- Shared
Arc<Container>application data. - Shared request configuration with the Caelix body limit, normalized JSON and multipart errors, typed multipart DTO binding, temporary upload storage, and JSON body parsing for
#[body]routes even when the client omits theContent-Typeheader. - Generated controller routes from module metadata.
- A request logging wrapper, enabled only when access logging is explicitly requested.
Controller route wrappers are generated by #[controller]. Plain routes call the controller directly. Routes that use guards, interceptors, or #[user] build a RequestContext, run guards, run interceptors, call the controller method, convert the result to a Caelix response, then convert it to actix_web::HttpResponse.
Logging
Every container provides Logger. An Arc<Logger> field in an #[injectable]
class is resolved specially with the class name as its scope:
#![allow(unused)]
fn main() {
#[caelix::injectable]
struct OrdersService {
logger: Arc<Logger>,
}
self.logger.info("order accepted");
self.logger.warn("inventory is low");
self.logger.error("payment failed");
self.logger.debug("retry state updated");
}
Logger::new("scope"), Logger::for_type::<T>(), and context() are available
for manual construction. CAELIX_LOG selects error, warn, info/log, or
debug. If absent or invalid, Caelix reads an applicable caelix,
caelix_core, or caelix-core directive from RUST_LOG, then defaults to
debug. Framework startup, module/provider initialization, route mapping,
readiness, shutdown, and internal server failures use the same logger.
Generated controller routes include correlation metadata on every logged server error:
[2026-07-26T14:32:08.190Z ERROR caelix::error] 500 Internal Server Error: Internal Server Error | source: database unavailable | method=GET path="/orders" request_id="9e285a20-9405-4cc8-8941-0f758d84f524" trace_id="4bf92f3577b34da6a3ce929d0e0e4736" service="orders-api" environment="production"
Caelix accepts X-Request-Id and X-Trace-Id, and understands the trace ID
inside a W3C traceparent header. Missing request IDs are generated as UUIDs;
invalid correlation headers are ignored. X-Request-Id and X-Trace-Id are
included on the response so clients can report them.
Set CAELIX_SERVICE_NAME and CAELIX_ENVIRONMENT to identify the deployment.
OTEL_SERVICE_NAME is also accepted for the service name; APP_ENV and
ENVIRONMENT are environment fallbacks. Defaults are application and
development.
Default startup output is intentionally compact:
[2026-07-26T14:32:08.184Z INFO caelix::bootstrap] Building application
[2026-07-26T14:32:08.186Z INFO caelix::routes] 3 routes registered across 1 controller
[2026-07-26T14:32:08.187Z INFO caelix::server] Listening on http://127.0.0.1:3000 in 12ms
The ready event is written only after the server successfully binds.
Individual module, provider, controller, and route events are included by
default. Set CAELIX_LOG=info for compact startup output. In-process
TestApplication compilation does not emit server startup events.
Actix access logs
#![allow(unused)]
fn main() {
Application::new::<AppModule>()
.await?
.logging(Logging::default())
.listen("127.0.0.1:3000")
.await?;
}
Logging::default() enables compact method, path, status, and duration logs.
Logging::info() enables the detailed Actix-compatible format: peer address,
request line/protocol, status, response size, referrer, user agent, and duration.
Use .access_log(false) to disable middleware completely.
Explicit .logging(...) wins. If it is omitted, CAELIX_HTTP_LOG is checked,
then the legacy CAELIX_ACCESS_LOG; recognized boolean values decide whether
compact access logging is enabled. The default fallback is disabled.
Request workers enqueue access entries to a dedicated buffered writer. The
queue capacity is 65,536; when full, new entries are dropped instead of blocking
requests. Monitor dropped_http_request_logs(). Caelix reports drops to stderr
at most once per second. Buffered responses report their byte count; a streaming
response whose final size is unknown logs -.
Axum
Axum has the scoped application/framework Logger, but does not expose Actix’s
Logging application configuration or install this access-log middleware.
Attach a Tower HTTP tracing/access-log layer with Application::layer when
Axum request logs are required.
Multipart Uploads
Caelix accepts file uploads alongside typed request DTOs on both supported
runtimes. A controller method keeps its normal #[body] DTO and declares file
parts independently with #[file] or #[files]. JSON clients and multipart
clients can therefore use the same route whenever its file arguments are
optional.
Typed fields and one required file
Use #[body] for the non-file fields and #[file] for one upload. Multipart
text fields use typed Serde form decoding: strings, numbers, booleans, optional
fields, and repeated collection fields are decoded into the DTO before the
controller runs.
#![allow(unused)]
fn main() {
use caelix::{Response, Result, UploadedFile, controller, injectable};
use serde::Deserialize;
use serde_json::{Value, json};
use validator::Validate;
#[derive(Deserialize, Validate)]
struct DocumentInput {
#[validate(length(min = 3))]
title: String,
#[validate(range(min = 1))]
quantity: u32,
published: bool,
#[serde(default)]
labels: Vec<String>,
}
#[injectable]
struct DocumentsController;
#[controller("/documents")]
impl DocumentsController {
#[post("")]
async fn create(
&self,
#[body] #[validate] input: DocumentInput,
#[file] document: UploadedFile,
) -> Result<Response<Value>> {
let contents = document.read_bytes().await?;
Ok(Response::Body(json!({
"title": input.title,
"bytes": contents.len(),
})))
}
}
}
The file-part name defaults to the Rust parameter name. Rename it when the HTTP contract needs a different name:
#![allow(unused)]
fn main() {
#[file(name = "cover_image")] image: UploadedFile
}
File validation
#[file(...)] and #[files(...)] can validate each staged file before the
handler runs. max_size accepts a whole number with B, KB, MB, GB,
KiB, MiB, or GiB; decimal suffixes use powers of 1,000 and IEC suffixes
use powers of 1,024. content_type is a comma-separated MIME allowlist.
#![allow(unused)]
fn main() {
#[post("/documents")]
async fn create(
&self,
#[file(
name = "document",
max_size = "10MiB",
content_type = "application/pdf, image/png",
validate = validate_document,
)]
document: UploadedFile,
) -> Result<Response<Document>> {
Ok(Response::Body(self.documents.store(document).await?))
}
}
Caelix checks, in order: declared size, declared content type, then the
controller validator. Size violations return 413 Payload Too Large; MIME
violations and undetectable files return 400 Bad Request. MIME validation is
secure by default: Caelix detects the type from the file’s magic bytes and does
not trust a client filename or part header.
Set trust_content_type_header = true only when that header is part of your
explicit trust boundary. It must accompany content_type; header parameters
such as charset=utf-8 are ignored. This mode accepts the client declaration
instead of inspecting file bytes, so it is unsafe for untrusted clients.
Validators must be controller methods with this exact shape:
#![allow(unused)]
fn main() {
async fn validate_document(&self, file: &UploadedFile) -> Result<()> {
self.document_rules.validate(file).await
}
}
For reusable rules, inject a service into the controller and forward to it as above. A validator receives only its attached file; keep cross-field checks in the handler body after every extractor is available. Optional files skip all file checks when absent. Repeated files are checked in request order. If any check fails, Caelix drops the collected files and all remaining staged files, deleting their temporary paths before returning the error response.
#[validate] runs after JSON or multipart DTO decoding. A successful multipart
decode that fails validation returns Caelix’s normal 400 Bad Request validation
envelope, including its errors map.
Content negotiation
#[body] accepts the following content types:
| Route arguments | Accepted request content types | File argument result |
|---|---|---|
| DTO only | application/json, omitted content type, multipart/form-data | Not applicable |
DTO + required UploadedFile | multipart/form-data | Required file must occur exactly once |
DTO + Option<UploadedFile> | JSON, omitted content type, multipart/form-data | JSON supplies None; multipart may supply one file |
DTO + Vec<UploadedFile> | multipart/form-data | Repeated file field, or an empty vector when absent |
#[multipart] MultipartForm | multipart/form-data | Full form access |
Other declared content types return 415 Unsupported Media Type. Invalid
boundaries, malformed parts, duplicate values for a single-file extractor, and
invalid typed form values return 400 Bad Request.
Optional and repeated files
Optional files allow the endpoint to retain a JSON workflow. They are None
for JSON requests and when the named multipart part is not present:
#![allow(unused)]
fn main() {
#[post("/profile")]
async fn update(
&self,
#[body] input: UpdateProfile,
#[file(name = "avatar")] avatar: Option<UploadedFile>,
) -> Result<Response<Profile>> {
if let Some(avatar) = avatar {
self.profiles.replace_avatar(avatar).await?;
}
Ok(Response::Body(self.profiles.update(input).await?))
}
}
Use Vec<UploadedFile> for repeated parts under one field name:
#![allow(unused)]
fn main() {
#[post("/attachments")]
async fn attach(
&self,
#[files(name = "attachment")] attachments: Vec<UploadedFile>,
) -> Result<Response<AttachmentSummary>> {
// `attachments` is empty when the field is absent.
Ok(Response::Body(self.files.store_all(attachments).await?))
}
}
Full-form access
Use #[multipart] MultipartForm when the controller needs direct control over
repeated text or file fields instead of binding a DTO. It cannot be combined
with #[body], #[file], or #[files] on the same route.
#![allow(unused)]
fn main() {
#[post("/import")]
async fn import(&self, #[multipart] mut form: MultipartForm) -> Result<Response<ImportResult>> {
let mode = form.text("mode").and_then(|values| values.first());
let files = form.take_files("source");
Ok(Response::Body(self.importer.run(mode, files).await?))
}
}
text(name) and files(name) borrow the form. take_file(name) consumes one
file and returns 400 Bad Request if that field has duplicates; take_files
consumes every file with that name.
Uploaded file lifecycle
UploadedFile stages each file in Caelix’s isolated temporary directory. The
handle exposes its multipart field name, client filename, optional content type,
headers, byte size, and temporary path.
#![allow(unused)]
fn main() {
let bytes = upload.read_bytes().await?;
let destination = upload.persist_to("/srv/documents/report.pdf").await?;
}
These operations must happen in the opposite order when both are required:
persist_to consumes the upload handle, so call read_bytes first. Temporary
files are removed when their UploadedFile is dropped. persist_to requires
the destination directory to exist and uses no-overwrite creation; it returns an
error rather than replacing an existing file. Caelix supplies temporary upload
handling only—applications choose permanent local, object-storage, or database
storage themselves.
Limits and temporary storage
All multipart requests use Application::body_limit, which defaults to 1 MiB.
Configure a larger application limit and an application-owned staging directory
before listen:
#![allow(unused)]
fn main() {
Application::new::<AppModule>()
.await?
.body_limit(10 * 1024 * 1024)
.upload_temp_dir("/var/tmp/my-service/uploads")
.listen("127.0.0.1:3000")
.await?;
}
Apply a stricter multipart limit to one route with #[upload(limit = ...)]:
#![allow(unused)]
fn main() {
#[upload(limit = 512 * 1024)]
#[post("/avatar")]
async fn avatar(&self, #[file] image: UploadedFile) -> Result<Response<Avatar>> {
// ...
}
}
An overflow returns 413 Payload Too Large and includes the effective limit in
the response message. Storage failures are logged with their details but expose
only Caelix’s standard internal-server-error response to clients. TestApplication
offers the same .body_limit(...) and .upload_temp_dir(...) configuration.
Upload support is opt-in. Enable the uploads feature when an application uses
multipart extractor APIs; builds without it omit the multipart dependencies
entirely.
Testing with curl
This request exercises typed DTO binding, repeated values, file metadata, and the normal validation path:
curl --fail-with-body \
-F 'title=Quarterly report' \
-F 'quantity=42' \
-F 'published=true' \
-F 'labels=rust' \
-F 'labels=uploads' \
-F 'document=@report.pdf;type=application/pdf' \
http://127.0.0.1:3000/documents
Use raw multipart payloads through TestApplication::set_payload(...) when an
integration test needs malformed-boundary or duplicate-file coverage. The
Testing recipe shows the in-process request helpers.
Axum and Tower
Caelix has an Axum runtime adapter for applications that want Axum’s router, extractors, and Tower middleware ecosystem. The framework-level source stays the same: modules, providers, controllers, guards, interceptors, and route attributes are still registered through Caelix metadata.
This page focuses on what the Axum adapter does at the runtime boundary. For the protocol-specific APIs, see WebSockets and Socket.IO support.
Select the Axum backend
Actix is the default backend, so an Axum application disables default features
and enables axum explicitly:
[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }
The actix and axum features are mutually exclusive. Most applications
only need the caelix dependency and the #[caelix::main] macro. Add direct
Axum and Tokio dependencies when your application calls native Axum APIs such
as axum::serve or tokio::net::TcpListener:
[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }
axum = "0.8.8"
tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread"] }
Caelix’s generated controller code uses its own hidden Axum re-exports, so
controllers do not need to import Axum types just to use #[controller].
Direct Axum dependencies are useful when embedding the returned router or
adding native routes and extractors.
Minimal Axum application
Every Axum application starts with a module. The module registration model is the same as on Actix:
#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};
pub struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
}
}
}
The Caelix runtime owns the listener and server lifecycle when listen is
used:
use caelix::Application;
#[caelix::main]
async fn main() -> std::io::Result<()> {
Application::new::<AppModule>()
.await
.map_err(|error| std::io::Error::other(error.message))?
.listen("127.0.0.1:3000")
.await
}
Application::new performs the startup work before binding a TCP listener:
- Builds the dependency container from module metadata.
- Registers providers, controllers, gateways, and lifecycle handlers.
- Validates declarations and runs bootstrap hooks.
- Logs the module routes and returns an Axum
Application.
If startup fails, the error is returned from Application::new and the server
does not start. When listen returns after the server stops, Caelix runs
shutdown hooks. If listener binding fails, it also attempts shutdown hooks
before returning the bind error.
Configure the application
Application is a builder. Configure it before calling listen or
into_router:
#![allow(unused)]
fn main() {
let app = Application::new::<AppModule>()
.await?
.body_limit(2 * 1024 * 1024);
app.listen("127.0.0.1:3000").await?;
}
The default HTTP body limit is 1 MiB. It applies to body-consuming Caelix
routes including JSON bodies and multipart uploads, and body_limit changes
the limit in bytes. Configure upload_temp_dir(path) when multipart file
staging must use an application-specific directory.
The value is applied through Axum’s DefaultBodyLimit layer when the router
is built.
WebSocket messages have their own limit. Configure that separately with
websocket_max_message_size; see Axum WebSocket support.
Controllers and Axum extractors
Controller source does not change for Axum. Caelix maps its framework-neutral extractor attributes to Axum extractors internally:
| Caelix attribute | Axum extractor | Meaning |
|---|---|---|
#[param] | axum::extract::Path<T> | Route parameters such as {id} |
#[query] | axum::extract::Query<T> | Query-string values |
#[body] | Caelix negotiated request wrapper | JSON or typed multipart text fields |
#[file] | Caelix multipart file binding | One required or optional UploadedFile |
#[files] | Caelix multipart file binding | Repeated Vec<UploadedFile> field |
#[multipart] | Caelix multipart form binding | Direct MultipartForm access |
#[user] | RequestContext lookup | A typed value installed by a guard or interceptor |
The controller method receives the inner value (T), not Path<T>,
Query<T>, or Json<T>:
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use caelix::{
controller, injectable, Module, ModuleMetadata, Response, Result, StatusCode,
};
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Serialize)]
struct User {
id: String,
name: String,
}
#[injectable]
struct UsersController;
#[controller("/users")]
impl UsersController {
#[get("/{id}")]
async fn find(&self, #[param] id: String) -> Result<User> {
Ok(User {
id,
name: "Ada".to_owned(),
})
}
#[post("")]
async fn create(&self, #[body] input: CreateUser) -> Result<Response<User>> {
Ok(Response::WithStatus(
StatusCode::CREATED,
User {
id: "new-user".to_owned(),
name: input.name,
},
))
}
}
struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().controller::<UsersController>()
}
}
}
The generated Axum handler also receives the shared container through
State<Arc<Container>>. Application code normally does not see that state;
Caelix resolves the controller and its dependencies before invoking the
method.
Request context, guards, and interceptors
When a route uses a guard, interceptor, or #[user], Caelix builds a
framework-neutral RequestContext from the Axum request method, path, and
headers. The same guard and interceptor implementations can be used on both
backends:
#![allow(unused)]
fn main() {
use caelix::{
BoxFuture, Guard, RequestContext, Result, UnauthorizedException, guard,
};
#[guard]
struct ApiKeyGuard;
impl Guard for ApiKeyGuard {
fn can_activate<'a>(
&'a self,
context: &'a RequestContext,
) -> BoxFuture<'a, Result<bool>> {
Box::pin(async move {
if context.header("x-api-key") == Some("development-key") {
Ok(true)
} else {
Err(UnauthorizedException::new("invalid API key"))
}
})
}
}
}
Attach it at controller or method level with #[use_guard(ApiKeyGuard)].
Controller-level guards run before method-level guards. Interceptors receive
the same RequestContext and can transform the framework-neutral
HttpResponse; they do not need to depend on Axum’s response type.
Extractor failures
Path and query extraction happen in Axum before the generated handler body
runs. Caelix handles JSON and multipart body extraction through its shared
negotiated wrapper, yielding the same 400, 413, and 415 response
categories as the Actix adapter. A Caelix exception returned by the controller,
guard, interceptor, or provider is converted through Caelix’s response adapter.
See Multipart Uploads for field binding, limits, validation, and file persistence.
Use #[validate] when the value was successfully deserialized but its fields
need application-level validation:
#![allow(unused)]
fn main() {
use serde::Deserialize;
use validator::Validate;
#[derive(Deserialize, Validate)]
struct SearchUsers {
#[validate(length(min = 1))]
q: String,
}
#[get("/search")]
async fn search(
&self,
#[query] #[validate] query: SearchUsers,
) -> Result<Vec<User>> {
// query.q has already passed validator::Validate::validate.
Ok(Vec::new())
}
}
Responses in Axum
Controller return values are first converted to Caelix’s framework-neutral
HttpResponse, then caelix::to_axum_response converts that response into an
Axum Response.
Common controller returns include:
#![allow(unused)]
fn main() {
use caelix::{HttpResponse, Response, Result, StatusCode};
let json = Response::Body(value); // 200 JSON
let created = Response::WithStatus(StatusCode::CREATED, value);
let empty = Response::no_content(); // 204
let text = Response::text(StatusCode::OK, "ready");
let bytes = Response::bytes(StatusCode::OK, data);
}
For streaming endpoints, return Result<HttpResponse> and use
Response::stream, Response::sse, or Response::file. The Axum adapter
turns buffered bodies into axum::body::Body directly and turns streaming
bodies into Axum stream bodies. If a stream yields an error after the response
has started, Caelix logs the error and ends the body; it cannot change the
already-sent status code.
Native Axum response conversion
When an application adds a native Axum handler, it can still use Caelix’s response helpers and convert the result explicitly:
#![allow(unused)]
fn main() {
use caelix::{HttpResponse, StatusCode};
async fn native_health() -> axum::response::Response {
caelix::to_axum_response(HttpResponse::text(StatusCode::OK, "ok"))
}
}
to_axum_response applies the Caelix content type and all response headers.
This is useful when a native Axum route shares response construction with a
Caelix controller or service.
Compose with an Axum router
into_router is the integration boundary. It returns a fully configured Axum
router containing Caelix controller routes, WebSocket routes, body limits,
and the Caelix JSON fallback for unmatched requests.
Use it when another component owns the listener:
#![allow(unused)]
fn main() {
let app = Application::new::<AppModule>()
.await?
.into_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
}
You can add native Axum routes after obtaining the router:
#![allow(unused)]
fn main() {
use axum::{Router, routing::get};
let app: Router = Application::new::<AppModule>()
.await?
.into_router()
.route("/native", get(native_health));
}
Keep Caelix routes registered through controllers and modules. Add native
routes for Axum-only integrations that do not need Caelix controller metadata.
If a native handler needs shared application state, use Axum’s normal State
extractor and merge it before serving, taking care to preserve Caelix’s
router state requirements.
AxumRequestInfo
AxumRequestInfo is a parts-only extractor exposed by Caelix for native Axum
handlers that need request metadata without consuming the body:
#![allow(unused)]
fn main() {
use caelix::AxumRequestInfo;
async fn request_info(info: AxumRequestInfo) -> String {
format!("{} {}", info.method(), info.path())
}
}
It provides method(), path(), and headers(). Because it implements
FromRequestParts, it can be combined with a body-consuming extractor in a
native Axum handler. Caelix uses the same request information internally when
it builds a RequestContext for guarded, intercepted, or authenticated
controller routes.
Tower middleware
Application::layer accepts a compatible Tower layer and applies it to the
underlying Axum router:
#![allow(unused)]
fn main() {
use caelix::Application;
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
let app = Application::new::<AppModule>()
.await?
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.into_router();
}
This works with tracing, compression, CORS, request IDs, authentication, and rate-limiting layers from the Tower ecosystem. Add the relevant crate to your application:
[dependencies]
tower-http = { version = "0.6.8", features = ["compression-full", "trace"] }
Layers are attached to the router after Caelix has registered its routes, so they can observe normal controller requests and WebSocket upgrade requests. Message-level WebSocket authorization still belongs in the gateway; Tower middleware sees the HTTP upgrade boundary, not each WebSocket message.
A layer must satisfy the adapter’s service bounds: its service must return an
Axum IntoResponse, use an error convertible to Infallible, and expose a
Send future. If a third-party layer does not meet those bounds, wrap it in a
compatible Tower service or install it after into_router using native Axum
composition.
WebSockets and Socket.IO
Axum RFC 6455 gateways are registered with the same #[gateway] and
ModuleMetadata::gateway APIs as Actix gateways. The Axum adapter mounts them
when into_router or listen builds the application. See the dedicated
Axum WebSocket support section for
message callbacks, request metadata, close frames, message limits, and
browser clients.
Socket.IO is an Axum-only optional feature. Enable it with
features = ["socketio"], call with_socket_io::<AppModule>() before
listen or into_router, and use the Socket.IO client protocol. See the
Socket.IO guide for namespaces,
events, acknowledgements, rooms, and client examples.
Testing Axum applications
Axum provides the same in-process TestApplication API as the Actix adapter.
It builds the production container and routes without opening a TCP listener:
#![allow(unused)]
fn main() {
use caelix::{StatusCode, TestApplication};
#[caelix::test]
async fn health_route_works() {
let app = TestApplication::new::<AppModule>().await.unwrap();
let body = app
.get("/health")
.send()
.await
.unwrap()
.assert_status(StatusCode::OK)
.json::<serde_json::Value>()
.await;
assert_eq!(body["status"], "ok");
}
}
The builder supports override_provider, override_provider_factory, and
body_limit. Request helpers include get, post, put, patch, and
delete, with json, header, set_payload, and send; response helpers
include status, assert_status, json, body, and text. Call
app.shutdown().await when tests need provider shutdown hooks to run.
For lower-level Axum or Tower integration tests, you can still build the router
directly and send requests with ServiceExt::oneshot:
[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn health_route_works() {
let app = caelix::Application::new::<AppModule>()
.await
.unwrap()
.into_router();
let response = app
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), br#"{\"status\":\"ok\"}"#);
}
}
For WebSocket behavior, start an ephemeral tokio::net::TcpListener, serve
into_router(), and connect with a WebSocket client such as
tokio-tungstenite. For Socket.IO behavior, use a real Socket.IO client so
the Engine.IO handshake, namespace, acknowledgement, and room behavior are
tested together.
Common integration patterns
Put shared state in providers
Prefer injectable services for database pools, repositories, clients, and configuration. Controllers resolve them from the Caelix container regardless of whether Axum or Actix is selected:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{injectable, Module, ModuleMetadata};
#[injectable]
struct UsersService {
repository: Arc<UsersRepository>,
}
#[injectable]
struct UsersRepository;
struct AppModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new()
.provider::<UsersRepository>()
.provider::<UsersService>()
.controller::<UsersController>()
}
}
}
This keeps Axum-specific router wiring at the application boundary and keeps business logic portable across runtimes.
Use native Axum only at the boundary
Use native Axum routes or layers when an integration requires Axum types. Keep controller methods, providers, guards, and interceptors on Caelix abstractions when the code should remain portable. This gives one module graph and one dependency-injection model while still allowing Axum’s ecosystem at the edge.
Common Tasks
Create A Project
cargo install caelix-cli
caelix new demo-api
Generate A Feature
caelix g module greetings
Add the generated module to your app:
#![allow(unused)]
fn main() {
pub mod greetings;
use greetings::GreetingsModule;
impl Module for AppModule {
fn register() -> ModuleMetadata {
ModuleMetadata::new().import::<GreetingsModule>()
}
}
}
Generate Pieces Separately
caelix g service users
caelix g controller users
If the service exists before the controller is generated, the controller injects it.
Convert Library Errors
#![allow(unused)]
fn main() {
let user = repository
.find(id)
.await
.map_err(InternalServerErrorException::new)?;
}
For 5xx errors, the client response message stays generic.
Validate A Request Body
Derive Validate and mark the extractor:
#![allow(unused)]
fn main() {
#[post("")]
async fn create(&self, #[body] #[validate] input: CreateUser) -> Result<Response<UserDto>> {
self.users.create(input).await
}
}
Emit Events After Writes
Import EventModule, inject or resolve EventBus in a service, perform the write, then emit a cloned event type.
#![allow(unused)]
fn main() {
ModuleMetadata::new()
.import::<EventModule>()
.provider::<UsersService>()
.provider::<SendWelcomeEmail>()
.event_handler::<SendWelcomeEmail>();
self.events.emit(UserCreated { id: user.id }).await?;
}
Cache Service Results Explicitly
Import CacheModule, inject Arc<Cache>, and call get, set, set_with_ttl, delete, or clear inside service methods.
Set and Clear a Session Cookie
Create a response with Response::Body(value).with_cookie(Cookie::new("session", signed)). Clear it with Cookie::removal("session"), matching the original
path and domain. See Cookies and Sessions.
Call a Microservice
Inject Arc<MicroserviceClient> and call request::<Payload, Reply> or emit.
See Handlers and Client.
Testing
Caelix supports two testing styles: unit tests against the DI container, and full in-process HTTP tests with TestApplication.
Unit tests
Build a Container, register dependencies, and call methods directly:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn service_returns_user() -> caelix::Result<()> {
let mut container = Container::new();
container.register::<UsersService>().await?;
let service = container.resolve::<UsersService>()?;
let user = service.find(1).await?;
assert_eq!(user.id, 1);
Ok(())
}
}
You can also use build_container::<AppModule>() or build_container_with_overrides when you want the full module graph without HTTP.
Integration tests with TestApplication
TestApplication builds the same container and route table as production, then serves requests through the selected runtime’s in-memory service (no TCP listener). The API is the same when the Actix or Axum backend is selected.
#![allow(unused)]
fn main() {
use caelix::{StatusCode, TestApplication};
#[caelix::test]
async fn should_create_user() -> caelix::Result<()> {
let app = TestApplication::new::<AppModule>().await?;
let response = app
.post("/users")
.json(CreateUserDto {
name: "Ronnie".into(),
email: "ronnie@example.com".into(),
})
.send()
.await?;
response.assert_status(StatusCode::CREATED);
Ok(())
}
}
#[caelix::test] runs the selected backend runtime, so it is suitable for
TestApplication tests with either backend. It expands through Caelix’s hidden
runtime re-exports, so a direct Actix or Axum runtime dependency is not needed
just to use the test macro.
Request helpers
| Method | Purpose |
|---|---|
app.get/post/put/patch/delete(path) | Start a request |
.json(value) | JSON body + content type |
.header(name, value) | Set a header |
.set_payload(bytes) | Raw body |
.send().await | Execute against the in-process app |
Response helpers
| Method | Purpose |
|---|---|
.status() | http::StatusCode |
.assert_status(code) | Panic on mismatch; returns self for chaining |
.json::<T>().await | Deserialize JSON body |
.body().await / .text().await | Raw bytes / UTF-8 text |
Cookies
Send cookies with .header("cookie", "session=hello%20world; theme=dark").
Assert required/optional extraction and errors. For response cookies, inspect
each Set-Cookie value separately and assert its security attributes and scope.
See Cookies and Sessions.
Provider overrides
Swap a concrete provider (including ones registered in nested imports) before the container is built:
#![allow(unused)]
fn main() {
#[caelix::test]
async fn creates_user_without_database() -> caelix::Result<()> {
let app = TestApplication::new::<AppModule>()
.override_provider(UserRepository::in_memory())
.await?;
let response = app
.post("/users")
.json(CreateUserDto {
name: "Ronnie".into(),
email: "ronnie@example.com".into(),
})
.send()
.await?;
response.assert_status(StatusCode::CREATED);
// resolve the same type the app injects
let _repo = app.resolve::<UserRepository>()?;
Ok(())
}
}
Overrides match by TypeId. The replacement must be the same concrete type T that modules register and inject as Arc<T>. Typical pattern: give the production type a test constructor (for example UserRepository::in_memory()) rather than a separate mock struct type.
You can also use .override_provider_factory(...) for async construction,
.body_limit(n) to match Application::body_limit, and
.upload_temp_dir(path) when an upload test needs an isolated staging directory.
Multipart tests use the raw payload helper with a boundary header:
#![allow(unused)]
fn main() {
let response = app
.post("/uploads")
.header("content-type", "multipart/form-data; boundary=test-boundary")
.set_payload("--test-boundary\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nReport\r\n--test-boundary--\r\n")
.send()
.await?;
}
An override for a type that never appears in the module tree is a startup error.
Instance overrides use NestJS-style useValue semantics: declared lifecycle hooks (on_module_init, on_bootstrap, on_shutdown) for that type are skipped.
Shutdown
#![allow(unused)]
fn main() {
app.shutdown().await?;
}
Dropping a TestApplication without shutdown skips on_shutdown hooks. Call shutdown when your test cares about cleanup.
Microservice tests
Build the module container and invoke
Microservice::definition().handlers() directly for typed handler tests. Use an
isolated real broker with MicroserviceRuntime and MicroserviceClient for
queue groups, recovery, deadlines, retries, dead letters, and shutdown. See
Microservice Operations, Testing, and Interoperability.
CLI-generated projects
caelix new demo-api
cd demo-api
cargo test
Useful checks:
cargo test
cargo test -p caelix-actix
cargo check
Macro Attributes
#[injectable]
Applies to unit structs or named-field structs. Named fields must be Arc<T>.
#![allow(unused)]
fn main() {
#[injectable]
pub struct UsersService;
}
#![allow(unused)]
fn main() {
#[injectable]
pub struct UsersController {
service: Arc<UsersService>,
}
}
Expansion behavior:
- Implements
caelix::Injectable. - Resolves each
Arc<T>field withcontainer.resolve::<T>(). - Uses
container.resolve_logger(stringify!(TypeName))forArc<Logger>. - Leaves lifecycle hooks as default no-ops.
Invalid patterns:
#![allow(unused)]
fn main() {
#[injectable]
pub struct Bad(String); // tuple structs are rejected
#[injectable]
pub enum Bad { A } // non-struct items are rejected
#[injectable]
pub struct Bad {
value: String, // named fields must be Arc<T>
}
}
#[guard]
Uses the same dependency injection expansion as #[injectable]. The type must implement Guard.
#[controller("/base")]
Applies to an impl block. Route methods may use:
#[get("...")]#[post("...")]#[patch("...")]#[put("...")]#[delete("...")]
Controller and method attributes:
#[use_guard(Type)]#[use_interceptor(Type)]
Extractor attributes:
#[param]#[query]#[body]#[file]#[files]#[multipart]#[user]#[cookie("name")]#[validate]
Bodies and uploads
#[body] accepts application/json, omitted content type, and
multipart/form-data. JSON and multipart text fields both decode into the
declared type. Add #[validate] to run validator::Validate after either
decode path.
#[file] name: UploadedFile requires exactly one named multipart file; use
Option<UploadedFile> when it may be absent. #[files] files: Vec<UploadedFile>
collects repeated file parts. File fields default to their argument name and
accept name = "...", for example #[file(name = "avatar")] avatar: UploadedFile.
#[multipart] form: MultipartForm provides direct full-form access and is
intentionally incompatible with #[body], #[file], and #[files] on the
same route. Use #[upload(limit = bytes)] on a route to set a stricter
multipart body limit than the application limit.
#[file]/#[files] additionally accept max_size, content_type,
trust_content_type_header, and an async controller validate method. See
Extractors.
Example:
#![allow(unused)]
fn main() {
#[controller("/users")]
#[use_guard(AuthGuard)]
#[use_interceptor(AuditInterceptor)]
impl UsersController {
#[get("/{id}")]
pub async fn find(&self, #[param] id: i64) -> Result<UserDto> {
self.users.find(id).await
}
#[post("")]
pub async fn create(
&self,
#[body] #[validate] input: CreateUserDto,
) -> Result<Response<UserDto>> {
let user = self.users.create(input).await?;
Ok(Response::WithStatus(StatusCode::CREATED, user))
}
}
}
#[param] and #[query] use the selected runtime’s native path and query
extractors. Body and upload routes use Caelix’s shared negotiated request
wrapper so the same controller source works on Actix and Axum. #[user] reads
RequestContext::get::<T>() and returns 401 Unauthorized if the typed value
is missing.
Invalid extractor pattern:
#![allow(unused)]
fn main() {
#[get("/{id}")]
pub async fn find(&self, #[param] Some(id): Option<i64>) -> Result<String> {
Ok(id.to_string())
}
}
Extractor arguments must be simple identifiers because the macro moves extracted values into the controller method call.
#[gateway("/path")]
Decorates a WebSocket or Socket.IO gateway implementation and supplies the explicit path metadata
used by ModuleMetadata::gateway::<Gateway>().
For RFC 6455 sockets, place it on impl WebSocketGateway for Gateway; callback methods use the
WebSocketGateway trait. This works with both Actix and Axum.
With the Axum-only socketio feature, place it on an inherent implementation and annotate each
async event method with #[on_message("event")]. Such methods accept either payload: T or
socket: SocketRef, payload: T and return Result<Reply>; successful replies become Socket.IO
acks, while failures also emit "error".
#[cookie("name")]
Controller arguments may use String for a required cookie or
Option<String> for an optional cookie. The name must be a non-empty string
literal. Extraction reads the generated RequestContext, so the same
controller source works with Actix and Axum.
OpenAPI markers
With openapi, controller expansion consumes #[response(...)],
#[errors(...)], #[request_header(...)], and #[security(...)]. They declare
successful/error responses, header parameters, and security requirements; they
do not perform runtime authentication or extraction. Controller extractors,
including cookies and uploads, also contribute request metadata.
#[upload(limit = bytes)]
This route marker requires uploads and supplies a stricter multipart limit
than the application body limit. It is meaningful only on a controller route.
Microservice macros
#[microservice] decorates an inherent impl. Its methods use exactly one
#[message_pattern("subject")] or #[event_pattern("subject")], one bare
#[payload], and optionally one bare #[context] MessageContext.
Handlers must be async, non-generic, take &self, use named parameters, and
return Result<T>. Payloads require DeserializeOwned + Send; command response
types require Serialize + Send; events must return Result<()>. Command
subjects reject wildcards. Event subjects accept whole-token * and terminal
>. The macro generates Microservice metadata and resolves the handler class
from the module container.
Public Traits
Module
#![allow(unused)]
fn main() {
pub trait Module {
fn register() -> ModuleMetadata;
}
}
Injectable
#![allow(unused)]
fn main() {
pub trait Injectable: Send + Sync + 'static {
fn create(container: &Container) -> BoxFuture<'_, Result<Self>>
where
Self: Sized;
fn dependencies() -> Vec<ProviderDependency>
where
Self: Sized;
fn on_module_init(&self) -> BoxFuture<'_, Result<()>>;
fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>>;
fn on_shutdown(&self) -> BoxFuture<'_, Result<()>>;
}
}
Implement manually for owned state, custom construction, or lifecycle hooks. A
manual implementation must declare every provider it resolves from create:
#![allow(unused)]
fn main() {
fn dependencies() -> Vec<ProviderDependency> {
provider_dependencies![Database, Config]
}
}
Use provider_dependencies![] when it resolves no providers. The compiler
requires this method, and Caelix rejects an undeclared container.resolve::<T>()
while constructing the provider. #[injectable] supplies the declaration
automatically for its Arc<T> fields; Arc<Logger> is excluded because it uses
the scoped logger API rather than DI resolution.
Controller
Generated by #[controller] for normal use. Manual implementations register routes into an adapter-specific service config.
#![allow(unused)]
fn main() {
pub trait Controller {
fn base_path() -> &'static str;
fn routes() -> &'static [RouteDef] {
&[]
}
fn register_routes(cfg: &mut dyn Any);
}
}
Implement manually only when writing adapter-specific route integration.
Guard
#![allow(unused)]
fn main() {
pub trait Guard: Send + Sync + 'static {
fn can_activate<'a>(&'a self, ctx: &'a RequestContext) -> BoxFuture<'a, Result<bool>>;
}
}
Interceptor
#![allow(unused)]
fn main() {
pub trait Interceptor: Send + Sync + 'static {
fn intercept<'a>(
&'a self,
ctx: &'a RequestContext,
next: Next<'a>,
) -> BoxFuture<'a, Result<HttpResponse>>;
}
}
EventHandler<E>
#![allow(unused)]
fn main() {
pub trait EventHandler<E>: Send + Sync + 'static {
fn handle(&self, event: E) -> BoxFuture<'_, Result<()>>;
}
}
Event types must be Clone + Send + Sync + 'static.
RegisterableEventHandler
#![allow(unused)]
fn main() {
pub trait RegisterableEventHandler: Injectable {
type Event: Clone + Send + Sync + 'static;
}
}
Implement this to use .event_handler::<Handler>(). Use .event_handler_for::<Event, Handler>() when you want to avoid the marker implementation.
CacheStore
Custom cache backends implement get, set, delete, and clear over JSON values.
#![allow(unused)]
fn main() {
pub trait CacheStore: Send + Sync + 'static {
fn get(&self, key: String) -> BoxFuture<'_, Result<Option<serde_json::Value>>>;
fn set(
&self,
key: String,
value: serde_json::Value,
ttl: Option<std::time::Duration>,
) -> BoxFuture<'_, Result<()>>;
fn delete(&self, key: String) -> BoxFuture<'_, Result<()>>;
fn clear(&self) -> BoxFuture<'_, Result<()>>;
}
}
Implement manually for Redis, Memcached, database-backed caches, or test stores. Caelix does not provide automatic HTTP response caching.
Response conversion
IntoCaelixResponse converts controller return values into HttpResponse.
Caelix implements it for its response wrappers and common serializable results.
Implement it for an application response wrapper when centralized status,
headers, cookies, or body conversion is needed.
Microservices
Microservice: Injectable is generated by #[microservice]; its definition
returns provider and handler metadata. Do not implement MessageHandlerDef
plumbing manually in normal applications.
Gateways
Gateway: Injectable supplies registration metadata and is normally generated
by #[gateway]. WebSocketGateway: Injectable is the public RFC 6455 callback
contract. Custom runtime transports may implement WebSocketTransport.
RegisteredWebSocketGateway is a framework composition marker and should not be
implemented directly. With socketio, SocketIoGateway is generated for
annotated gateways.
Documentation and framework internals
OpenApiError is the public extension point for custom documented error types.
Controller, Microservice, Gateway, RegisteredWebSocketGateway, and
RegisterableEventHandler are primarily macro/registration metadata traits.
Application authors normally implement behavior traits (Module, Injectable,
Guard, Interceptor, EventHandler, CacheStore, WebSocketGateway,
WebSocketTransport, IntoCaelixResponse, and custom OpenApiError) and let
macros produce framework metadata.
Error Response Shape
HTTP exceptions serialize as JSON:
{
"status": 404,
"error": "Not Found",
"message": "user not found"
}
Validation or structured client errors can include errors:
{
"status": 422,
"error": "Unprocessable Entity",
"message": "invalid request",
"errors": {
"email": ["must be an email"]
}
}
For 5xx statuses, Caelix hides the internal message from the response body:
{
"status": 500,
"error": "Internal Server Error",
"message": "Internal Server Error"
}
Registration And Lifecycle Order
Module registration follows the metadata graph. Each module is discovered once; repeated imports share one provider graph and module-import cycles are startup errors.
- The complete module graph, exports, duplicates, and dependency metadata are validated.
- Providers are constructed in dependency order and initialized once.
- Event handlers are registered after their provider exists.
on_bootstrapruns in dependency order.
on_module_init runs when an injectable provider is registered. on_shutdown
runs once in reverse successful startup order. If startup fails, Caelix shuts
down providers already initialized and preserves the original error. Shutdown
continues after individual hook failures and returns the first such error.
Async factory providers are construction-only and use no-op lifecycle callbacks.
Controllers participate in dependency injection because registering a controller creates a provider definition for that controller type.
Visibility
Providers are visible to their declaring module, to direct importers only when explicitly exported, and through explicit exports of reachable global modules. An import does not expose a module’s private providers. Re-exporting is allowed only from a direct import.
Failures
Startup can fail for:
- Missing providers declared in module metadata.
- Missing event handler providers.
- Missing
EventModuleimport before resolvingArc<EventBus>or registering event handlers. - A dependency that is private, unexported, or declared by a module that was not imported.
- A handwritten provider or factory resolving a type absent from its declared dependencies.
- Duplicate production provider, factory, controller, or gateway registrations.
- Async factory errors.
- Lifecycle hook errors.
- Dependency resolution failures during provider construction.
Application::new::<AppModule>() returns startup errors as caelix::Result<Application>, so callers can choose whether to propagate, map, or unwrap them.
Feature Flags
caelix defaults to actix. Disable defaults when selecting Axum or building a
broker-only process.
| Feature | Enables and exports | Compatibility |
|---|---|---|
actix | Actix Application, TestApplication, runtime macros, Logging, to_actix_response | mutually exclusive with axum |
axum | Axum Application, TestApplication, runtime macros, AxumRouterBuilder, to_axum_response | mutually exclusive with actix |
socketio | Socket.IO types and Application::with_socket_io; selects axum | unavailable with Actix |
sqlx | Compatibility feature; SQLx errors work with ? without enabling it | combines with either runtime |
validator | #[validate] and validator re-export | combines with either runtime |
openapi | OpenAPI metadata/config, Swagger UI, controller markers | requires an HTTP runtime to serve docs |
uploads | UploadedFile, MultipartForm, upload extractors/configuration | combines with either runtime |
microservices-nats | microservice macros, application/runtime/client, NATS options, Tokio runtime macros | no HTTP runtime required |
microservices-redis | same shared microservice API plus RedisTransportOptions | no HTTP runtime required; Redis 6.2+ |
# Default Actix
caelix = "0.0.32"
# Axum
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }
# Actix with request facilities
caelix = { version = "0.0.32", features = ["uploads", "validator", "openapi"] }
# Broker-only process with both transports
caelix = { version = "0.0.32", default-features = false, features = [
"microservices-nats",
"microservices-redis",
] }
actix and axum must never be enabled together. socketio selects Axum, so
disable defaults. NATS and Redis may be enabled separately or together and may
also coexist with one HTTP runtime in a combined process. Generated macro code
uses facade-only hidden exports; applications should depend on caelix, not
adapter or macro crates directly.
Design Decisions
Caelix keeps framework-neutral concepts separate from adapter-specific HTTP server behavior internally, while the public caelix package exports the application-facing API.
Interceptors are framework-neutral. They receive RequestContext, Next, and the already converted HttpResponse, so they can inspect or transform responses without depending on a handler’s original return type.
HttpResponse body is either buffered bytes or a streaming Stream of Bytes chunks (ResponseBody). This is a breaking change from body: Vec<u8> (acceptable on early 0.0.x; migrate with body_bytes() / as_buffered_mut()). Optional headers live on HttpResponse.headers as owned (String, String) pairs (dynamic values allowed; not a full HeaderMap API). SSE and file downloads are thin helpers on the same primitive — SSE is data-frame framing plus cache/proxy headers, not the full SSE protocol. Adapters (today Actix) branch on ResponseBody at the boundary — buffered uses .body(...), streaming uses .streaming(...). Streaming handlers return Result<HttpResponse> via the existing identity IntoCaelixResponse impl; no separate controller attribute is required. Interceptors may rewrite buffered bodies; streaming bodies stay opaque after the handler returns.
EventBus::emit runs registered handlers first; only on full success does it publish to subscribe streams. Broadcast channels are created by subscribe, not by emit.
Middleware remains adapter-specific. The Actix layer can continue to use native Actix middleware without adding a framework-neutral middleware abstraction before another backend exists.
Lifecycle hooks live on Injectable. Normal providers use .provider::<T>(); async factory providers keep construction-only behavior.
Events are opt-in through EventModule, which registers EventBus. Event handlers must be providers before being registered as handlers.
Cache support is explicit service-level caching through Cache, CacheStore, MemoryCacheStore, and CacheModule.
Cookies are explicit response data
Request cookies are parsed into RequestContext, while response cookies live
in a dedicated ordered collection on HttpResponse. This keeps controllers
runtime-neutral and preserves multiple Set-Cookie headers without changing
the intentionally simple generic response-header collection. Caelix does not
track cookie mutations or provide an application session store.