use std::sync::Arc;
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use super::error::HandlerError;
use super::service::Service;
use super::session::Session;
use super::MAX_HTTP_BODY_BYTES;
pub fn router<D: Send + Sync + 'static>(service: Arc<Service<D>>) -> Router {
Router::new()
.route("/health", get(health_handler))
.route("/{command}", axum::routing::post(command_handler))
.layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
.with_state(service)
}
pub async fn serve<D: Send + Sync + 'static>(
service: Arc<Service<D>>,
addr: &str,
) -> Result<(), std::io::Error> {
let app = router(service);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await
}
async fn health_handler<D: Send + Sync + 'static>(
State(service): State<Arc<Service<D>>>,
) -> impl IntoResponse {
let commands: Vec<&str> = service.command_names();
Json(json!({ "ok": true, "commands": commands }))
}
async fn command_handler<D: Send + Sync + 'static>(
State(service): State<Arc<Service<D>>>,
Path(command): Path<String>,
headers: HeaderMap,
Json(input): Json<Value>,
) -> impl IntoResponse {
let session = session_from_headers(&headers);
match service.dispatch(&command, input, session).await {
Ok(value) => (StatusCode::OK, Json(value)).into_response(),
Err(err) => {
let status = status_for_error(&err);
if status.is_server_error() {
eprintln!("microsvc command `{command}` failed: {err}");
}
let body = json!({ "error": err.client_facing_message() });
(status, Json(body)).into_response()
}
}
}
fn status_for_error(error: &HandlerError) -> StatusCode {
match error {
HandlerError::UnknownCommand(_) | HandlerError::NotFound(_) => StatusCode::NOT_FOUND,
HandlerError::DecodeFailed(_) | HandlerError::GuardRejected(_) => StatusCode::BAD_REQUEST,
HandlerError::Rejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
HandlerError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
HandlerError::Repository(_) | HandlerError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn session_from_headers(headers: &HeaderMap) -> Session {
let mut vars = std::collections::HashMap::new();
for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() {
vars.insert(name.as_str().to_string(), v.to_string());
}
}
Session::from_map(vars)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::RepositoryError;
#[test]
fn status_for_error_maps_all_handler_errors() {
let cases = vec![
(
HandlerError::UnknownCommand("missing".into()),
StatusCode::NOT_FOUND,
),
(
HandlerError::DecodeFailed("bad json".into()),
StatusCode::BAD_REQUEST,
),
(
HandlerError::Rejected("invalid command".into()),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
HandlerError::NotFound("counter-1".into()),
StatusCode::NOT_FOUND,
),
(
HandlerError::Unauthorized("missing user".into()),
StatusCode::UNAUTHORIZED,
),
(
HandlerError::Repository(RepositoryError::Model("store failed".into())),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
HandlerError::GuardRejected("counter.create".into()),
StatusCode::BAD_REQUEST,
),
(
HandlerError::Other(Box::new(std::io::Error::other("handler failed"))),
StatusCode::INTERNAL_SERVER_ERROR,
),
];
for (error, expected) in cases {
let status = status_for_error(&error);
assert_eq!(status, expected);
assert_eq!(status.as_u16(), error.status_code());
assert!(!status.is_success());
}
}
#[test]
fn client_facing_message_preserves_client_errors() {
let error = HandlerError::Rejected("invalid command".into());
assert_eq!(error.client_facing_message(), "rejected: invalid command");
}
#[test]
fn client_facing_message_hides_server_errors() {
let errors = [
HandlerError::Repository(RepositoryError::Model("store failed".into())),
HandlerError::Other(Box::new(std::io::Error::other("handler failed"))),
];
for error in errors {
assert_eq!(error.client_facing_message(), "Internal server error");
}
}
}