feat(api): axum skeleton with health and OpenAPI (#53)
This commit was merged in pull request #53.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
//! `GET /api/health` — is each of the three upstreams answering.
|
||||
//!
|
||||
//! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without
|
||||
//! Prowlarr nothing is found, without Transmission nothing is fetched, and
|
||||
//! without TMDB nothing is identified. The endpoint always answers `200` —
|
||||
//! the body carries the verdict, so a degraded service can still explain
|
||||
//! itself to the UI instead of looking like a fourth outage.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Whether the service as a whole can do its job.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Health {
|
||||
/// Every upstream answered.
|
||||
Ok,
|
||||
/// At least one upstream is unreachable or unconfigured.
|
||||
Degraded,
|
||||
}
|
||||
|
||||
/// The verdict for a single upstream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
/// Answered as expected.
|
||||
Ok,
|
||||
/// Did not answer, or answered with an unexpected status.
|
||||
Unreachable,
|
||||
/// No API key configured, so it was not probed.
|
||||
Unconfigured,
|
||||
}
|
||||
|
||||
/// One upstream's result.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct Check {
|
||||
pub status: Status,
|
||||
/// Why, when the status is not `ok`. Never contains the probed URL: it
|
||||
/// can carry an API key in the query string.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
status: Status::Ok,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unreachable(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: Status::Unreachable,
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn unconfigured(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: Status::Unconfigured,
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of `GET /api/health`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct HealthReport {
|
||||
pub status: Health,
|
||||
/// The running binary's version.
|
||||
#[schema(example = "0.1.0")]
|
||||
pub version: String,
|
||||
pub prowlarr: Check,
|
||||
pub transmission: Check,
|
||||
pub tmdb: Check,
|
||||
}
|
||||
|
||||
/// Report reachability of Prowlarr, Transmission and TMDB.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/health",
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "Per-upstream reachability", body = HealthReport),
|
||||
),
|
||||
)]
|
||||
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
||||
// Three independent network probes; serialising them would make the
|
||||
// endpoint as slow as the sum of the timeouts.
|
||||
let (prowlarr, transmission, tmdb) = tokio::join!(
|
||||
probe_prowlarr(&state),
|
||||
probe_transmission(&state),
|
||||
probe_tmdb(&state),
|
||||
);
|
||||
|
||||
let status = if [prowlarr.status, transmission.status, tmdb.status]
|
||||
.iter()
|
||||
.all(|s| *s == Status::Ok)
|
||||
{
|
||||
Health::Ok
|
||||
} else {
|
||||
Health::Degraded
|
||||
};
|
||||
|
||||
Json(HealthReport {
|
||||
status,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
prowlarr,
|
||||
transmission,
|
||||
tmdb,
|
||||
})
|
||||
}
|
||||
|
||||
/// Prowlarr answers `/ping` without a key; the key is sent anyway so a
|
||||
/// misconfigured one shows up here rather than at the first search.
|
||||
async fn probe_prowlarr(state: &AppState) -> Check {
|
||||
let url = format!(
|
||||
"{}/ping",
|
||||
state.upstreams().prowlarr_url.trim_end_matches('/')
|
||||
);
|
||||
let mut request = state.http().get(url);
|
||||
if let Some(key) = &state.upstreams().prowlarr_api_key {
|
||||
request = request.header("X-Api-Key", key);
|
||||
}
|
||||
match request.send().await {
|
||||
Ok(response) if response.status().is_success() => Check::ok(),
|
||||
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
|
||||
Err(err) => Check::unreachable(describe(err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transmission answers an RPC call without a session id with `409` plus the
|
||||
/// id to retry with. That is a live daemon, so it counts as reachable.
|
||||
async fn probe_transmission(state: &AppState) -> Check {
|
||||
let request = state
|
||||
.http()
|
||||
.post(&state.upstreams().transmission_url)
|
||||
.json(&serde_json::json!({ "method": "session-get" }));
|
||||
match request.send().await {
|
||||
Ok(response)
|
||||
if response.status().is_success()
|
||||
|| response.status() == reqwest::StatusCode::CONFLICT =>
|
||||
{
|
||||
Check::ok()
|
||||
}
|
||||
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
|
||||
Err(err) => Check::unreachable(describe(err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// TMDB is the only upstream that cannot be probed at all without a key, so
|
||||
/// a missing key is reported as its own state rather than as an outage.
|
||||
async fn probe_tmdb(state: &AppState) -> Check {
|
||||
let Some(key) = &state.upstreams().tmdb_api_key else {
|
||||
return Check::unconfigured("no ARR_TMDB_API_KEY set");
|
||||
};
|
||||
let url = format!(
|
||||
"{}/configuration",
|
||||
state.upstreams().tmdb_url.trim_end_matches('/')
|
||||
);
|
||||
match state
|
||||
.http()
|
||||
.get(url)
|
||||
.query(&[("api_key", key)])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => Check::ok(),
|
||||
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
|
||||
Err(err) => Check::unreachable(describe(err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `reqwest`'s own `Display` includes the URL, and the TMDB URL carries the
|
||||
/// API key. `without_url` is what keeps the key out of the response body.
|
||||
fn describe(err: reqwest::Error) -> String {
|
||||
err.without_url().to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user