浏览代码

Initial commit

master
Jared Bell 2 周前
当前提交
20a4ca698e
共有 26 个文件被更改,包括 3717 次插入0 次删除
  1. +20
    -0
      .env.example
  2. +4
    -0
      .gitignore
  3. +2562
    -0
      Cargo.lock
  4. +58
    -0
      Cargo.toml
  5. +121
    -0
      README.md
  6. +16
    -0
      config/default.toml
  7. +2
    -0
      config/development.toml
  8. +2
    -0
      config/production.toml
  9. +101
    -0
      src/config/mod.rs
  10. +124
    -0
      src/errors/mod.rs
  11. +47
    -0
      src/handlers/health.rs
  12. +139
    -0
      src/handlers/items.rs
  13. +14
    -0
      src/handlers/mod.rs
  14. +10
    -0
      src/lib.rs
  15. +98
    -0
      src/main.rs
  16. +69
    -0
      src/middleware/auth.rs
  17. +7
    -0
      src/middleware/mod.rs
  18. +9
    -0
      src/middleware/request_id.rs
  19. +65
    -0
      src/models/item.rs
  20. +11
    -0
      src/models/mod.rs
  21. +50
    -0
      src/models/pagination.rs
  22. +65
    -0
      src/routes/mod.rs
  23. +27
    -0
      src/routes/v1.rs
  24. +50
    -0
      src/state/mod.rs
  25. +21
    -0
      tests/common/mod.rs
  26. +25
    -0
      tests/health_test.rs

+ 20
- 0
.env.example 查看文件

@@ -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

+ 4
- 0
.gitignore 查看文件

@@ -0,0 +1,4 @@
/target
.env
*.pem
*.key

+ 2562
- 0
Cargo.lock
文件差异内容过多而无法显示
查看文件


+ 58
- 0
Cargo.toml 查看文件

@@ -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

+ 121
- 0
README.md 查看文件

@@ -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

+ 16
- 0
config/default.toml 查看文件

@@ -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

+ 2
- 0
config/development.toml 查看文件

@@ -0,0 +1,2 @@
[logging]
level = "debug"

+ 2
- 0
config/production.toml 查看文件

@@ -0,0 +1,2 @@
[logging]
level = "warn"

+ 101
- 0
src/config/mod.rs 查看文件

@@ -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"),
}
}
}

+ 124
- 0
src/errors/mod.rs 查看文件

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

+ 47
- 0
src/handlers/health.rs 查看文件

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

+ 139
- 0
src/handlers/items.rs 查看文件

@@ -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,
}
}

+ 14
- 0
src/handlers/mod.rs 查看文件

@@ -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;

+ 10
- 0
src/lib.rs 查看文件

@@ -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;

+ 98
- 0
src/main.rs 查看文件

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

+ 69
- 0
src/middleware/auth.rs 查看文件

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

+ 7
- 0
src/middleware/mod.rs 查看文件

@@ -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;

+ 9
- 0
src/middleware/request_id.rs 查看文件

@@ -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.

+ 65
- 0
src/models/item.rs 查看文件

@@ -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,
}
}
}

+ 11
- 0
src/models/mod.rs 查看文件

@@ -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;

+ 50
- 0
src/models/pagination.rs 查看文件

@@ -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 },
}
}
}

+ 65
- 0
src/routes/mod.rs 查看文件

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

+ 27
- 0
src/routes/v1.rs 查看文件

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

+ 50
- 0
src/state/mod.rs 查看文件

@@ -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,
}

+ 21
- 0
tests/common/mod.rs 查看文件

@@ -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")
}

+ 25
- 0
tests/health_test.rs 查看文件

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

正在加载...
取消
保存