Building REST APIs With Light-Axum
light-axum lets a service use normal Axum routing while delegating listener
binding, TLS, config loading, runtime metadata, logging control, and graceful
shutdown to light-runtime.
The application owns the HTTP API shape. The framework owns how the service is started and managed.
Working Examples
The light-example-rs repository contains two REST API demos built with
light-axum:
| Demo | Purpose | Local port | OpenAPI |
|---|---|---|---|
apps/demo-customer-profile-api | Customer profile, preferences, policies, vehicles, and prior claims | 8085 | apps/demo-customer-profile-api/openapi.yaml |
apps/demo-offer-decision-api | Offer search, offer decisions, claim triage, and settlement recommendations | 8086 | apps/demo-offer-decision-api/openapi.yaml |
Both demos follow the same service pattern:
- Define request and response models with
serde. - Build a standard Axum
Router. - Implement
AxumAppand return that router. - Start the app through
LightRuntimeBuilder::new(AxumTransport::new(app)). - Keep runtime config in the app
config/directory. - Publish an OpenAPI document for endpoint import and API management.
Dependencies
A minimal REST API needs these crates:
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
light-axum = { workspace = true }
light-runtime = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
Add serde_json when handlers accept or return dynamic JSON values.
Application Shape
Create a service type and implement AxumApp. The runtime passes a
ServerContext into router. Most simple REST APIs do not need it, but it is
available when routes need runtime metadata.
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use axum::{Json, Router, routing::get};
use light_axum::{AxumApp, ServerContext};
use light_runtime::RuntimeError;
use serde::Serialize;
#[derive(Clone, Default)]
struct CustomerProfileApp;
#[async_trait]
impl AxumApp for CustomerProfileApp {
async fn router(&self, _context: ServerContext) -> Result<Router, RuntimeError> {
Ok(build_router())
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct HealthResponse {
status: &'static str,
service: &'static str,
}
fn build_router() -> Router {
Router::new().route("/health", get(health))
}
async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "UP",
service: "demo-customer-profile-api",
})
}
}
Everything inside build_router is standard Axum. Use Path, Query,
State, Json, HeaderMap, middleware, extractors, and response types the
same way you would in a standalone Axum service.
Runtime Startup
Start the service through LightRuntimeBuilder instead of binding a
TcpListener directly.
use anyhow::{Context, Result};
use light_axum::AxumTransport;
use light_runtime::{LightRuntimeBuilder, TracingOptions, init_tracing};
use tracing::info;
const CONFIG_DIR_ENV: &str = "CUSTOMER_PROFILE_CONFIG_DIR";
const EXTERNAL_CONFIG_DIR_ENV: &str = "CUSTOMER_PROFILE_EXTERNAL_CONFIG_DIR";
const LOG_ANSI_ENV: &str = "CUSTOMER_PROFILE_LOG_ANSI";
const DEFAULT_CONFIG_DIR: &str = "apps/demo-customer-profile-api/config";
const DEFAULT_EXTERNAL_CONFIG_DIR: &str =
"apps/demo-customer-profile-api/config-cache";
#[tokio::main]
async fn main() -> Result<()> {
let tracing_guard = init_tracing(
TracingOptions::new("demo-customer-profile-api")
.with_legacy_ansi_env(LOG_ANSI_ENV),
)
.context("failed to initialize tracing")?;
let config_dir =
std::env::var(CONFIG_DIR_ENV).unwrap_or_else(|_| DEFAULT_CONFIG_DIR.to_string());
let external_config_dir = std::env::var(EXTERNAL_CONFIG_DIR_ENV)
.unwrap_or_else(|_| DEFAULT_EXTERNAL_CONFIG_DIR.to_string());
let runtime = LightRuntimeBuilder::new(AxumTransport::new(CustomerProfileApp))
.with_config_dir(config_dir)
.with_external_config_dir(external_config_dir)
.with_logging_control(tracing_guard.logging_control())
.build();
let running = runtime
.start()
.await
.context("failed to start demo customer profile API")?;
info!("demo customer profile API started");
tokio::signal::ctrl_c()
.await
.context("failed to listen for shutdown signal")?;
running
.shutdown()
.await
.context("failed to shut down demo customer profile API")?;
Ok(())
}
This startup path gives the application the same runtime behavior as other Light services:
- listener configuration comes from
server.yml - local and external config directories are resolved by
light-runtime - TLS is controlled by runtime config, not by route code
- graceful shutdown goes through the runtime handle
- logging can be controlled by the runtime logging control object
Routing Patterns
The customer profile demo shows read-only REST endpoints:
#![allow(unused)]
fn main() {
fn build_router() -> Router {
Router::new()
.route("/health", get(health))
.route("/customers/{customer_id}", get(get_customer))
.route(
"/customers/{customer_id}/preferences",
get(get_customer_preferences),
)
.route(
"/customers/{customer_id}/policies",
get(get_customer_policies),
)
.route(
"/customers/{customer_id}/vehicles/{vehicle_id}",
get(get_covered_vehicle),
)
.route(
"/customers/{customer_id}/prior-claims",
get(get_prior_claims),
)
.with_state(AppState::seeded())
}
}
The offer decision demo shows query parameters, request bodies, headers, and shared mutable state:
#![allow(unused)]
fn main() {
fn build_router() -> Router {
Router::new()
.route("/health", get(health))
.route("/offers", get(search_offers))
.route("/offer-decisions", post(record_offer_decision))
.route("/claim-triage", post(triage_claim))
.route("/settlement-recommendations", post(recommend_settlement))
.with_state(AppState::seeded())
}
}
Use typed handlers for predictable API behavior:
#![allow(unused)]
fn main() {
async fn search_offers(
State(state): State<AppState>,
Query(query): Query<OfferQuery>,
) -> Json<Vec<Offer>> {
Json(state.search_offers(&query))
}
}
For request bodies:
#![allow(unused)]
fn main() {
async fn record_offer_decision(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<OfferDecisionRequest>,
) -> Result<Json<OfferDecisionResponse>, ApiError> {
// validate request and return a typed API response
}
}
Errors
Define one service error type and implement IntoResponse. This keeps handlers
small and ensures failures return stable JSON.
#![allow(unused)]
fn main() {
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ErrorResponse {
code: &'static str,
message: String,
}
#[derive(Debug)]
struct ApiError {
status: StatusCode,
code: &'static str,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorResponse {
code: self.code,
message: self.message,
}),
)
.into_response()
}
}
}
The customer profile API returns 404 with CUSTOMER_NOT_FOUND. The offer
decision API returns 400 with INVALID_DECISION_REQUEST when request content
is invalid.
Configuration
Each application should keep its runtime configuration under its own config/
directory:
apps/<service-name>/
Cargo.toml
openapi.yaml
src/main.rs
config/
client.yml
portal-registry.yml
server.yml
startup.yml
values.yml
server.yml controls the listener and service identity:
ip: ${server.ip:0.0.0.0}
advertisedAddress: ${server.advertisedAddress:127.0.0.1}
httpPort: ${server.httpPort:8085}
enableHttp: ${server.enableHttp:true}
httpsPort: ${server.httpsPort:8443}
enableHttps: ${server.enableHttps:false}
tlsCertPath: ${server.tlsCertPath:}
tlsKeyPath: ${server.tlsKeyPath:}
serviceId: ${server.serviceId:com.networknt.demo.customer-profile-1.0.0}
enableRegistry: ${server.enableRegistry:false}
startOnRegistryFailure: ${server.startOnRegistryFailure:true}
dynamicPort: ${server.dynamicPort:false}
environment: ${server.environment:demo}
shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}
Use a unique server.serviceId per service. The demos use:
com.networknt.demo.customer-profile-1.0.0com.networknt.demo.offer-decision-1.0.0
values.yml supplies defaults for template variables:
server.serviceId: com.networknt.demo.customer-profile-1.0.0
server.environment: demo
server.ip: 0.0.0.0
server.advertisedAddress: 127.0.0.1
server.httpPort: 8085
server.enableHttp: true
server.enableHttps: false
server.enableRegistry: false
server.startOnRegistryFailure: true
Enable registry integration only when the service should register with Portal
or Controller discovery. For local standalone development, keep
server.enableRegistry: false.
OpenAPI
Keep the OpenAPI document beside the service source. The OpenAPI file is the
contract used by API management and workflow tooling, while src/main.rs is the
runtime implementation.
The demo specs are:
light-example-rs/apps/demo-customer-profile-api/openapi.yamllight-example-rs/apps/demo-offer-decision-api/openapi.yaml
When adding or changing a route, update both the Axum router and openapi.yaml.
Use operation IDs that match the business action, such as
getCustomerProfile, searchOffers, or recordOfferDecision.
Running Locally
From the light-example-rs repository:
cargo run -p demo-customer-profile-api
Then verify the health endpoint:
curl http://127.0.0.1:8085/health
Run the offer decision API the same way:
cargo run -p demo-offer-decision-api
curl http://127.0.0.1:8086/health
Override config locations with environment variables when running from a different working directory:
CUSTOMER_PROFILE_CONFIG_DIR=/path/to/config \
CUSTOMER_PROFILE_EXTERNAL_CONFIG_DIR=/path/to/config-cache \
cargo run -p demo-customer-profile-api
Checklist
Use this checklist when creating a new REST API with light-axum:
- create an app crate under
apps/ - add
axum,light-axum,light-runtime,tokio,serde, andasync-trait - define typed request, response, and error models
- build routes with a standard Axum
Router - implement
AxumAppfor the service type - start the service with
LightRuntimeBuilderandAxumTransport - add
config/server.yml,startup.yml,portal-registry.yml,client.yml, andvalues.yml - assign a stable
server.serviceId - add or update
openapi.yaml - verify
/healthand one representative business endpoint locally