feat(api): axum skeleton with health and OpenAPI (#53)
ci / web (push) Successful in 6s
ci / rust (push) Successful in 45s
e2e / e2e (push) Successful in 1m31s

This commit was merged in pull request #53.
This commit is contained in:
2026-08-22 20:09:52 +01:00
parent 03ca4a26e8
commit 58827da647
10 changed files with 910 additions and 16 deletions
+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);
}
}