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(service: Arc<Service>) -> Router {
let mut router = Router::new().route("/health", get(health_handler));
if service.http_command_routes_enabled() {
router = router.route("/{command}", axum::routing::post(command_handler));
}
#[cfg(feature = "metrics")]
{
router = router.route("/metrics", get(metrics_handler));
}
#[cfg(feature = "graphql")]
{
if service.graphql_engine().is_some() {
router = router
.route(
"/graphql",
axum::routing::post(crate::graphql::http::microsvc_graphql_handler)
.get(crate::graphql::http::microsvc_graphql_get),
)
.route(
"/graphql/ws",
axum::routing::get(crate::graphql::http::microsvc_graphql_ws),
);
}
}
router
.layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
.with_state(service)
}
pub async fn serve(service: Arc<Service>, 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(State(service): State<Arc<Service>>) -> impl IntoResponse {
let commands: Vec<&str> = service.command_names();
#[cfg(feature = "graphql")]
let body = {
let mut v = json!({ "ok": true, "commands": commands });
if service.graphql_engine().is_some() {
v.as_object_mut()
.unwrap()
.insert("graphql".into(), json!(true));
}
v
};
#[cfg(not(feature = "graphql"))]
let body = json!({ "ok": true, "commands": commands });
Json(body)
}
#[cfg(feature = "metrics")]
async fn metrics_handler(State(service): State<Arc<Service>>) -> impl IntoResponse {
crate::metrics::prometheus_response(service.name())
}
async fn command_handler(
State(service): State<Arc<Service>>,
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::Projection(_)
| HandlerError::UnqualifiedProjectionDelivery(_)
| HandlerError::ProjectionRepairPending { .. }
| HandlerError::ProjectionTerminalRecorded { .. }
| HandlerError::ProjectionDeliveryHalted { .. }
| HandlerError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub(crate) 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");
}
}
}