feat(api): add health probes, OpenAPI doc, and router
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Liveness payload: the process is running.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct Live {
|
||||||
|
/// Always `"ok"` when the process serves requests.
|
||||||
|
pub status: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Readiness payload: dependencies were checked.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct Ready {
|
||||||
|
/// `"ok"` when ready, `"degraded"` otherwise.
|
||||||
|
pub status: &'static str,
|
||||||
|
/// Whether the database responded to a ping.
|
||||||
|
pub database: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liveness probe — no dependencies checked.
|
||||||
|
#[utoipa::path(get, path = "/health/live", responses((status = 200, body = Live)))]
|
||||||
|
pub(crate) async fn live() -> Json<Live> {
|
||||||
|
Json(Live { status: "ok" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Readiness probe — confirms the database answers.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/health/ready",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = Ready, description = "Ready"),
|
||||||
|
(status = 503, body = Ready, description = "A dependency is unavailable")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn ready(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
match state.db.ping().await {
|
||||||
|
Ok(()) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(Ready {
|
||||||
|
status: "ok",
|
||||||
|
database: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(_) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(Ready {
|
||||||
|
status: "degraded",
|
||||||
|
database: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health routes, parameterized over [`AppState`].
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/health/live", get(live))
|
||||||
|
.route("/health/ready", get(ready))
|
||||||
|
}
|
||||||
@@ -1 +1,24 @@
|
|||||||
//! HTTP API: router, handlers, and OpenAPI document.
|
//! HTTP API: router, handlers, and OpenAPI document.
|
||||||
|
|
||||||
|
mod health;
|
||||||
|
mod openapi;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use db::Db;
|
||||||
|
|
||||||
|
/// Shared application state passed to handlers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
/// Database handle for this organization.
|
||||||
|
pub db: Db,
|
||||||
|
/// User-facing product name (from config). Never hardcoded.
|
||||||
|
pub app_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the application router from shared state.
|
||||||
|
pub fn build_app(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.merge(health::routes())
|
||||||
|
.merge(openapi::routes())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use crate::{AppState, health};
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(health::live, health::ready),
|
||||||
|
components(schemas(health::Live, health::Ready)),
|
||||||
|
info(title = "Collection Management System", version = "0.0.0")
|
||||||
|
)]
|
||||||
|
struct ApiDoc;
|
||||||
|
|
||||||
|
/// Serve the OpenAPI document, overriding the title from runtime config so the
|
||||||
|
/// product name is never hardcoded.
|
||||||
|
async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
|
||||||
|
let mut doc = ApiDoc::openapi();
|
||||||
|
doc.info.title = state.app_name.clone();
|
||||||
|
Json(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAPI routes, parameterized over [`AppState`].
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/api-docs/openapi.json", get(openapi_json))
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use api::{AppState, build_app};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt; // for `oneshot`
|
||||||
|
|
||||||
|
fn state(pool: PgPool, app_name: &str) -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: db::Db::from_pool(pool),
|
||||||
|
app_name: app_name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn live_returns_ok(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool, "Test"));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/health/live")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(json["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn ready_reports_database_true(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool, "Test"));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/health/ready")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(json["database"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn openapi_doc_uses_configured_title(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool, "My Museum CMS"));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api-docs/openapi.json")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(json["info"]["title"], "My Museum CMS");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user