feat(api): axum skeleton with health and OpenAPI
ci / rust (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 6s
e2e / e2e (pull_request) Successful in 1m17s

The document is collected from `#[utoipa::path]` annotations through
`utoipa_axum::routes!`, which refuses a handler that has none, so an
unannotated route fails the build rather than silently leaving the
TypeScript client short an endpoint.

Health probes the three upstreams DESIGN.md §9.5 calls "Broken", always
answering 200 so a degraded service can still explain itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-22 19:55:31 +01:00
co-authored by Claude Opus 5
parent 1529e9fdca
commit 2de7f88e42
10 changed files with 1626 additions and 33 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
- name: Skip when the frontend does not exist yet
id: probe
run: |
if [ -d web ]; then
if [ -f web/package.json ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
+2
View File
@@ -1,4 +1,6 @@
/target
# Generated by `just gen-client`, never edited and never reviewed.
/web/src/api
/web/node_modules
/web/dist
.env
Generated
+940 -17
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -31,11 +31,13 @@ test:
e2e:
cargo nextest run -p arr-e2e
# Frontend lint and typecheck. No-op until web/ exists (issue #6).
# Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes
# for package.json, not the directory: `just gen-client` writes into web/ and
# must not turn the gate on before there is a project to check.
web-check:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -d web ]; then echo "web/ not present yet, skipping"; exit 0; fi
if [ ! -f web/package.json ]; then echo "web/ not present yet, skipping"; exit 0; fi
biome ci web/
pnpm -C web install --frozen-lockfile
pnpm -C web exec tsc -b --noEmit
@@ -44,12 +46,22 @@ web-check:
build:
#!/usr/bin/env bash
set -euo pipefail
if [ -d web ]; then
if [ -f web/package.json ]; then
pnpm -C web install --frozen-lockfile
pnpm -C web build
fi
cargo build --release
# Regenerate the TypeScript client from the OpenAPI document (DESIGN.md §9.1).
# The document is dumped from the binary, so nothing has to be running. Both
# outputs are generated: never edit them, and never hand-write the spec.
gen-client:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p web/src/api
cargo run --quiet -p arr-daemon --bin arr -- --openapi > web/src/api/openapi.json
pnpm dlx openapi-typescript@7 web/src/api/openapi.json -o web/src/api/schema.d.ts
# Recreate the development database from an empty file.
db-reset:
#!/usr/bin/env bash
+11
View File
@@ -7,6 +7,17 @@ repository.workspace = true
publish = false
[dependencies]
axum = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
utoipa = { workspace = true }
utoipa-axum = { workspace = true }
utoipa-scalar = { workspace = true }
[dev-dependencies]
wiremock = { workspace = true }
[lints]
workspace = true
+183
View File
@@ -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()
}
+269 -1
View File
@@ -1 +1,269 @@
//! arr-api — see DESIGN.md.
//! arr-api — the HTTP surface. See DESIGN.md §9.1.
//!
//! The API is the product; the web UI is one client of it. So the `OpenAPI`
//! document is not written by hand and not kept in step by review: routes are
//! registered through [`utoipa_axum::routes`], which only accepts a handler
//! carrying a `#[utoipa::path]` annotation. A handler added without one fails
//! to compile, and the gate in DESIGN.md §12 fails with it.
mod health;
mod state;
use axum::routing::get;
use axum::{Json, Router};
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use utoipa_scalar::{Scalar, Servable};
pub use health::{Check, Health, HealthReport, Status};
pub use state::{AppState, Upstreams, DEFAULT_TMDB_URL};
/// Where the generated document is served, and where `just gen-client` reads
/// it back from when it is fetched rather than dumped from the binary.
pub const OPENAPI_PATH: &str = "/api/openapi.json";
/// Where the browsable UI lives.
pub const DOCS_PATH: &str = "/api/docs";
/// Document-level metadata. Paths and schemas are collected from the router,
/// never listed here — a list is a thing to forget to update.
#[derive(OpenApi)]
#[openapi(
info(
title = "arr",
description = "One service in place of Radarr and Sonarr. No authentication: \
the perimeter is the VPN (DESIGN.md §2).",
),
tags((name = "system", description = "Service health and metadata")),
)]
struct ApiDoc;
/// Every annotated route, still needing state.
fn api_router() -> OpenApiRouter<AppState> {
OpenApiRouter::with_openapi(ApiDoc::openapi()).routes(routes!(health::health))
}
/// The generated `OpenAPI` document.
#[must_use]
pub fn openapi() -> utoipa::openapi::OpenApi {
api_router().split_for_parts().1
}
/// The whole application: the API, the served document, and the browsable UI.
pub fn router(state: AppState) -> Router {
let (router, api) = api_router().split_for_parts();
let document = api.clone();
router
.route(
OPENAPI_PATH,
get(move || {
let document = document.clone();
async move { Json(document) }
}),
)
.merge(Scalar::with_url(DOCS_PATH, api))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A Prowlarr that answers `/ping`, and a Transmission that answers an
/// RPC call the way a real one does when it has no session id yet.
async fn upstreams_up() -> (MockServer, MockServer) {
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ping"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "OK" })),
)
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/transmission/rpc"))
.respond_with(
ResponseTemplate::new(409).insert_header("X-Transmission-Session-Id", "abc"),
)
.mount(&transmission)
.await;
(prowlarr, transmission)
}
/// Serve the app on an ephemeral port and return its base URL. The server
/// task dies with the runtime at the end of the test.
async fn serve(state: AppState) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral port");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
axum::serve(listener, router(state)).await.expect("serve");
});
format!("http://{addr}")
}
async fn report(state: AppState) -> serde_json::Value {
let base = serve(state).await;
let response = reqwest::get(format!("{base}/api/health"))
.await
.expect("request health");
assert_eq!(response.status(), 200, "health always answers 200");
response.json().await.expect("health body is json")
}
#[tokio::test]
async fn all_upstreams_up_is_ok() {
let (prowlarr, transmission) = upstreams_up().await;
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/configuration"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&tmdb)
.await;
let state = AppState::new(
Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state");
let body = report(state).await;
assert_eq!(body["status"], "ok");
assert_eq!(body["prowlarr"]["status"], "ok");
assert_eq!(body["transmission"]["status"], "ok");
assert_eq!(body["tmdb"]["status"], "ok");
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
}
#[tokio::test]
async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() {
let (prowlarr, transmission) = upstreams_up().await;
let state = AppState::new(Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unconfigured");
assert_eq!(body["status"], "degraded");
}
#[tokio::test]
async fn an_unreachable_upstream_degrades_the_service() {
let (_prowlarr, transmission) = upstreams_up().await;
// Port 1 is privileged and nothing binds it, so the probe gets a
// refused connection immediately instead of waiting out the timeout.
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let body = report(state).await;
assert_eq!(body["status"], "degraded");
assert_eq!(body["prowlarr"]["status"], "unreachable");
assert_eq!(body["transmission"]["status"], "ok");
}
#[tokio::test]
async fn an_upstream_answering_wrongly_is_unreachable() {
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ping"))
.respond_with(ResponseTemplate::new(500))
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
let state = AppState::new(Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let body = report(state).await;
assert_eq!(body["prowlarr"]["status"], "unreachable");
assert_eq!(body["prowlarr"]["detail"], "http 500");
}
#[tokio::test]
async fn a_failed_tmdb_probe_never_echoes_the_api_key() {
let (prowlarr, transmission) = upstreams_up().await;
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/configuration"))
.respond_with(ResponseTemplate::new(401))
.mount(&tmdb)
.await;
let state = AppState::new(
Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("super-secret".into())),
)
.expect("state");
let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unreachable");
assert!(
!body.to_string().contains("super-secret"),
"the key must not reach the response body: {body}"
);
}
#[test]
fn the_document_is_generated_from_the_handler() {
let document = openapi();
let json = serde_json::to_value(&document).expect("serialise document");
assert!(
json["paths"]["/api/health"]["get"].is_object(),
"the health route registered itself: {json}"
);
assert_eq!(json["paths"]["/api/health"]["get"]["tags"][0], "system");
assert!(
json["components"]["schemas"]["HealthReport"].is_object(),
"the response body schema came along with it: {json}"
);
}
#[tokio::test]
async fn the_document_and_the_ui_are_served() {
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state");
let base = serve(state).await;
let document: serde_json::Value = reqwest::get(format!("{base}{OPENAPI_PATH}"))
.await
.expect("fetch document")
.json()
.await
.expect("document is json");
assert!(document["paths"]["/api/health"].is_object());
let docs = reqwest::get(format!("{base}{DOCS_PATH}"))
.await
.expect("fetch docs");
assert_eq!(docs.status(), 200);
}
}
+89
View File
@@ -0,0 +1,89 @@
//! What the API needs to answer a request: one HTTP client and the addresses
//! of the three upstreams the service cannot work without (DESIGN.md §3).
use std::sync::Arc;
use std::time::Duration;
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
/// is configurable, so this is a constant that tests point elsewhere.
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
/// How long an upstream has to answer a probe before it counts as
/// unreachable. Health is polled by a human waiting on a page.
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// Where the upstreams live, and the keys for the two that need one.
#[derive(Debug, Clone)]
pub struct Upstreams {
pub prowlarr_url: String,
pub prowlarr_api_key: Option<String>,
pub transmission_url: String,
pub tmdb_url: String,
pub tmdb_api_key: Option<String>,
}
impl Upstreams {
/// Every upstream at its documented default, no keys.
#[must_use]
pub fn new(prowlarr_url: String, transmission_url: String) -> Self {
Self {
prowlarr_url,
prowlarr_api_key: None,
transmission_url,
tmdb_url: DEFAULT_TMDB_URL.to_string(),
tmdb_api_key: None,
}
}
/// Set the Prowlarr API key.
#[must_use]
pub fn with_prowlarr_api_key(mut self, key: Option<String>) -> Self {
self.prowlarr_api_key = key;
self
}
/// Set the TMDB API key.
#[must_use]
pub fn with_tmdb_api_key(mut self, key: Option<String>) -> Self {
self.tmdb_api_key = key;
self
}
/// Point TMDB somewhere other than the real API. Tests only.
#[must_use]
pub fn with_tmdb_url(mut self, url: String) -> Self {
self.tmdb_url = url;
self
}
}
/// Shared handler state. Cheap to clone: the client pools internally and the
/// upstream addresses are behind an [`Arc`].
#[derive(Debug, Clone)]
pub struct AppState {
http: reqwest::Client,
upstreams: Arc<Upstreams>,
}
impl AppState {
/// Build the state, including the shared HTTP client.
///
/// # Errors
///
/// If the TLS backend cannot be initialised.
pub fn new(upstreams: Upstreams) -> Result<Self, reqwest::Error> {
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
Ok(Self {
http,
upstreams: Arc::new(upstreams),
})
}
pub(crate) fn http(&self) -> &reqwest::Client {
&self.http
}
pub(crate) fn upstreams(&self) -> &Upstreams {
&self.upstreams
}
}
+11 -4
View File
@@ -11,12 +11,19 @@ name = "arr"
path = "src/main.rs"
[dependencies]
serde.workspace = true
thiserror.workspace = true
toml.workspace = true
arr-api = { workspace = true }
axum = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
tempfile.workspace = true
tempfile = { workspace = true }
[lints]
workspace = true
+105 -7
View File
@@ -4,15 +4,113 @@ mod config;
use std::process::ExitCode;
fn main() -> ExitCode {
match config::Config::load() {
Ok(config) => {
println!("arr starting, bind_addr={}", config.bind_addr);
ExitCode::SUCCESS
}
use arr_api::{AppState, Upstreams};
use config::Config;
use tower_http::trace::TraceLayer;
/// Dump the `OpenAPI` document and exit, instead of serving. `just gen-client`
/// uses this so the TypeScript client can be regenerated without a port or a
/// single upstream being up.
const OPENAPI_FLAG: &str = "--openapi";
#[tokio::main]
async fn main() -> ExitCode {
if std::env::args().nth(1).as_deref() == Some(OPENAPI_FLAG) {
return dump_openapi();
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,tower_http=debug".into()),
)
.init();
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("arr: {err}");
tracing::error!("{err}");
ExitCode::FAILURE
}
}
}
fn dump_openapi() -> ExitCode {
match arr_api::openapi().to_pretty_json() {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("arr: openapi: {err}");
ExitCode::FAILURE
}
}
}
#[derive(Debug, thiserror::Error)]
enum Error {
#[error("config: {0}")]
Config(#[from] config::ConfigError),
#[error("http client: {0}")]
HttpClient(#[from] reqwest::Error),
#[error("bind {addr}: {source}")]
Bind {
addr: std::net::SocketAddr,
source: std::io::Error,
},
#[error("serve: {0}")]
Serve(std::io::Error),
}
async fn run() -> Result<(), Error> {
let config = Config::load()?;
let state = AppState::new(
Upstreams::new(config.prowlarr_url, config.transmission_url)
.with_prowlarr_api_key(config.prowlarr_api_key)
.with_tmdb_api_key(config.tmdb_api_key),
)?;
let app = arr_api::router(state).layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(config.bind_addr)
.await
.map_err(|source| Error::Bind {
addr: config.bind_addr,
source,
})?;
tracing::info!(addr = %config.bind_addr, docs = arr_api::DOCS_PATH, "listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown())
.await
.map_err(Error::Serve)
}
/// Stop accepting on Ctrl-C, or on the SIGTERM a service manager sends.
async fn shutdown() {
let interrupt = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut signal) => {
signal.recv().await;
}
Err(err) => tracing::warn!("no SIGTERM handler: {err}"),
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = interrupt => {},
() = terminate => {},
}
tracing::info!("shutting down");
}