feat(api): axum skeleton with health and OpenAPI (#53)
This commit was merged in pull request #53.
This commit is contained in:
+269
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user