| @@ -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(); | |||||
| } | |||||