tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 08a48938b151fd7006f53cd46550bc82b958ef04
parent 5635b88327826d48db6844ee888fd035b5965552
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 00:44:09 -0700

http: add health endpoints

Diffstat:
MCargo.lock | 43+++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/Cargo.toml | 4++++
Mcrates/tangle_runtime/src/lib.rs | 229++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 275 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -252,10 +252,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -263,10 +266,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -285,6 +293,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3238,6 +3247,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] name = "salsa20" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3357,6 +3372,29 @@ dependencies = [ ] [[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3884,8 +3922,12 @@ dependencies = [ name = "tangle_runtime" version = "0.1.0" dependencies = [ + "axum", + "http", "serde", "serde_json", + "tokio", + "tower", ] [[package]] @@ -4227,6 +4269,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml @@ -8,10 +8,14 @@ license.workspace = true description = "HTTP, WebSocket, and CLI runtime surfaces for tangle" [dependencies] +axum = "0.8" +http = "1" serde = { version = "1", features = ["derive"] } [dev-dependencies] serde_json = "1" +tokio = { version = "1", features = ["macros", "rt"] } +tower = { version = "0.5", features = ["util"] } [lints] workspace = true diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -1,6 +1,13 @@ #![forbid(unsafe_code)] +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, Response}, + routing::get, +}; use core::fmt; +use http::StatusCode; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -111,6 +118,14 @@ impl fmt::Display for ApiError { impl std::error::Error for ApiError {} +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = + StatusCode::from_u16(self.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status, Json(self.envelope())).into_response() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApiErrorEnvelope { pub error: ApiErrorBody, @@ -122,9 +137,122 @@ pub struct ApiErrorBody { pub message: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadinessCheckStatus { + Ready, + NotReady, +} + +impl ReadinessCheckStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::NotReady => "not_ready", + } + } + + pub fn is_ready(self) -> bool { + self == Self::Ready + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadinessState { + pub database: ReadinessCheckStatus, + pub migrations: ReadinessCheckStatus, + pub repository: ReadinessCheckStatus, +} + +impl ReadinessState { + pub fn new( + database: ReadinessCheckStatus, + migrations: ReadinessCheckStatus, + repository: ReadinessCheckStatus, + ) -> Self { + Self { + database, + migrations, + repository, + } + } + + pub fn ready() -> Self { + Self::new( + ReadinessCheckStatus::Ready, + ReadinessCheckStatus::Ready, + ReadinessCheckStatus::Ready, + ) + } + + pub fn is_ready(self) -> bool { + self.database.is_ready() && self.migrations.is_ready() && self.repository.is_ready() + } + + pub fn response(self) -> ReadinessDocument { + ReadinessDocument { + status: if self.is_ready() { + "ready".to_owned() + } else { + "not_ready".to_owned() + }, + checks: ReadinessChecksDocument { + database: self.database.as_str().to_owned(), + migrations: self.migrations.as_str().to_owned(), + repository: self.repository.as_str().to_owned(), + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HealthDocument { + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadinessDocument { + pub status: String, + pub checks: ReadinessChecksDocument, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadinessChecksDocument { + pub database: String, + pub migrations: String, + pub repository: String, +} + +pub fn health_router(readiness: ReadinessState) -> Router { + Router::new() + .route("/healthz", get(healthz)) + .route("/readyz", get(readyz)) + .with_state(readiness) +} + +async fn healthz() -> Json<HealthDocument> { + Json(HealthDocument { + status: "ok".to_owned(), + }) +} + +async fn readyz(State(readiness): State<ReadinessState>) -> (StatusCode, Json<ReadinessDocument>) { + let status = if readiness.is_ready() { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + (status, Json(readiness.response())) +} + #[cfg(test)] mod tests { - use super::{ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope}; + use super::{ + ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, ReadinessCheckStatus, + ReadinessState, health_router, + }; + use axum::{body::Body, response::IntoResponse}; + use http::{Request, StatusCode}; + use tower::ServiceExt; #[test] fn api_error_codes_have_stable_labels_and_statuses() { @@ -191,4 +319,103 @@ mod tests { errors[0].envelope() ); } + + #[tokio::test] + async fn api_error_into_response_keeps_public_envelope_shape() { + let response = ApiError::not_found("listing not found").into_response(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&body).expect("json"), + serde_json::json!({ + "error": { + "code": "not_found", + "message": "listing not found" + } + }) + ); + } + + #[tokio::test] + async fn health_endpoint_reports_liveness() { + let response = health_router(ReadinessState::ready()) + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&body).expect("json"), + serde_json::json!({ "status": "ok" }) + ); + } + + #[tokio::test] + async fn readiness_endpoint_reports_ready_checks() { + let response = health_router(ReadinessState::ready()) + .oneshot( + Request::builder() + .uri("/readyz") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&body).expect("json"), + serde_json::json!({ + "status": "ready", + "checks": { + "database": "ready", + "migrations": "ready", + "repository": "ready" + } + }) + ); + } + + #[tokio::test] + async fn readiness_endpoint_reports_unavailable_checks() { + let response = health_router(ReadinessState::new( + ReadinessCheckStatus::NotReady, + ReadinessCheckStatus::Ready, + ReadinessCheckStatus::NotReady, + )) + .oneshot( + Request::builder() + .uri("/readyz") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&body).expect("json"), + serde_json::json!({ + "status": "not_ready", + "checks": { + "database": "not_ready", + "migrations": "ready", + "repository": "not_ready" + } + }) + ); + } }