Rust Oxide Template Documentation
Single-page docs with section-based rendering. Use the sidebar to jump between setup, architecture, auth, database, config, routing, logging, error handling, and CLI workflows.
Getting started
From entity to API in four steps.
The minimum path is: define your SeaORM entity, add a DAO, implement a service, then mount a CRUD router. Provider wiring is centralized in src/auth/bootstrap.rs, so main.rs only orchestrates startup.
Workflow overview
- Define entity in src/db/entities/
- Add DAO in src/db/dao/
- Implement CrudService in src/services/
- Mount CrudApiRouter in src/routes/api/
Step 1: Entity
// src/db/entities/todo_list.rs
use base_entity_derive::base_entity;
use sea_orm::entity::prelude::*;
#[base_entity]
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "todo_lists")]
pub struct Model {
pub title: String,
#[sea_orm(default_value = 0)]
pub score: i32,
#[sea_orm(has_many)]
pub items: HasMany,
}
impl ActiveModelBehavior for ActiveModel {}
Step 2: DAO
// src/db/dao/todo_dao.rs
use sea_orm::DatabaseConnection;
use crate::db::dao::DaoBase;
use crate::db::entities::prelude::TodoList;
#[derive(Clone)]
pub struct TodoDao {
db: DatabaseConnection,
}
impl DaoBase for TodoDao {
type Entity = TodoList;
fn new(db: &DatabaseConnection) -> Self {
Self { db: db.clone() }
}
fn db(&self) -> &DatabaseConnection {
&self.db
}
}
Step 3: Service
// src/services/todo_service.rs
use crate::db::dao::TodoDao;
use crate::services::crud_service::CrudService;
#[derive(Clone)]
pub struct TodoService {
todo_dao: TodoDao,
}
impl TodoService {
pub fn new(todo_dao: TodoDao) -> Self {
Self { todo_dao }
}
}
impl CrudService for TodoService {
type Dao = TodoDao;
fn dao(&self) -> &Self::Dao {
&self.todo_dao
}
}
Step 4: Router
// src/routes/api/todo_crud.rs
use std::sync::Arc;
use axum::Router;
use crate::{
auth::Role,
middleware::AuthRolGuardLayer,
routes::crud_api_router::{CrudApiRouter, Method},
services::ServiceContext,
state::AppState,
};
const BASE_PATH: &str = "/todo-crud";
pub fn router(state: Arc) -> Router {
let service = ServiceContext::from_state(state.as_ref()).todo();
CrudApiRouter::new(service, BASE_PATH)
.set_method_middleware(
Method::Create,
AuthRolGuardLayer::new(state.clone(), Role::User),
)
.router()
.with_state(state)
}
Project structure
Know where to look first.
This tree highlights the core modules you will touch most when extending the template.
Core module map
# repo root (key modules)
src/
main.rs # config -> logging -> DB -> auth init -> state -> router
config.rs # env-driven settings
state.rs # shared AppState (config, db, auth providers)
auth/
bootstrap.rs # provider registration + active provider selection
providers/ # auth provider implementations
routes/ # HTTP layer (routing + middleware + response mapping)
crud_api_router.rs # concrete CRUD router wrapper
base_api_router.rs # shared CRUD trait hooks
base_router.rs # router helper trait
response.rs # JsonApiResponse + AppError -> HTTP mapping
route_list.rs # generated route list
middleware/
auth.rs # JWT middleware + role guard layer
guards.rs # extractor guards (AuthGuard/AuthRoleGuard)
json_error.rs # normalize non-JSON errors to JSON envelope
panic.rs # panic -> JSON response layer
api/
public.rs # /api/v1/public + /api/v1/routes.json
auth.rs # /api/v1 register/login/refresh
protected.rs # /api/v1/me route
admin.rs # /api/v1/admin stats
todo_crud.rs # /api/v1 todo CRUD
views/
public.rs # landing + docs + routes views
todo.rs # /todo/ui
services/ # business logic layer
crud_service.rs # filter parsing + list helpers
db/
entities/ # SeaORM entities (schema)
dao/ # data access objects
views/
base.html # shared layout
docs.html # docs page
Authentication
Register, get a token, protect routes.
The auth router provides /api/v1/register, /api/v1/login, and /api/v1/refresh. Use the access token as a Bearer token to call protected endpoints.
Get tokens
# register
curl -X POST http://localhost:3000/api/v1/register \\
-H "Content-Type: application/json" \\
-d '{"email":"[email protected]","password":"password123"}'
# login
curl -X POST http://localhost:3000/api/v1/login \\
-H "Content-Type: application/json" \\
-d '{"email":"[email protected]","password":"password123"}'
Call a protected route
# /api/v1/me is protected
curl http://localhost:3000/api/v1/me \\
-H "Authorization: Bearer $ACCESS_TOKEN"
Minimal route protection
// src/routes/api/protected.rs
use axum::{Router, routing::get};
use std::sync::Arc;
use crate::middleware::AuthGuard;
use crate::state::AppState;
pub fn router(state: Arc) -> Router {
Router::new()
.route("/me", get(me))
.with_state(state)
}
async fn me(_auth: AuthGuard) {
// ...
}
Role-based protection
// src/routes/api/admin.rs
use axum::{Router, routing::get};
use std::sync::Arc;
use crate::middleware::{AdminRole, AuthRoleGuard};
use crate::state::AppState;
pub fn router(state: Arc) -> Router {
Router::new()
.route("/admin/stats", get(admin_stats))
.with_state(state)
}
async fn admin_stats(AuthRoleGuard { .. }: AuthRoleGuard) {
// ...
}
Role per route
// src/routes/api/protected.rs
use axum::{Router, routing::get};
use std::sync::Arc;
use crate::middleware::{AdminRole, AuthRoleGuard, UserRole};
use crate::state::AppState;
pub fn router(state: Arc) -> Router {
Router::new()
.route("/reports", get(reports))
.route("/admin/stats", get(admin_stats))
.with_state(state)
}
async fn reports(_auth: AuthRoleGuard) {
// ...
}
async fn admin_stats(_auth: AuthRoleGuard) {
// ...
}
Auth providers
Auth is provider-driven. The active provider is selected by AUTH_PROVIDER and registered in src/auth/bootstrap.rs. Route handlers use the auth service, and auth guards verify tokens through the active provider.
// src/auth/providers/your_provider.rs
use async_trait::async_trait;
use crate::auth::{Claims, TokenBundle};
use crate::auth::providers::{AuthProvider, AuthProviderId};
pub struct YourProvider;
#[async_trait]
impl AuthProvider for YourProvider {
fn id(&self) -> AuthProviderId { /* ... */ }
async fn register(&self, email: &str, password: &str) -> Result { /* ... */ }
async fn login(&self, email: &str, password: &str) -> Result { /* ... */ }
async fn refresh(&self, refresh_token: &str) -> Result { /* ... */ }
async fn verify(&self, access_token: &str) -> Result { /* ... */ }
}
// src/auth/bootstrap.rs
let mut providers = AuthProviders::new(cfg.auth_provider)
.with_provider(std::sync::Arc::new(YourProvider::new(/* deps */)))?;
providers.set_active(cfg.auth_provider)?;
Add provider-specific env vars, then set AUTH_PROVIDER to switch.
Database
Use URL-driven provider selection for Postgres or SQLite.
Database backend is selected from APP_DATABASE__URL. Startup resolves a provider, connects with provider-specific options, runs provider post-connect hooks, then syncs entity schema.
Usage first: enable Postgres or SQLite
# Postgres
APP_DATABASE__URL=postgres://postgres:postgres@localhost:5432/app_db
APP_DATABASE__MAX_CONNECTIONS=10
APP_DATABASE__MIN_IDLE=2
# SQLite (recommended quick-start shape)
APP_DATABASE__URL=sqlite://app.db?mode=rwc
APP_DATABASE__MAX_CONNECTIONS=1
APP_DATABASE__MIN_IDLE=0
The SQLite URL should include mode=rwc so the file is created if missing. For existing projects, switching backend is changing this URL plus data migration.
# CLI initialization options
cargo run -p rust-oxide-cli -- init my_app --db sqlite
cargo run -p rust-oxide-cli -- init my_app --db postgres
Current providers implemented
- PostgresDbProvider in src/db/providers/postgres.rs
- SqliteDbProvider in src/db/providers/sqlite.rs
- Both are registered in src/db/providers/mod.rs
- Selection happens in src/db/connection.rs using URL scheme
// src/db/connection.rs
let providers = default_registry()?;
let provider = providers.provider_for_url(&cfg.url)?;
let db = provider.connect(cfg).await?;
provider.post_connect(&db, cfg).await?;
Entity design for multi-db safety
Keep entity definitions backend-agnostic. Use standard SeaORM types and relations. The #[base_entity] macro injects common id/timestamp fields and DAO trait support.
// src/db/entities/todo_item.rs
#[base_entity]
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "todo_items")]
pub struct Model {
#[sea_orm(indexed)]
pub list_id: Uuid,
pub description: String,
#[sea_orm(default_value = false)]
pub done: bool,
#[sea_orm(belongs_to, from = "list_id", to = "id", on_delete = "Cascade")]
pub list: HasOne<super::todo_list::Entity>,
}
- Put entities under src/db/entities/.
- Define relations explicitly with SeaORM relation attributes.
- Prefer app-managed timestamps and UUIDs through DAO create/update flow.
DAO patterns that scale across providers
Keep all data access in DAO modules. The generic DaoBase handles CRUD, pagination, filtering, id assignment, and timestamp updates. Route handlers should not embed raw SeaORM queries.
// src/db/dao/todo_dao.rs
#[derive(Clone)]
pub struct TodoDao {
db: DatabaseConnection,
}
impl DaoBase for TodoDao {
type Entity = TodoList;
fn new(db: &DatabaseConnection) -> Self {
Self { db: db.clone() }
}
fn db(&self) -> &DatabaseConnection {
&self.db
}
}
- Add provider-agnostic queries first (filters, joins, pagination).
- If provider-specific SQL is unavoidable, isolate it in clearly named DAO methods.
- Keep service and route layers free of backend conditionals.
Add custom database support (provider extension)
To add a new backend, implement DbProvider, register it, and choose it by URL scheme. supports_url identifies compatible URLs and post_connect applies backend-specific session setup.
// src/db/providers/your_db.rs
use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use crate::config::DatabaseConfig;
use crate::db::providers::{DbProvider, DbProviderId};
pub struct YourDbProvider;
#[async_trait]
impl DbProvider for YourDbProvider {
fn id(&self) -> DbProviderId { /* add enum variant */ }
fn supports_url(&self, url: &str) -> bool {
url.starts_with("yourdb://")
}
async fn connect(&self, cfg: &DatabaseConfig) -> anyhow::Result<DatabaseConnection> {
// build connect options + open connection
}
async fn post_connect(
&self,
db: &DatabaseConnection,
cfg: &DatabaseConfig,
) -> anyhow::Result<()> {
// backend-specific setup (optional)
Ok(())
}
}
// src/db/providers/mod.rs
pub fn default_registry() -> anyhow::Result<DbProviders> {
DbProviders::new()
.with_provider(Arc::new(PostgresDbProvider))?
.with_provider(Arc::new(SqliteDbProvider))?
.with_provider(Arc::new(YourDbProvider))
}
Keep provider concerns in src/db/providers/. Keep entity/DAO/service APIs stable so backend swaps do not leak into business logic.
Application config
Typed config with env mapping, optional sections, and centralized validation.
Configuration lives in src/config/ and is loaded once at startup. AppConfig is stored in AppState, so routes and services can access the same typed config object.
Usage: access config in startup and routes
// src/main.rs
let cfg = AppConfig::from_env()?;
init_tracing(&cfg.logging.rust_log);
let db_cfg = cfg.database.as_ref().context("database config missing")?;
let auth_cfg = cfg.auth.as_ref().context("auth config missing")?;
let db = connection::connect(db_cfg).await?;
let providers = init_providers(auth_cfg, &services).await?;
// route/handler example
async fn handler(State(state): State>) {
let host = &state.config.general.host;
let docs_in_release = state.config.general.enable_docs_in_release;
let auth_enabled = state.config.auth.is_some();
// use values
}
Add custom config
// src/config/configs.rs
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct AppConfig {
pub general: GeneralConfig,
pub logging: LoggingConfig,
pub database: Option<DatabaseConfig>,
pub auth: Option<AuthConfig>,
pub external_api: Option<ExternalApiConfig>, // new section
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalApiConfig {
pub base_url: String,
#[serde(default = "default_external_timeout_secs")]
pub timeout_secs: u64,
}
Then provide env vars like APP_EXTERNAL_API__BASE_URL and APP_EXTERNAL_API__TIMEOUT_SECS.
How config loading works
// src/config/envconfig.rs
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__")
.try_parsing(true)
- Prefix convention: APP_.
- Nesting separator: __ (double underscore).
- Example key: APP_DATABASE__MAX_CONNECTIONS.
- Release docs toggle: APP_GENERAL__ENABLE_DOCS_IN_RELEASE=true.
- .env is loaded first from crate root, then process env values are read.
- Deserialization is typed (numbers/bools/enums are parsed, invalid values fail fast).
Routing
Compose routers, expose CRUD endpoints, and keep extension points.
Routing is layered. The entry router mounts API routes at /api/v1 and merges view routes. CrudApiRouter lives in src/routes/crud_api_router.rs, while src/routes/base_api_router.rs contains the underlying trait hooks used by advanced routers. HTTP middleware and JSON response normalization also live under src/routes/.
Router composition
// src/routes/entry.rs
pub const API_PREFIX: &str = "/api/v1";
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.nest(API_PREFIX, api::router(state))
.merge(views::router())
}
Quick CRUD setup
// src/routes/api/todo_crud.rs
use std::sync::Arc;
use axum::{Router, routing::get};
use crate::auth::Role;
use crate::routes::middleware::AuthRolGuardLayer;
use crate::routes::crud_api_router::{CrudApiRouter, Method};
use crate::services::ServiceContext;
use crate::state::AppState;
const BASE_PATH: &str = "/todo-crud";
pub fn router(state: Arc<AppState>) -> Router {
let service = ServiceContext::from_state(state.as_ref()).todo();
CrudApiRouter::new(service.clone(), BASE_PATH)
.set_method_middleware(
Method::Create,
AuthRolGuardLayer::new(state.clone(), Role::User),
)
.router()
.route("/todo-crud/count", get(list_count_handler))
.with_state(state)
}
Keep handlers thin. Route-level HTTP composition stays in src/routes/api/; business logic and filter behavior stay in services.
Default endpoints
# http
POST /api/v1/<base-path>
GET /api/v1/<base-path>
GET /api/v1/<base-path>/{id}
PATCH /api/v1/<base-path>/{id}
DELETE /api/v1/<base-path>/{id}
Example base path: /todo-crud. IDs are UUIDs in this template.
Pagination defaults
# query params
?page=1&page_size=25
Max page_size is 100.
Filters on list routes
Generated list routes accept query params and parse them by column type. Defaults are FilterMode::AllColumns + FilterParseStrategy::ByColumnType.
# string column examples
title=acme
title=acme*
title=*acme
title=*acme*
# orderable non-string examples
score=>=10
score=<10
score=10..25
# typed examples
done=yes
external_id=550e8400-e29b-41d4-a716-446655440000
scheduled_at=2026-01-01T00:00:00+00:00
meta={"tier":"pro"}
Wildcards are only valid on string columns and only at edges. Interior wildcards (for example a*b) return 400.
Filter validation behavior
# 400 "Invalid filter"
# - unknown column key
# - denied column key
# - comparison/range used on non-orderable types
# 400 "Invalid filter value"
# - malformed typed values
# - malformed ranges (e.g. 1..2..3)
# - wildcard misuse
# - null literal ("null")
// src/services/your_service.rs
fn list_filter_mode(&self) -> FilterMode {
FilterMode::AllColumns {
deny: &["internal_only"],
parse: FilterParseStrategy::ByColumnType,
}
// or FilterMode::Allowlist(SPECS)
// or parse: FilterParseStrategy::StringsOnly / BestEffortString
}
Extension points
// src/routes/api/your_router.rs
use axum::routing::get;
use crate::routes::crud_api_router::{CrudApiRouter, Method};
let router = CrudApiRouter::new(service, "/items")
.set_allowed_methods(&[Method::List, Method::Get])
.router()
.route("/items/count", get(count_items));
// For deeper customization, implement BaseApiRouter and override:
// - list_order
// - list_apply
// - register_routes
// - apply_router_middleware
Route catalog notes
Keep .route(...) paths and CrudApiRouter::new(..., base_path) base paths as string literals or simple string constants. This keeps /routes and /api/v1/routes.json accurate in debug builds.
Logging
How logging is initialized and why each level is used.
Logging is built on tracing + tracing-subscriber. Startup calls init_tracing, then installs HTTP tracing and panic capture. The default filter is info,tower_http=info.
How to add your own logs
use tracing::{debug, info, warn, error};
info!(user_id = %user.id, "user created");
warn!(path = %request_path, "invalid query parameter");
debug!(role = %required_role, "auth check failed");
error!(error = ?err, "database operation failed");
Prefer structured fields (key = value) so logs are machine-queryable.
Set log verbosity
# default behavior
RUST_LOG=info,tower_http=info cargo run -p rust_oxide
# include request/response debug events
RUST_LOG=debug,tower_http=debug cargo run -p rust_oxide
# very verbose (including trace)
RUST_LOG=trace,tower_http=trace cargo run -p rust_oxide
Startup logging pipeline
// src/main.rs
let cfg = AppConfig::from_env()?;
init_tracing(&cfg.log_level);
let app = Router::new()
.merge(router(Arc::clone(&state)))
.layer(middleware::from_fn(json_error_middleware))
.layer(catch_panic_layer())
.layer(TraceLayer::new_for_http());
Files: src/main.rs, src/logging.rs, src/middleware/json_error.rs, src/middleware/panic.rs.
Automatic logging behavior
Automatic events you get without custom instrumentation
- Server boot and bind message (listening on ...).
- Schema sync message during database initialization.
- Per-request tracing emitted by TraceLayer.
- Panic logs with panic location and backtrace.
- Structured AppError logs when handlers return errors.
TraceLayer::new_for_http() classifies only 5xx as request failures by default. Non-5xx responses are normal completions.
Error level mapping
// src/response.rs (log_app_error)
if status.is_server_error() {
// 5xx -> error
} else if status == 401 || status == 403 {
// unauthorized/forbidden -> debug
} else {
// other 4xx -> warn
}
Error: Internal server failures and panic paths.
Warn: Client-facing validation and request-shape errors (for example 400/404/409).
Debug: Auth denials (401/403) to reduce log noise in normal protected-route traffic.
TraceLayer request logs and defaults
tower-http defaults used by TraceLayer::new_for_http()
- Request start event level: DEBUG.
- Response finished event level: DEBUG.
- Failure event level (5xx/service errors): ERROR.
With default RUST_LOG=info,tower_http=info, DEBUG-level request/response events are filtered out. Raise to DEBUG during local troubleshooting.
CLI
Scaffold projects and APIs fast.
The companion CLI is named oxide. Install it once, then initialize a fresh project and add or remove CRUD APIs from your project root.
Install the CLI
# with Rust installed (crates.io)
cargo install rust-oxide-cli
# no Rust required (macOS/Linux)
curl -fsSL https://raw.githubusercontent.com/HarrisDePerceptron/Rust-Oxide/master/scripts/install.sh | sh
# update to the latest version
curl -fsSL https://raw.githubusercontent.com/HarrisDePerceptron/Rust-Oxide/master/scripts/install.sh | sh -s -- --update
# uninstall
curl -fsSL https://raw.githubusercontent.com/HarrisDePerceptron/Rust-Oxide/master/scripts/install.sh | sh -s -- --uninstall
Init a new project
# installed via cargo or installer
oxide init my_app
# from this repo
cargo run -p rust-oxide-cli -- init my_app
Add --non-interactive or --force when you want to skip prompts.
Add or remove an API
Run these commands from your project root (the directory with Cargo.toml).
# add a CRUD API
oxide api add todo_item --fields "title:string,done:bool"
# remove a CRUD API
oxide api remove todo_item
# preview changes
oxide api add todo_item --dry-run
Use --no-auth to skip auth middleware and --force to overwrite or prune when needed. For local dev, prefix with cargo run -p rust-oxide-cli --.
Run oxide --help for full flags.
Error handling
Use AppError as the single application error contract.
The server normalizes handler and service failures through AppError. This keeps response shape, status mapping, and logging behavior consistent across the stack.
AppError mechanism
// src/error.rs
pub enum AppError {
BadRequest(String),
Unauthorized(String),
Forbidden(String),
NotFound(String),
Conflict(String),
Internal(InternalError),
}
- Client-facing variants carry a plain message.
- Internal can optionally carry a source error for logs.
- The same type is used by services, middleware, and route handlers.
Built-in conversions to AppError
Current conversion implementations
- anyhow::Error -> AppError: mapped to internal error with source.
- DaoLayerError -> AppError: database faults map to internal-with-source; validation-like DAO errors map to bad request.
- jsonwebtoken::errors::Error -> AppError: mapped to bad request with token validation context.
These conversions let you use ? in functions returning Result<_, AppError>.
Internal server errors: with source vs without source
// no source attached
return Err(AppError::internal("internal server error"));
// source attached (preferred for infra/IO/DB failures)
return Err(AppError::internal_with_source(
"database operation failed. Please check the logs for more details",
db_err,
));
- Use internal when you only have safe context text.
- Use internal_with_source when an underlying error exists and should be logged.
- Response message stays controlled while detailed source is available to logs.
How to implement conversion from a custom error
#[derive(Debug)]
enum MyDomainError {
InvalidInput(String),
Upstream(std::io::Error),
}
impl From for AppError {
fn from(err: MyDomainError) -> Self {
match err {
MyDomainError::InvalidInput(msg) => AppError::bad_request(msg),
MyDomainError::Upstream(source) => {
AppError::internal_with_source("upstream call failed", source)
}
}
}
}
Mapping guideline
- Domain or user-input violations should map to 4xx variants.
- Infrastructure failures should map to internal errors, ideally with source.
- Keep client message stable and log details through attached source errors.
anyhow interoperability
What is built in today
- anyhow::Error -> AppError is implemented directly.
- The reverse direction is not implemented as a dedicated From<AppError> impl.
- Because AppError implements std::error::Error, it can still be wrapped into anyhow::Error when needed.
// anyhow -> AppError
let app_err: AppError = anyhow_err.into();
// AppError -> anyhow (manual wrap)
let anyhow_err = anyhow::Error::new(app_err);
Response and middleware flow
- AppError::into_response maps variant to HTTP status and emits JSON API error payload.
- Error logs are level-mapped from status (5xx=error, 401/403=debug, other 4xx=warn).
- json_error_middleware wraps non-JSON error responses into the same AppError-based JSON format.
- catch_panic_layer ensures panics become 500 JSON responses.