feat(api): list roots with policy names

The §9.2 add flow pre-fills root and policy; without this endpoint the
SPA has no way to know either. No issue covers it separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-22 21:48:27 +01:00
co-authored by Claude Fable 5
parent 5a1cf999b6
commit 6d5c90ac08
3 changed files with 134 additions and 1 deletions
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.kind AS \"kind!: String\", r.audience AS \"audience!: String\", r.path AS \"path!: String\", p.id AS \"policy_id!: i64\", p.name AS \"policy_name!: String\" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "kind!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "audience!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "policy_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "policy_name!: String",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf"
}
+5 -1
View File
@@ -8,6 +8,7 @@
mod health;
mod movies;
mod roots;
mod search;
mod state;
@@ -20,6 +21,7 @@ use utoipa_scalar::{Scalar, Servable};
pub use health::{Check, Health, HealthReport, Status};
pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie};
pub use roots::Root;
pub use search::{ClassifiedRelease, SearchResponse};
pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
@@ -42,7 +44,8 @@ pub const DOCS_PATH: &str = "/api/docs";
tags(
(name = "system", description = "Service health and metadata"),
(name = "movies", description = "Movie library and actions"),
(name = "search", description = "Unified title and release search")
(name = "search", description = "Unified title and release search"),
(name = "roots", description = "Root folders and their policies")
),
)]
struct ApiDoc;
@@ -59,6 +62,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(movies::attention))
.routes(routes!(search::search))
.routes(routes!(search::releases))
.routes(routes!(roots::list))
}
/// The generated `OpenAPI` document.
+79
View File
@@ -0,0 +1,79 @@
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use crate::movies::{ApiError, ErrorBody};
use crate::state::AppState;
/// A root folder joined with its policy, enough for the add flow to pre-fill
/// root and policy (DESIGN.md §9.2) without a second request.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct Root {
pub id: i64,
pub kind: String,
pub audience: String,
pub path: String,
pub policy_id: i64,
pub policy_name: String,
}
#[utoipa::path(
get, path = "/api/roots", tag = "roots",
responses(
(status = 200, body = [Root]),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Root>>, ApiError> {
let database = state.database().ok_or(ApiError::Unavailable)?;
let roots = sqlx::query_as!(
Root,
r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"#
)
.fetch_all(database.pool())
.await?;
Ok(Json(roots))
}
#[cfg(test)]
mod tests {
use crate::state::Upstreams;
use crate::{router, AppState};
#[tokio::test]
async fn roots_carry_their_policy_name() {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("database");
database.migrate().await.expect("migrate");
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
let roots: Vec<serde_json::Value> = reqwest::get(format!("http://{address}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("json");
assert_eq!(roots.len(), 2, "the two seeded movie roots: {roots:?}");
assert_eq!(roots[0]["audience"], "main");
assert_eq!(roots[0]["policy_name"], "Movies — main");
assert_eq!(roots[1]["audience"], "kids");
assert!(roots[1]["path"]
.as_str()
.is_some_and(|p| p.contains("kids")));
}
}