Files
arr/crates/arr-api/src/lib.rs
T
naps62-yolo bf526b9198
ci / rust (push) Failing after 1m26s
ci / web (push) Successful in 29s
e2e / e2e (push) Successful in 1m55s
Unified search box (#30) (#75)
2026-08-22 22:08:32 +01:00

327 lines
11 KiB
Rust

//! 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 movies;
mod owners;
mod roots;
mod search;
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 movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie};
pub use owners::{CreateOwner, Owner, UpdateOwner};
pub use roots::Root;
pub use search::{ClassifiedRelease, SearchResponse};
pub use state::{AppState, MovieCommand, 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"),
(name = "movies", description = "Movie library and actions"),
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
(name = "search", description = "Unified title and release search"),
(name = "roots", description = "Root folders and their policies")
),
)]
struct ApiDoc;
/// Every annotated route, still needing state.
fn api_router() -> OpenApiRouter<AppState> {
OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(health::health))
.routes(routes!(movies::list, movies::create))
.routes(routes!(movies::get, movies::update, movies::delete))
.routes(routes!(movies::search))
.routes(routes!(movies::releases))
.routes(routes!(movies::grab))
.routes(routes!(movies::attention))
.routes(routes!(movies::list_owners))
.routes(routes!(movies::tag_owner, movies::untag_owner))
.routes(routes!(owners::list, owners::create))
.routes(routes!(owners::get, owners::update, owners::delete))
.routes(routes!(search::search))
.routes(routes!(search::releases))
.routes(routes!(roots::list))
}
/// 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}"
);
for (path, method) in [
("/api/movies", "get"),
("/api/movies", "post"),
("/api/movies/{movie_id}", "get"),
("/api/movies/{movie_id}", "patch"),
("/api/movies/{movie_id}", "delete"),
("/api/movies/{movie_id}/search", "post"),
("/api/movies/{movie_id}/releases", "get"),
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
("/api/queues/attention", "get"),
] {
assert!(
json["paths"][path][method].is_object(),
"missing {method} {path}"
);
}
for schema in [
"Movie",
"CreateMovie",
"UpdateMovie",
"Release",
"AttentionQueues",
] {
assert!(
json["components"]["schemas"][schema].is_object(),
"{schema}"
);
}
}
#[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);
}
}