| @@ -0,0 +1,20 @@ | |||
| # Copy this file to .env and fill in real values. | |||
| # Never commit .env to version control. | |||
| # ── Environment ─────────────────────────────────────────────────────────────── | |||
| APP_ENV=development # development | staging | production | |||
| # ── Server ──────────────────────────────────────────────────────────────────── | |||
| APP__SERVER__HOST=0.0.0.0 | |||
| APP__SERVER__PORT=8080 | |||
| # ── Logging ─────────────────────────────────────────────────────────────────── | |||
| # Any valid tracing EnvFilter directive. | |||
| RUST_LOG=axum_api_template=debug,tower_http=debug | |||
| # ── Database ────────────────────────────────────────────────────────────────── | |||
| APP__DATABASE__URL=postgres://user:password@localhost:5432/axum_api | |||
| # ── Auth ────────────────────────────────────────────────────────────────────── | |||
| APP__AUTH__JWT_SECRET=change-me-to-a-long-random-string | |||
| APP__AUTH__TOKEN_EXPIRY_SECONDS=3600 | |||
| @@ -0,0 +1,4 @@ | |||
| /target | |||
| .env | |||
| *.pem | |||
| *.key | |||
| @@ -0,0 +1,58 @@ | |||
| [package] | |||
| name = "axum-api-template" | |||
| version = "0.1.0" | |||
| edition = "2021" | |||
| rust-version = "1.75" | |||
| [[bin]] | |||
| name = "axum-api-template" | |||
| path = "src/main.rs" | |||
| [dependencies] | |||
| # Web framework | |||
| axum = { version = "0.7", features = ["macros"] } | |||
| axum-extra = { version = "0.9", features = ["typed-header"] } | |||
| tower = { version = "0.4", features = ["full"] } | |||
| tower-http = { version = "0.5", features = ["cors", "trace", "request-id", "normalize-path"] } | |||
| # Async runtime | |||
| tokio = { version = "1", features = ["full"] } | |||
| # Serialization | |||
| serde = { version = "1", features = ["derive"] } | |||
| serde_json = "1" | |||
| # Configuration | |||
| config = "0.14" | |||
| dotenvy = "0.15" | |||
| # Error handling | |||
| thiserror = "1" | |||
| anyhow = "1" | |||
| # Logging / tracing | |||
| tracing = "0.1" | |||
| tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } | |||
| # Validation | |||
| validator = { version = "0.18", features = ["derive"] } | |||
| # UUIDs | |||
| uuid = { version = "1", features = ["v4", "serde"] } | |||
| # Time | |||
| chrono = { version = "0.4", features = ["serde"] } | |||
| # HTTP status codes (re-exported via axum, listed explicitly for clarity) | |||
| http = "1" | |||
| [dev-dependencies] | |||
| axum-test = "14" | |||
| tokio = { version = "1", features = ["full"] } | |||
| serde_json = "1" | |||
| [profile.release] | |||
| opt-level = 3 | |||
| lto = true | |||
| codegen-units = 1 | |||
| strip = true | |||
| @@ -0,0 +1,121 @@ | |||
| # axum-api-template | |||
| An opinionated Axum web API template modelled after the ASP.NET project layout. | |||
| ## Philosophy | |||
| | ASP.NET concept | This template | | |||
| |---|---| | |||
| | `Program.cs` bootstrap | `src/main.rs` | | |||
| | `appsettings.json` + env overrides | `config/*.toml` + `APP__*` env vars | | |||
| | `IServiceCollection` / DI | `AppState` (shared via Axum `State` extractor) | | |||
| | Controller classes | `src/handlers/` — one file per resource | | |||
| | Request / Response DTOs | `src/models/` — `*Request` / `*Response` suffix convention | | |||
| | `ProblemDetails` | `src/errors/ApiError` — single `IntoResponse` impl | | |||
| | `app.Use*` middleware pipeline | `src/routes/mod.rs` `build_router()` | | |||
| | Route groups (`MapGroup`) | `src/routes/v1.rs` nested routers | | |||
| ## Project layout | |||
| ``` | |||
| axum-api-template/ | |||
| ├── config/ | |||
| │ ├── default.toml # base config (committed) | |||
| │ ├── development.toml # dev overrides (committed) | |||
| │ └── production.toml # prod overrides (committed; no secrets) | |||
| ├── src/ | |||
| │ ├── main.rs # entry point — startup sequence | |||
| │ ├── lib.rs # re-exports all modules for integration tests | |||
| │ ├── config/ # AppConfig (layered TOML + env vars) | |||
| │ ├── errors/ # ApiError → consistent JSON error envelope | |||
| │ ├── handlers/ | |||
| │ │ ├── health.rs # GET /health/live, /health/ready | |||
| │ │ └── items.rs # full CRUD scaffold for an example resource | |||
| │ ├── middleware/ | |||
| │ │ ├── auth.rs # AuthenticatedUser extractor (JWT stub) | |||
| │ │ └── request_id.rs # X-Request-ID documentation anchor | |||
| │ ├── models/ | |||
| │ │ ├── item.rs # Item domain struct + CreateItemRequest + ItemResponse | |||
| │ │ └── pagination.rs # PaginationQuery + PagedResponse<T> | |||
| │ ├── routes/ | |||
| │ │ ├── mod.rs # build_router() — middleware stack assembly | |||
| │ │ └── v1.rs # /api/v1/* route table | |||
| │ └── state/ # AppState (Arc<InnerState>) | |||
| ├── tests/ | |||
| │ ├── common/mod.rs # shared test helpers (TestServer factory) | |||
| │ └── health_test.rs # integration tests for /health/* | |||
| ├── .env.example | |||
| ├── .gitignore | |||
| └── Cargo.toml | |||
| ``` | |||
| ## Quickstart | |||
| ```bash | |||
| cp .env.example .env | |||
| # Edit .env — at minimum set APP__DATABASE__URL | |||
| cargo run | |||
| # → Listening on 0.0.0.0:8080 | |||
| ``` | |||
| ## Configuration | |||
| Configuration is resolved in order of increasing precedence: | |||
| 1. `config/default.toml` | |||
| 2. `config/{APP_ENV}.toml` (set `APP_ENV=production` in your environment) | |||
| 3. Environment variables prefixed `APP__` with double-underscore separators | |||
| ```bash | |||
| # Examples | |||
| APP__SERVER__PORT=9000 | |||
| APP__DATABASE__URL=postgres://user:pass@db/myapp | |||
| APP__AUTH__JWT_SECRET=super-secret | |||
| ``` | |||
| ## Naming conventions | |||
| | Thing | Convention | Example | | |||
| |---|---|---| | |||
| | Handler functions | `verb_resource` | `list_items`, `create_item` | | |||
| | Request DTOs | `{Resource}CreateRequest` | `CreateItemRequest` | | |||
| | Response DTOs | `{Resource}Response` | `ItemResponse` | | |||
| | Route modules | version-namespaced | `routes/v1.rs` | | |||
| | Config env vars | `APP__{SECTION}__{KEY}` | `APP__SERVER__PORT` | | |||
| | Error variants | PascalCase domain names | `ApiError::NotFound` | | |||
| ## Endpoints | |||
| | Method | Path | Description | | |||
| |---|---|---| | |||
| | `GET` | `/health/live` | Liveness check | | |||
| | `GET` | `/health/ready` | Readiness check (dependency health) | | |||
| | `GET` | `/api/v1/items` | List items (paginated) | | |||
| | `POST` | `/api/v1/items` | Create item | | |||
| | `GET` | `/api/v1/items/:id` | Get item by ID | | |||
| | `PUT` | `/api/v1/items/:id` | Update item | | |||
| | `DELETE` | `/api/v1/items/:id` | Delete item | | |||
| ## Error shape | |||
| All errors return a consistent JSON envelope: | |||
| ```json | |||
| { | |||
| "error": { | |||
| "status": 404, | |||
| "code": "NOT_FOUND", | |||
| "message": "Item 00000000-0000-0000-0000-000000000001 not found", | |||
| "trace_id": "01924b72-1234-7abc-def0-000000000000" | |||
| } | |||
| } | |||
| ``` | |||
| ## Next steps | |||
| - [ ] Replace the in-memory stubs in `handlers/items.rs` with real `sqlx` queries | |||
| - [ ] Implement JWT validation in `middleware/auth.rs` | |||
| - [ ] Add `APP__DATABASE__URL` to `.env` and uncomment the DB pool in `state/mod.rs` | |||
| - [ ] Tighten the CORS policy in `routes/mod.rs` for production | |||
| - [ ] Add a service layer between handlers and DB for business logic | |||
| @@ -0,0 +1,16 @@ | |||
| # Default configuration — values here are overridden by environment-specific | |||
| # files (development.toml, production.toml) and then by environment variables. | |||
| [server] | |||
| host = "0.0.0.0" | |||
| port = 8080 | |||
| [logging] | |||
| level = "info" | |||
| [database] | |||
| url = "postgres://localhost/axum_api" | |||
| [auth] | |||
| jwt_secret = "change-me-in-production" | |||
| token_expiry_seconds = 3600 | |||
| @@ -0,0 +1,2 @@ | |||
| [logging] | |||
| level = "debug" | |||
| @@ -0,0 +1,2 @@ | |||
| [logging] | |||
| level = "warn" | |||
| @@ -0,0 +1,101 @@ | |||
| //! Application configuration. | |||
| //! | |||
| //! Values are loaded in order of increasing precedence: | |||
| //! 1. Defaults embedded in code | |||
| //! 2. `config/default.toml` | |||
| //! 3. `config/{environment}.toml` (e.g. `config/production.toml`) | |||
| //! 4. Environment variables prefixed with `APP__` (double-underscore separator) | |||
| //! 5. `.env` file (loaded before this module runs) | |||
| //! | |||
| //! Mirrors the layered settings model familiar from `appsettings.json` in ASP.NET. | |||
| use config::{Config, ConfigError, Environment, File}; | |||
| use serde::Deserialize; | |||
| // ── Top-level config ───────────────────────────────────────────────────────── | |||
| #[derive(Debug, Clone, Deserialize)] | |||
| pub struct AppConfig { | |||
| pub environment: AppEnvironment, | |||
| pub server: ServerConfig, | |||
| pub logging: LoggingConfig, | |||
| pub database: DatabaseConfig, | |||
| pub auth: AuthConfig, | |||
| } | |||
| impl AppConfig { | |||
| pub fn load() -> Result<Self, ConfigError> { | |||
| let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); | |||
| let cfg = Config::builder() | |||
| .add_source(File::with_name("config/default").required(false)) | |||
| .add_source(File::with_name(&format!("config/{env}")).required(false)) | |||
| // APP__SERVER__PORT=9000 overrides server.port | |||
| .add_source( | |||
| Environment::with_prefix("APP") | |||
| .separator("__") | |||
| .try_parsing(true), | |||
| ) | |||
| .set_default("environment", env.clone())? | |||
| .set_default("server.host", "0.0.0.0")? | |||
| .set_default("server.port", 8080_i64)? | |||
| .set_default("logging.level", "info")? | |||
| .set_default("database.url", "postgres://localhost/axum_api")? | |||
| .set_default("auth.jwt_secret", "change-me-in-production")? | |||
| .set_default("auth.token_expiry_seconds", 3600_i64)? | |||
| .build()?; | |||
| cfg.try_deserialize() | |||
| } | |||
| } | |||
| // ── Sub-configs ─────────────────────────────────────────────────────────────── | |||
| #[derive(Debug, Clone, Deserialize)] | |||
| pub struct ServerConfig { | |||
| pub host: String, | |||
| pub port: u16, | |||
| } | |||
| #[derive(Debug, Clone, Deserialize)] | |||
| pub struct LoggingConfig { | |||
| /// Passed directly to `EnvFilter` — e.g. `"info"`, `"debug,tower_http=warn"`. | |||
| pub level: String, | |||
| } | |||
| #[derive(Debug, Clone, Deserialize)] | |||
| pub struct DatabaseConfig { | |||
| pub url: String, | |||
| } | |||
| #[derive(Debug, Clone, Deserialize)] | |||
| pub struct AuthConfig { | |||
| pub jwt_secret: String, | |||
| pub token_expiry_seconds: u64, | |||
| } | |||
| // ── Environment enum ────────────────────────────────────────────────────────── | |||
| #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] | |||
| #[serde(rename_all = "lowercase")] | |||
| pub enum AppEnvironment { | |||
| Development, | |||
| Staging, | |||
| Production, | |||
| } | |||
| impl AppEnvironment { | |||
| pub fn is_production(&self) -> bool { | |||
| matches!(self, AppEnvironment::Production) | |||
| } | |||
| } | |||
| impl std::fmt::Display for AppEnvironment { | |||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |||
| match self { | |||
| AppEnvironment::Development => write!(f, "development"), | |||
| AppEnvironment::Staging => write!(f, "staging"), | |||
| AppEnvironment::Production => write!(f, "production"), | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,124 @@ | |||
| //! Unified API error type. | |||
| //! | |||
| //! Every handler returns `Result<T, ApiError>`. `ApiError` implements | |||
| //! `IntoResponse`, so Axum serialises it automatically as a consistent JSON | |||
| //! envelope — no `match` boilerplate in handlers. | |||
| //! | |||
| //! Analogous to ASP.NET's `ProblemDetails` / `IActionResult` error pattern. | |||
| use axum::{ | |||
| http::StatusCode, | |||
| response::{IntoResponse, Response}, | |||
| Json, | |||
| }; | |||
| use serde::Serialize; | |||
| use thiserror::Error; | |||
| use uuid::Uuid; | |||
| // ── Error variants ──────────────────────────────────────────────────────────── | |||
| #[derive(Debug, Error)] | |||
| pub enum ApiError { | |||
| /// 400 — caller sent invalid data. | |||
| #[error("Validation error: {0}")] | |||
| Validation(String), | |||
| /// 401 — missing or invalid credentials. | |||
| #[error("Unauthorized: {0}")] | |||
| Unauthorized(String), | |||
| /// 403 — authenticated but not permitted. | |||
| #[error("Forbidden: {0}")] | |||
| Forbidden(String), | |||
| /// 404 — resource does not exist. | |||
| #[error("Not found: {0}")] | |||
| NotFound(String), | |||
| /// 409 — state conflict (duplicate, stale version, etc.). | |||
| #[error("Conflict: {0}")] | |||
| Conflict(String), | |||
| /// 422 — semantically invalid request body. | |||
| #[error("Unprocessable entity: {0}")] | |||
| UnprocessableEntity(String), | |||
| /// 500 — unexpected internal error. The internal detail is logged but | |||
| /// never exposed to callers. | |||
| #[error("Internal server error")] | |||
| Internal(#[from] anyhow::Error), | |||
| } | |||
| // ── JSON error body ─────────────────────────────────────────────────────────── | |||
| /// The JSON shape every error response returns. | |||
| /// | |||
| /// ```json | |||
| /// { | |||
| /// "error": { | |||
| /// "status": 404, | |||
| /// "code": "NOT_FOUND", | |||
| /// "message": "Item not found", | |||
| /// "trace_id": "01924b72-…" | |||
| /// } | |||
| /// } | |||
| /// ``` | |||
| #[derive(Serialize)] | |||
| struct ErrorBody { | |||
| error: ErrorDetail, | |||
| } | |||
| #[derive(Serialize)] | |||
| struct ErrorDetail { | |||
| status: u16, | |||
| code: &'static str, | |||
| message: String, | |||
| trace_id: String, | |||
| } | |||
| // ── IntoResponse impl ───────────────────────────────────────────────────────── | |||
| impl IntoResponse for ApiError { | |||
| fn into_response(self) -> Response { | |||
| let trace_id = Uuid::new_v4().to_string(); | |||
| let (status, code, message) = match &self { | |||
| ApiError::Validation(msg) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR", msg.clone()), | |||
| ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone()), | |||
| ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()), | |||
| ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()), | |||
| ApiError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()), | |||
| ApiError::UnprocessableEntity(msg) => { | |||
| (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY", msg.clone()) | |||
| } | |||
| ApiError::Internal(err) => { | |||
| // Log the real error server-side; never leak it to the caller. | |||
| tracing::error!(error = %err, trace_id = %trace_id, "Internal server error"); | |||
| ( | |||
| StatusCode::INTERNAL_SERVER_ERROR, | |||
| "INTERNAL_SERVER_ERROR", | |||
| "An unexpected error occurred.".to_string(), | |||
| ) | |||
| } | |||
| }; | |||
| let body = ErrorBody { | |||
| error: ErrorDetail { | |||
| status: status.as_u16(), | |||
| code, | |||
| message, | |||
| trace_id, | |||
| }, | |||
| }; | |||
| (status, Json(body)).into_response() | |||
| } | |||
| } | |||
| // ── Convenience conversions ─────────────────────────────────────────────────── | |||
| impl From<validator::ValidationErrors> for ApiError { | |||
| fn from(errs: validator::ValidationErrors) -> Self { | |||
| ApiError::Validation(errs.to_string()) | |||
| } | |||
| } | |||
| @@ -0,0 +1,47 @@ | |||
| //! Health and readiness check handlers. | |||
| //! | |||
| //! `/health/live` — liveness: is the process up? | |||
| //! `/health/ready` — readiness: are all dependencies reachable? | |||
| use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; | |||
| use chrono::Utc; | |||
| use serde::Serialize; | |||
| use crate::state::AppState; | |||
| // ── Response shape ──────────────────────────────────────────────────────────── | |||
| #[derive(Serialize)] | |||
| struct HealthResponse { | |||
| status: &'static str, | |||
| version: &'static str, | |||
| timestamp: String, | |||
| } | |||
| // ── Handlers ────────────────────────────────────────────────────────────────── | |||
| /// `GET /health/live` — always 200 while the process is running. | |||
| pub async fn liveness() -> impl IntoResponse { | |||
| ( | |||
| StatusCode::OK, | |||
| Json(HealthResponse { | |||
| status: "ok", | |||
| version: env!("CARGO_PKG_VERSION"), | |||
| timestamp: Utc::now().to_rfc3339(), | |||
| }), | |||
| ) | |||
| } | |||
| /// `GET /health/ready` — checks that all dependencies are healthy. | |||
| pub async fn readiness(State(_state): State<AppState>) -> impl IntoResponse { | |||
| // TODO: add real dependency checks, e.g. state.db().acquire().await | |||
| ( | |||
| StatusCode::OK, | |||
| Json(HealthResponse { | |||
| status: "ok", | |||
| version: env!("CARGO_PKG_VERSION"), | |||
| timestamp: Utc::now().to_rfc3339(), | |||
| }), | |||
| ) | |||
| } | |||
| @@ -0,0 +1,139 @@ | |||
| //! CRUD handlers for the `Item` resource. | |||
| //! | |||
| //! These are intentionally thin: extract → validate → delegate to a service | |||
| //! layer → map to a response. Business logic does NOT belong here. | |||
| //! | |||
| //! TODO: inject a real service / repository and remove the in-memory stub. | |||
| use axum::{ | |||
| extract::{Path, Query, State}, | |||
| http::StatusCode, | |||
| response::IntoResponse, | |||
| Json, | |||
| }; | |||
| use chrono::Utc; | |||
| use uuid::Uuid; | |||
| use validator::Validate; | |||
| use crate::{ | |||
| errors::ApiError, | |||
| models::{ | |||
| item::{CreateItemRequest, Item, ItemResponse, UpdateItemRequest}, | |||
| pagination::{PagedResponse, PaginationQuery}, | |||
| }, | |||
| state::AppState, | |||
| }; | |||
| // ── List ────────────────────────────────────────────────────────────────────── | |||
| /// `GET /api/v1/items?page=1&per_page=20` | |||
| pub async fn list_items( | |||
| State(_state): State<AppState>, | |||
| Query(pagination): Query<PaginationQuery>, | |||
| ) -> Result<impl IntoResponse, ApiError> { | |||
| // TODO: replace with a real database query. | |||
| let items: Vec<ItemResponse> = vec![stub_item()].into_iter().map(ItemResponse::from).collect(); | |||
| let total = items.len() as u64; | |||
| Ok(( | |||
| StatusCode::OK, | |||
| Json(PagedResponse::new(items, pagination.page, pagination.per_page, total)), | |||
| )) | |||
| } | |||
| // ── Get ─────────────────────────────────────────────────────────────────────── | |||
| /// `GET /api/v1/items/:id` | |||
| pub async fn get_item( | |||
| State(_state): State<AppState>, | |||
| Path(id): Path<Uuid>, | |||
| ) -> Result<impl IntoResponse, ApiError> { | |||
| // TODO: query DB; return `ApiError::NotFound` when absent. | |||
| let item = stub_item(); | |||
| if item.id != id { | |||
| return Err(ApiError::NotFound(format!("Item {id} not found"))); | |||
| } | |||
| Ok((StatusCode::OK, Json(ItemResponse::from(item)))) | |||
| } | |||
| // ── Create ──────────────────────────────────────────────────────────────────── | |||
| /// `POST /api/v1/items` | |||
| pub async fn create_item( | |||
| State(_state): State<AppState>, | |||
| Json(body): Json<CreateItemRequest>, | |||
| ) -> Result<impl IntoResponse, ApiError> { | |||
| body.validate()?; | |||
| // TODO: persist to DB. | |||
| let now = Utc::now(); | |||
| let item = Item { | |||
| id: Uuid::new_v4(), | |||
| name: body.name, | |||
| description: body.description, | |||
| created_at: now, | |||
| updated_at: now, | |||
| }; | |||
| Ok((StatusCode::CREATED, Json(ItemResponse::from(item)))) | |||
| } | |||
| // ── Update ──────────────────────────────────────────────────────────────────── | |||
| /// `PUT /api/v1/items/:id` | |||
| pub async fn update_item( | |||
| State(_state): State<AppState>, | |||
| Path(id): Path<Uuid>, | |||
| Json(body): Json<UpdateItemRequest>, | |||
| ) -> Result<impl IntoResponse, ApiError> { | |||
| body.validate()?; | |||
| // TODO: load from DB, apply patch, persist. | |||
| let mut item = stub_item(); | |||
| if item.id != id { | |||
| return Err(ApiError::NotFound(format!("Item {id} not found"))); | |||
| } | |||
| if let Some(name) = body.name { | |||
| item.name = name; | |||
| } | |||
| if let Some(description) = body.description { | |||
| item.description = Some(description); | |||
| } | |||
| item.updated_at = Utc::now(); | |||
| Ok((StatusCode::OK, Json(ItemResponse::from(item)))) | |||
| } | |||
| // ── Delete ──────────────────────────────────────────────────────────────────── | |||
| /// `DELETE /api/v1/items/:id` | |||
| pub async fn delete_item( | |||
| State(_state): State<AppState>, | |||
| Path(id): Path<Uuid>, | |||
| ) -> Result<impl IntoResponse, ApiError> { | |||
| // TODO: delete from DB; return 404 if absent. | |||
| let item = stub_item(); | |||
| if item.id != id { | |||
| return Err(ApiError::NotFound(format!("Item {id} not found"))); | |||
| } | |||
| Ok(StatusCode::NO_CONTENT) | |||
| } | |||
| // ── Stub helper (remove when DB is wired) ──────────────────────────────────── | |||
| fn stub_item() -> Item { | |||
| let now = Utc::now(); | |||
| Item { | |||
| id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), | |||
| name: "Example Item".to_string(), | |||
| description: Some("A placeholder item.".to_string()), | |||
| created_at: now, | |||
| updated_at: now, | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| //! Request handlers. | |||
| //! | |||
| //! One submodule per resource. Each handler is a plain `async fn` that | |||
| //! accepts Axum extractors and returns `Result<impl IntoResponse, ApiError>`. | |||
| //! | |||
| //! Convention: | |||
| //! list_* → GET /resource (paginated) | |||
| //! get_* → GET /resource/:id | |||
| //! create_* → POST /resource | |||
| //! update_* → PUT /resource/:id | |||
| //! delete_* → DELETE /resource/:id | |||
| pub mod health; | |||
| pub mod items; | |||
| @@ -0,0 +1,10 @@ | |||
| //! Library root — re-exports all public modules so integration tests can | |||
| //! reference them as `axum_api_template::config`, etc. | |||
| pub mod config; | |||
| pub mod errors; | |||
| pub mod handlers; | |||
| pub mod middleware; | |||
| pub mod models; | |||
| pub mod routes; | |||
| pub mod state; | |||
| @@ -0,0 +1,98 @@ | |||
| //! Application entry point. | |||
| //! | |||
| //! Bootstraps configuration, initialises shared state, wires up the router, | |||
| //! and binds the TCP listener — mirroring the ASP.NET `Program.cs` pattern. | |||
| use std::net::SocketAddr; | |||
| use anyhow::Context; | |||
| use axum_api_template::{ | |||
| config::AppConfig, | |||
| routes::build_router, | |||
| state::AppState, | |||
| }; | |||
| use tokio::net::TcpListener; | |||
| use tracing::info; | |||
| #[tokio::main] | |||
| async fn main() -> anyhow::Result<()> { | |||
| // ── 1. Bootstrap configuration ────────────────────────────────────────── | |||
| dotenvy::dotenv().ok(); | |||
| let config = AppConfig::load().context("Failed to load application configuration")?; | |||
| // ── 2. Initialise structured logging ──────────────────────────────────── | |||
| init_tracing(&config); | |||
| // ── 3. Build shared application state ─────────────────────────────────── | |||
| let state = AppState::new(config.clone()).await.context("Failed to initialise AppState")?; | |||
| // ── 4. Build the router ────────────────────────────────────────────────── | |||
| let app = build_router(state); | |||
| // ── 5. Bind and serve ──────────────────────────────────────────────────── | |||
| let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port) | |||
| .parse() | |||
| .context("Invalid server address")?; | |||
| let listener = TcpListener::bind(addr).await.context("Failed to bind TCP listener")?; | |||
| info!(address = %addr, "Server listening"); | |||
| axum::serve(listener, app) | |||
| .with_graceful_shutdown(shutdown_signal()) | |||
| .await | |||
| .context("Server error")?; | |||
| Ok(()) | |||
| } | |||
| /// Configure `tracing-subscriber` based on the current environment. | |||
| /// | |||
| /// - Development: human-readable `RUST_LOG`-filtered output. | |||
| /// - Production: JSON structured output for log aggregators. | |||
| fn init_tracing(config: &AppConfig) { | |||
| use tracing_subscriber::{fmt, prelude::*, EnvFilter}; | |||
| let env_filter = EnvFilter::try_from_default_env() | |||
| .unwrap_or_else(|_| EnvFilter::new(&config.logging.level)); | |||
| if config.environment.is_production() { | |||
| tracing_subscriber::registry() | |||
| .with(env_filter) | |||
| .with(fmt::layer().json()) | |||
| .init(); | |||
| } else { | |||
| tracing_subscriber::registry() | |||
| .with(env_filter) | |||
| .with(fmt::layer().pretty()) | |||
| .init(); | |||
| } | |||
| } | |||
| /// Listens for `SIGINT` (Ctrl-C) and `SIGTERM` so the server drains in-flight | |||
| /// requests before exiting. | |||
| async fn shutdown_signal() { | |||
| use tokio::signal; | |||
| let ctrl_c = async { | |||
| signal::ctrl_c().await.expect("Failed to install Ctrl-C handler"); | |||
| }; | |||
| #[cfg(unix)] | |||
| let terminate = async { | |||
| signal::unix::signal(signal::unix::SignalKind::terminate()) | |||
| .expect("Failed to install SIGTERM handler") | |||
| .recv() | |||
| .await; | |||
| }; | |||
| #[cfg(not(unix))] | |||
| let terminate = std::future::pending::<()>(); | |||
| tokio::select! { | |||
| _ = ctrl_c => {}, | |||
| _ = terminate => {}, | |||
| } | |||
| info!("Shutdown signal received, draining connections…"); | |||
| } | |||
| @@ -0,0 +1,69 @@ | |||
| //! Bearer-token authentication extractor. | |||
| //! | |||
| //! Use `AuthenticatedUser` as a handler parameter to require a valid JWT. | |||
| //! Unauthenticated requests are rejected with `401 Unauthorized` before the | |||
| //! handler body ever executes. | |||
| //! | |||
| //! # Example | |||
| //! | |||
| //! ```rust,ignore | |||
| //! async fn protected_handler( | |||
| //! user: AuthenticatedUser, | |||
| //! State(state): State<AppState>, | |||
| //! ) -> Result<impl IntoResponse, ApiError> { | |||
| //! // user.id and user.roles are guaranteed valid here | |||
| //! Ok(Json(json!({ "sub": user.id }))) | |||
| //! } | |||
| //! ``` | |||
| use axum::{ | |||
| async_trait, | |||
| extract::FromRequestParts, | |||
| http::request::Parts, | |||
| RequestPartsExt, | |||
| }; | |||
| use axum_extra::{ | |||
| headers::{authorization::Bearer, Authorization}, | |||
| TypedHeader, | |||
| }; | |||
| use uuid::Uuid; | |||
| use crate::errors::ApiError; | |||
| // ── Authenticated user claim ────────────────────────────────────────────────── | |||
| /// Injected into handlers that require authentication. | |||
| #[derive(Debug, Clone)] | |||
| pub struct AuthenticatedUser { | |||
| pub id: Uuid, | |||
| pub roles: Vec<String>, | |||
| } | |||
| // ── FromRequestParts impl ───────────────────────────────────────────────────── | |||
| #[async_trait] | |||
| impl<S> FromRequestParts<S> for AuthenticatedUser | |||
| where | |||
| S: Send + Sync, | |||
| { | |||
| type Rejection = ApiError; | |||
| async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { | |||
| // Extract the Bearer token from the Authorization header. | |||
| let TypedHeader(Authorization(bearer)) = parts | |||
| .extract::<TypedHeader<Authorization<Bearer>>>() | |||
| .await | |||
| .map_err(|_| ApiError::Unauthorized("Missing or malformed Authorization header".to_string()))?; | |||
| // TODO: replace this stub with real JWT validation. | |||
| // | |||
| // Example using `jsonwebtoken`: | |||
| // let secret = state.config().auth.jwt_secret.as_bytes(); | |||
| // let token_data = decode::<Claims>(bearer.token(), &DecodingKey::from_secret(secret), &Validation::default())?; | |||
| // return Ok(AuthenticatedUser { id: token_data.claims.sub, roles: token_data.claims.roles }); | |||
| let _ = bearer.token(); // silence unused-variable lint on the stub | |||
| Err(ApiError::Unauthorized("JWT validation not yet implemented".to_string())) | |||
| } | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| //! Tower middleware layers. | |||
| //! | |||
| //! Each submodule exports a function that returns a `ServiceBuilder` layer | |||
| //! or a standalone handler. They are composed in `routes/mod.rs`. | |||
| pub mod auth; | |||
| pub mod request_id; | |||
| @@ -0,0 +1,9 @@ | |||
| //! Propagates / generates a `X-Request-ID` header on every response. | |||
| //! | |||
| //! If the caller supplies `X-Request-ID` in the request, that value is echoed | |||
| //! back. Otherwise a new UUID v4 is generated. The ID is also injected into | |||
| //! the `tracing` span so it appears in every log line for the request. | |||
| // This middleware is wired up in routes/mod.rs via tower_http::request_id. | |||
| // Nothing to implement here — the tower-http crate handles it. | |||
| // This file exists as a documentation anchor and future extension point. | |||
| @@ -0,0 +1,65 @@ | |||
| //! Example domain model: `Item`. | |||
| //! | |||
| //! Replace with your own domain entities. The pattern to follow: | |||
| //! 1. Define the domain struct (mirrors your DB row). | |||
| //! 2. Define `*CreateRequest` / `*UpdateRequest` with `validator` annotations. | |||
| //! 3. Define `*Response` — the public-facing shape, free of internal fields. | |||
| use chrono::{DateTime, Utc}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use uuid::Uuid; | |||
| use validator::Validate; | |||
| // ── Domain ──────────────────────────────────────────────────────────────────── | |||
| #[derive(Debug, Clone, Serialize)] | |||
| pub struct Item { | |||
| pub id: Uuid, | |||
| pub name: String, | |||
| pub description: Option<String>, | |||
| pub created_at: DateTime<Utc>, | |||
| pub updated_at: DateTime<Utc>, | |||
| } | |||
| // ── Request DTOs ────────────────────────────────────────────────────────────── | |||
| #[derive(Debug, Deserialize, Validate)] | |||
| pub struct CreateItemRequest { | |||
| #[validate(length(min = 1, max = 100, message = "name must be 1–100 characters"))] | |||
| pub name: String, | |||
| #[validate(length(max = 500, message = "description must be ≤ 500 characters"))] | |||
| pub description: Option<String>, | |||
| } | |||
| #[derive(Debug, Deserialize, Validate)] | |||
| pub struct UpdateItemRequest { | |||
| #[validate(length(min = 1, max = 100, message = "name must be 1–100 characters"))] | |||
| pub name: Option<String>, | |||
| #[validate(length(max = 500, message = "description must be ≤ 500 characters"))] | |||
| pub description: Option<String>, | |||
| } | |||
| // ── Response DTO ────────────────────────────────────────────────────────────── | |||
| #[derive(Debug, Serialize)] | |||
| pub struct ItemResponse { | |||
| pub id: Uuid, | |||
| pub name: String, | |||
| pub description: Option<String>, | |||
| pub created_at: DateTime<Utc>, | |||
| pub updated_at: DateTime<Utc>, | |||
| } | |||
| impl From<Item> for ItemResponse { | |||
| fn from(item: Item) -> Self { | |||
| Self { | |||
| id: item.id, | |||
| name: item.name, | |||
| description: item.description, | |||
| created_at: item.created_at, | |||
| updated_at: item.updated_at, | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| //! Data transfer objects (DTOs) and domain models. | |||
| //! | |||
| //! Convention: | |||
| //! - `*Request` — inbound JSON body (what the caller sends). | |||
| //! - `*Response` — outbound JSON body (what we return). | |||
| //! - Domain structs without a suffix hold business data (DB row shape, etc.). | |||
| //! | |||
| //! Keep request/response types thin — no business logic here. | |||
| pub mod item; | |||
| pub mod pagination; | |||
| @@ -0,0 +1,50 @@ | |||
| //! Generic pagination envelope and query parameters. | |||
| use serde::{Deserialize, Serialize}; | |||
| /// Query parameters for paginated list endpoints: `?page=2&per_page=25` | |||
| #[derive(Debug, Deserialize)] | |||
| pub struct PaginationQuery { | |||
| #[serde(default = "default_page")] | |||
| pub page: u32, | |||
| #[serde(default = "default_per_page")] | |||
| pub per_page: u32, | |||
| } | |||
| fn default_page() -> u32 { | |||
| 1 | |||
| } | |||
| fn default_per_page() -> u32 { | |||
| 20 | |||
| } | |||
| /// Wraps a list payload with pagination metadata. | |||
| /// | |||
| /// ```json | |||
| /// { | |||
| /// "data": [...], | |||
| /// "meta": { "page": 1, "per_page": 20, "total": 42 } | |||
| /// } | |||
| /// ``` | |||
| #[derive(Debug, Serialize)] | |||
| pub struct PagedResponse<T: Serialize> { | |||
| pub data: Vec<T>, | |||
| pub meta: PageMeta, | |||
| } | |||
| #[derive(Debug, Serialize)] | |||
| pub struct PageMeta { | |||
| pub page: u32, | |||
| pub per_page: u32, | |||
| pub total: u64, | |||
| } | |||
| impl<T: Serialize> PagedResponse<T> { | |||
| pub fn new(data: Vec<T>, page: u32, per_page: u32, total: u64) -> Self { | |||
| Self { | |||
| data, | |||
| meta: PageMeta { page, per_page, total }, | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,65 @@ | |||
| //! Router assembly. | |||
| //! | |||
| //! All routes are grouped by API version. Each version's routes live in their | |||
| //! own submodule, keeping `build_router` readable regardless of how many | |||
| //! endpoints the application grows to. | |||
| //! | |||
| //! Middleware is layered here rather than scattered across handlers, mirroring | |||
| //! ASP.NET's `app.Use*` pipeline in `Program.cs`. | |||
| mod v1; | |||
| use axum::Router; | |||
| use tower::ServiceBuilder; | |||
| use tower_http::{ | |||
| cors::{Any, CorsLayer}, | |||
| normalize_path::NormalizePathLayer, | |||
| request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}, | |||
| trace::TraceLayer, | |||
| }; | |||
| use crate::state::AppState; | |||
| const REQUEST_ID_HEADER: &str = "x-request-id"; | |||
| pub fn build_router(state: AppState) -> Router { | |||
| let request_id_header = http::HeaderName::from_static(REQUEST_ID_HEADER); | |||
| // Build the middleware stack (applied bottom-up). | |||
| let middleware_stack = ServiceBuilder::new() | |||
| // Strip/add trailing slashes so `/items` and `/items/` both work. | |||
| .layer(NormalizePathLayer::trim_trailing_slash()) | |||
| // Generate / propagate X-Request-ID on every request. | |||
| .layer(SetRequestIdLayer::new( | |||
| request_id_header.clone(), | |||
| MakeRequestUuid, | |||
| )) | |||
| .layer(PropagateRequestIdLayer::new(request_id_header)) | |||
| // Structured tracing span per request (includes method, path, status, latency). | |||
| .layer(TraceLayer::new_for_http()) | |||
| // Permissive CORS — tighten in production via config. | |||
| .layer( | |||
| CorsLayer::new() | |||
| .allow_origin(Any) | |||
| .allow_methods(Any) | |||
| .allow_headers(Any), | |||
| ); | |||
| Router::new() | |||
| // Unversioned health endpoints (no auth required). | |||
| .nest("/health", health_router(state.clone())) | |||
| // Versioned API routes. | |||
| .nest("/api/v1", v1::router(state.clone())) | |||
| .layer(middleware_stack) | |||
| } | |||
| /// Health check sub-router. | |||
| fn health_router(state: AppState) -> Router { | |||
| use axum::routing::get; | |||
| use crate::handlers::health; | |||
| Router::new() | |||
| .route("/live", get(health::liveness)) | |||
| .route("/ready", get(health::readiness)) | |||
| .with_state(state) | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| //! API v1 route definitions. | |||
| //! | |||
| //! Add new resource routers by nesting them here. Each resource follows the | |||
| //! standard REST layout so route intent is obvious at a glance. | |||
| use axum::{ | |||
| routing::get, | |||
| Router, | |||
| }; | |||
| use crate::{handlers::items, state::AppState}; | |||
| pub fn router(state: AppState) -> Router { | |||
| Router::new() | |||
| .nest("/items", items_router()) | |||
| // .nest("/users", users_router()) ← add more resources here | |||
| .with_state(state) | |||
| } | |||
| fn items_router() -> Router<AppState> { | |||
| Router::new() | |||
| .route("/", get(items::list_items)) | |||
| .route("/", axum::routing::post(items::create_item)) | |||
| .route("/:id", get(items::get_item)) | |||
| .route("/:id", axum::routing::put(items::update_item)) | |||
| .route("/:id", axum::routing::delete(items::delete_item)) | |||
| } | |||
| @@ -0,0 +1,50 @@ | |||
| //! Shared application state. | |||
| //! | |||
| //! `AppState` is cloned cheaply into every request handler via Axum's | |||
| //! `State` extractor. All expensive resources (DB pools, HTTP clients, etc.) | |||
| //! live behind `Arc` internally — the outer clone is O(1). | |||
| //! | |||
| //! Add new cross-cutting resources here, not in individual handlers. | |||
| use std::sync::Arc; | |||
| use anyhow::Result; | |||
| use crate::config::AppConfig; | |||
| // ── Public handle (cheap to clone) ─────────────────────────────────────────── | |||
| /// The type you inject into handlers: `State<AppState>`. | |||
| #[derive(Clone)] | |||
| pub struct AppState { | |||
| inner: Arc<InnerState>, | |||
| } | |||
| impl AppState { | |||
| pub async fn new(config: AppConfig) -> Result<Self> { | |||
| // TODO: initialise a real DB pool here, e.g. sqlx::PgPool::connect(&config.database.url) | |||
| // let db = PgPool::connect(&config.database.url).await?; | |||
| Ok(Self { | |||
| inner: Arc::new(InnerState { | |||
| config, | |||
| // db, | |||
| }), | |||
| }) | |||
| } | |||
| /// Access the loaded configuration from any handler. | |||
| pub fn config(&self) -> &AppConfig { | |||
| &self.inner.config | |||
| } | |||
| // TODO: expose other resources as methods, e.g.: | |||
| // pub fn db(&self) -> &PgPool { &self.inner.db } | |||
| } | |||
| // ── Private inner state (heap-allocated once) ───────────────────────────────── | |||
| struct InnerState { | |||
| config: AppConfig, | |||
| // db: PgPool, | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| //! Shared test helpers. | |||
| use axum_test::TestServer; | |||
| use axum_api_template::{ | |||
| config::AppConfig, | |||
| routes::build_router, | |||
| state::AppState, | |||
| }; | |||
| /// Spins up an in-process test server with a minimal config. | |||
| pub async fn test_server() -> TestServer { | |||
| // Set minimal env vars so AppConfig::load() succeeds. | |||
| std::env::set_var("APP_ENV", "development"); | |||
| let config = AppConfig::load().expect("Failed to load test config"); | |||
| let state = AppState::new(config).await.expect("Failed to build test state"); | |||
| let app = build_router(state); | |||
| TestServer::new(app).expect("Failed to create test server") | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| //! Integration tests for the health endpoints. | |||
| //! | |||
| //! Uses `axum-test` for a zero-network in-process test client. | |||
| use axum_test::TestServer; | |||
| use serde_json::Value; | |||
| mod common; | |||
| #[tokio::test] | |||
| async fn liveness_returns_200() { | |||
| let server = common::test_server().await; | |||
| let response = server.get("/health/live").await; | |||
| response.assert_status_ok(); | |||
| let body: Value = response.json(); | |||
| assert_eq!(body["status"], "ok"); | |||
| } | |||
| #[tokio::test] | |||
| async fn readiness_returns_200() { | |||
| let server = common::test_server().await; | |||
| let response = server.get("/health/ready").await; | |||
| response.assert_status_ok(); | |||
| } | |||