Initial commit: Axum web service template

This commit is contained in:
fa-sharp
2026-02-19 15:45:45 -05:00
commit 694b94d579
11 changed files with 1719 additions and 0 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# API Configuration
{{env_prefix}}_API_KEY=your-secret-api-key-here
# Server Configuration
{{env_prefix}}_HOST=127.0.0.1
{{env_prefix}}_PORT={{default_port}}
# Logging
{{env_prefix}}_LOG_LEVEL={{default_log_level}}

3
.genignore Normal file
View File

@@ -0,0 +1,3 @@
target/
Cargo.lock
.env

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
.env*
!.env.example

1359
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
Cargo.toml Normal file
View File

@@ -0,0 +1,36 @@
[package]
name = "{{project-name}}"
version = "0.1.0"
edition = "2021"
description = "{{project-description}}"
[dependencies]
{% if include_aide %}aide = { version = "0.16.0-alpha.1", features = [
"axum",
"axum-json",
"axum-query",
] }
{% endif %}anyhow = "1.0.101"
axum = { version = "0.8.8", features = ["json", "query"] }
axum-app-wrapper = { git = "https://gitea.fasharp.io/fa-sharp/axum-app-wrapper", rev = "a8d5e4f962" }
{% if include_aide %}schemars = { version = "1.0", features = [
"chrono04",
"preserve_order",
"url2",
"uuid1"
] }
{% endif %}
dotenvy = "0.15.7"
figment = { version = "0.10.19", features = ["env"] }
serde = "1.0.228"
serde_json = "1.0.145"
tokio = { version = "1.48.0", default-features = false, features = [
"macros",
"net",
"rt",
"rt-multi-thread",
"signal"
] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
type-map = "0.5.1"

111
README.md Normal file
View File

@@ -0,0 +1,111 @@
# Axum Web Service Template
A production-ready template for building web services with Rust and Axum.
## Features
- 🚀 **Axum** - Fast and ergonomic web framework
- ⚙️ **Configuration Management** - Environment-based config with `figment`
- 📝 **Structured Logging** - JSON logging in production with `tracing`
- 🔄 **Graceful Shutdown** - Handles SIGTERM and SIGINT signals
- 🏗️ **Plugin Architecture** - Modular app initialization with `axum-app-wrapper`
- 🔌 **Optional OpenAPI** - API documentation with `aide` (optional)
## Usage
### Using cargo-generate
Install cargo-generate if you haven't already:
```bash
cargo install cargo-generate
```
Generate a new project from this template:
```bash
cargo generate --git <your-template-repo-url>
```
You'll be prompted for:
- **Project name**: The name of your new project
- **Project description**: A brief description
- **Environment variable prefix**: Prefix for env vars (e.g., `APP` for `APP_HOST`, `APP_PORT`)
- **Default port**: The server's default port
- **Default log level**: trace, debug, info, warn, or error
- **Include aide**: Whether to include OpenAPI documentation support
### Manual Setup
1. Clone or download this repository
2. Update `Cargo.toml` with your project name and details
3. Copy `.env.example` to `.env` and configure your environment variables
4. Run `cargo build`
## Configuration
Configuration is loaded from environment variables. The prefix is configurable during template generation.
Example with `APP` prefix:
```bash
# Required
APP_API_KEY=your-secret-key
# Optional (defaults shown)
APP_HOST=127.0.0.1
APP_PORT=8080
APP_LOG_LEVEL=info
```
In development, you can use a `.env` file (copy from `.env.example`).
## Project Structure
```
.
├── src/
│ ├── main.rs # Entry point, server setup
│ ├── lib.rs # App initialization
│ ├── config.rs # Configuration management
│ └── state.rs # Application state
├── Cargo.toml # Dependencies
└── .env.example # Example environment variables
```
## Development
```bash
# Run in development mode (loads .env file)
cargo run
# Run with custom log level
RUST_LOG=debug cargo run
# Build for production
cargo build --release
```
## Adding Routes
This template uses `axum-app-wrapper` for modular initialization. To add routes:
1. Create a new plugin in a separate module
2. Register it in `lib.rs`:
```rust
pub async fn create_app() -> anyhow::Result<(axum::Router, AppConfig, impl Future + Send)> {
let (router, state, on_shutdown) = App::new()
.register(config::plugin())
.register(your_routes::plugin()) // Add your plugin here
.init()
.await?;
let app_config = state.config.to_owned();
Ok((router.with_state(state), app_config, on_shutdown))
}
```
## License
Configure your license as needed.

28
cargo-generate.toml Normal file
View File

@@ -0,0 +1,28 @@
[template]
cargo_generate_version = ">=0.10.0"
[placeholders.project-description]
type = "string"
prompt = "Project description?"
default = "A Rust web service built with Axum"
[placeholders.env_prefix]
type = "string"
prompt = "Environment variable prefix (e.g., APP for APP_HOST, APP_PORT)?"
default = "APP"
[placeholders.default_port]
type = "string"
prompt = "Default server port?"
default = "8080"
[placeholders.default_log_level]
type = "string"
prompt = "Default log level?"
default = "info"
choices = ["trace", "debug", "info", "warn", "error"]
[placeholders.include_aide]
type = "bool"
prompt = "Include aide for OpenAPI documentation?"
default = true

48
src/config.rs Normal file
View File

@@ -0,0 +1,48 @@
use std::net::{IpAddr, Ipv4Addr};
use anyhow::Context;
use axum_app_wrapper::AdHocPlugin;
{% if include_aide %}use schemars::JsonSchema;
{% endif %}
use serde::Deserialize;
use crate::state::AppState;
#[derive(Debug, Clone, Deserialize{% if include_aide %}, JsonSchema{% endif %})]
pub struct AppConfig {
pub api_key: String,
#[serde(default = "default_host")]
pub host: IpAddr,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_log_level")]
pub log_level: String,
}
fn default_host() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
fn default_port() -> u16 {
{{default_port}}
}
fn default_log_level() -> String {
"{{default_log_level}}".to_string()
}
/// Plugin that reads and validates configuration, and adds it to server state
pub fn plugin() -> AdHocPlugin<AppState> {
AdHocPlugin::new().on_init(|mut state| async move {
let config = extract_config()?;
state.insert(config);
Ok(state)
})
}
/// Extract the configuration from env variables prefixed with `{{env_prefix}}_`.
fn extract_config() -> anyhow::Result<AppConfig> {
let config = figment::Figment::new()
.merge(figment::providers::Env::prefixed("{{env_prefix}}_"))
.extract::<AppConfig>()
.context("Failed to extract valid configuration")?;
Ok(config)
}

13
src/lib.rs Normal file
View File

@@ -0,0 +1,13 @@
use axum_app_wrapper::App;
use crate::config::AppConfig;
mod config;
mod state;
pub async fn create_app() -> anyhow::Result<(axum::Router, AppConfig, impl Future + Send)> {
let (router, state, on_shutdown) = App::new().register(config::plugin()).init().await?;
let app_config = state.config.to_owned();
Ok((router.with_state(state), app_config, on_shutdown))
}

70
src/main.rs Normal file
View File

@@ -0,0 +1,70 @@
use std::{net::SocketAddr, str::FromStr};
use {{crate_name}}::create_app;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Read .env file in debug mode
#[cfg(debug_assertions)]
dotenvy::dotenv().ok();
// Build server
let (router, config, on_shutdown) = create_app().await?;
// Configure logging output (use JSON logs in release mode)
let level_filter = LevelFilter::from_str(&config.log_level)?;
let env_filter = tracing_subscriber::EnvFilter::builder()
.with_default_directive(level_filter.into())
.from_env_lossy();
if cfg!(debug_assertions) {
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_max_level(level_filter)
.init();
} else {
tracing_subscriber::registry()
.with(env_filter)
.with(level_filter)
.with(tracing_subscriber::fmt::layer().json().flatten_event(true))
.init();
}
// Start listening for requests
let addr = SocketAddr::new(config.host, config.port);
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("Server listening on http://{}...", listener.local_addr()?);
axum::serve(listener, router.into_make_service())
.with_graceful_shutdown(shutdown_signal(on_shutdown))
.await?;
Ok(())
}
/// Shutdown signal: listens for Ctrl-C, SIGINT, SIGTERM signals
async fn shutdown_signal(on_shutdown: impl Future + Send) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to register Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to register SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Received shutdown signal, shutting down server...");
on_shutdown.await;
}

39
src/state.rs Normal file
View File

@@ -0,0 +1,39 @@
//! Application state
use std::{any::type_name, ops::Deref, sync::Arc};
use type_map::concurrent::TypeMap;
use crate::config::AppConfig;
/// App state stored in the Axum router
#[derive(Clone)]
pub struct AppState(Arc<AppStateInner>);
pub struct AppStateInner {
pub config: AppConfig,
}
impl Deref for AppState {
type Target = AppStateInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl TryFrom<TypeMap> for AppState {
type Error = anyhow::Error;
fn try_from(mut map: TypeMap) -> Result<Self, Self::Error> {
Ok(Self(Arc::new(AppStateInner {
config: extract(&mut map)?,
})))
}
}
fn extract<T: 'static>(type_map: &mut TypeMap) -> anyhow::Result<T> {
type_map
.remove()
.ok_or_else(|| anyhow::anyhow!("Type not found in state: {}", type_name::<T>()))
}