//! 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 { 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); } }