Initial commit: Axum web service template
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
3
.genignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
.env
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
1359
Cargo.lock
generated
Normal file
1359
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal 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
111
README.md
Normal 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
28
cargo-generate.toml
Normal 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
48
src/config.rs
Normal 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
13
src/lib.rs
Normal 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
70
src/main.rs
Normal 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
39
src/state.rs
Normal 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>()))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user