feat(api): policy and root CRUD endpoints

Policies and roots were editable only via SQL inside the container.
Full CRUD over both, validated against the vocabulary the policy
engine knows — unknown resolutions or sources answer 422 naming the
field, malformed JSON answers 422, deleting a referenced policy or an
occupied root answers 409. Closes #116 (API half).
This commit is contained in:
Miguel Palhas
2026-08-23 14:56:51 +01:00
parent 951669749b
commit 9fa1f0cc51
17 changed files with 1450 additions and 12 deletions
+425 -10
View File
@@ -1,9 +1,15 @@
use axum::extract::State;
//! Root folders (`DESIGN.md` §5.1): kind, audience, path and the one policy
//! attached. Reads serve the add flow (§9.2); writes are the settings view's.
use axum::extract::rejection::JsonRejection;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::movies::{ApiError, ErrorBody};
use crate::movies::{pool, ApiError, ErrorBody};
use crate::policies::parsed;
use crate::state::AppState;
/// A root folder joined with its policy, enough for the add flow to pre-fill
@@ -18,6 +24,67 @@ pub struct Root {
pub policy_name: String,
}
/// The payload for creating or replacing a root.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct RootInput {
/// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4).
pub kind: String,
/// `main` or `kids`.
pub audience: String,
pub path: String,
pub policy_id: i64,
}
impl RootInput {
fn validate(&self) -> Result<(), String> {
if self.kind != "movie" && self.kind != "tv" {
return Err(format!("kind: '{}' is neither movie nor tv", self.kind));
}
if self.audience != "main" && self.audience != "kids" {
return Err(format!(
"audience: '{}' is neither main nor kids",
self.audience
));
}
if self.path.trim().is_empty() {
return Err("path: must not be empty".into());
}
Ok(())
}
async fn policy_exists(&self, state: &AppState) -> Result<(), ApiError> {
let exists: Option<i64> =
sqlx::query_scalar!("SELECT id FROM policies WHERE id = ?", self.policy_id)
.fetch_optional(pool(state)?)
.await?;
if exists.is_none() {
return Err(ApiError::Invalid(format!(
"policy_id: no policy {}",
self.policy_id
)));
}
Ok(())
}
}
async fn load_root(state: &AppState, id: i64) -> Result<Root, ApiError> {
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
WHERE r.id = ?"#,
id
)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::RootNotFound)
}
#[utoipa::path(
get, path = "/api/roots", tag = "roots",
responses(
@@ -27,23 +94,171 @@ pub struct Root {
)
)]
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())
.fetch_all(pool(&state)?)
.await?;
Ok(Json(roots))
}
#[utoipa::path(
get, path = "/api/roots/{root_id}", tag = "roots",
params(("root_id" = i64, Path, description = "Root row id")),
responses(
(status = 200, body = Root),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn get(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Root>, ApiError> {
Ok(Json(load_root(&state, id).await?))
}
#[utoipa::path(
post, path = "/api/roots", tag = "roots", request_body = RootInput,
responses(
(status = 201, body = Root),
(status = 409, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn create(
State(state): State<AppState>,
body: Result<Json<RootInput>, JsonRejection>,
) -> Result<(StatusCode, Json<Root>), ApiError> {
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?;
let path = input.path.trim().to_owned();
let result = sqlx::query!(
"INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)",
input.kind,
input.audience,
path,
input.policy_id,
)
.execute(pool(&state)?)
.await
.map_err(root_conflict)?;
let root = load_root(&state, result.last_insert_rowid()).await?;
Ok((StatusCode::CREATED, Json(root)))
}
#[utoipa::path(
put, path = "/api/roots/{root_id}", tag = "roots", request_body = RootInput,
params(("root_id" = i64, Path, description = "Root row id")),
responses(
(status = 200, body = Root),
(status = 404, body = ErrorBody),
(status = 409, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
body: Result<Json<RootInput>, JsonRejection>,
) -> Result<Json<Root>, ApiError> {
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?;
let path = input.path.trim().to_owned();
let result = sqlx::query!(
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?"#,
input.kind,
input.audience,
path,
input.policy_id,
id,
)
.execute(pool(&state)?)
.await
.map_err(root_conflict)?;
if result.rows_affected() == 0 {
return Err(ApiError::RootNotFound);
}
Ok(Json(load_root(&state, id).await?))
}
/// A duplicate path or a duplicate (kind, audience) pair is a settings
/// mistake the operator can fix, not a server fault.
fn root_conflict(error: sqlx::Error) -> ApiError {
if error
.as_database_error()
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
{
return ApiError::Conflict(
"a root with this path or this kind and audience already exists".into(),
);
}
ApiError::from(error)
}
#[utoipa::path(
delete, path = "/api/roots/{root_id}", tag = "roots",
params(("root_id" = i64, Path, description = "Root row id")),
responses(
(status = 204),
(status = 404, body = ErrorBody),
(status = 409, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
let movies: i64 = sqlx::query_scalar!(
r#"SELECT count(*) AS "count!: i64" FROM movies WHERE root_id = ?"#,
id
)
.fetch_one(pool(&state)?)
.await?;
if movies > 0 {
return Err(ApiError::Conflict(format!(
"this root still owns {movies} movie{} — move them first",
if movies == 1 { "" } else { "s" }
)));
}
let series: i64 = sqlx::query_scalar!(
r#"SELECT count(*) AS "count!: i64" FROM series WHERE root_id = ?"#,
id
)
.fetch_one(pool(&state)?)
.await?;
if series > 0 {
return Err(ApiError::Conflict(format!(
"this root still owns {series} series — move them first"
)));
}
let result = sqlx::query!("DELETE FROM roots WHERE id = ?", id)
.execute(pool(&state)?)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::RootNotFound);
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use crate::state::Upstreams;
use crate::{router, AppState};
use crate::{router, AppState, Upstreams};
use axum::http::StatusCode;
#[tokio::test]
async fn roots_carry_their_policy_name() {
async fn application() -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
@@ -61,8 +276,13 @@ mod tests {
let address = listener.local_addr().expect("address");
let app = router(state);
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"))
}
let roots: Vec<serde_json::Value> = reqwest::get(format!("http://{address}/api/roots"))
#[tokio::test]
async fn roots_carry_their_policy_name() {
let (_dir, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
@@ -80,4 +300,199 @@ mod tests {
assert_eq!(roots[3]["kind"], "tv");
assert_eq!(roots[3]["policy_name"], "TV — kids");
}
fn root_input(policy_id: i64) -> serde_json::Value {
serde_json::json!({
"kind": "movie",
"audience": "archive",
"path": "/mnt/media/movies/archive",
"policy_id": policy_id,
})
}
async fn first_policy_ids(base: &str) -> Vec<i64> {
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
.await
.expect("policies")
.json()
.await
.expect("policies json");
policies
.into_iter()
.map(|policy| policy["id"].as_i64().expect("policy id"))
.collect()
}
#[tokio::test]
async fn a_root_round_trips_through_create_and_update() {
let (_dir, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("roots json");
// the schema only knows movie and tv, main and kids
let rejected = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&root_input(policy_ids[0]))
.send()
.await
.expect("create root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
// (movie, kids) is taken by a seeded root, so free the pair first
let kids_root = roots[1]["id"].as_i64().expect("movie kids root id");
let freed = reqwest::Client::new()
.delete(format!("{base}/api/roots/{kids_root}"))
.send()
.await
.expect("free the seeded movie kids root");
assert_eq!(freed.status(), StatusCode::NO_CONTENT);
let mut payload = root_input(policy_ids[0]);
payload["audience"] = serde_json::json!("kids");
payload["path"] = serde_json::json!("/mnt/media/movies/archive");
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&payload)
.send()
.await
.expect("create root")
.json()
.await
.expect("created json");
assert_eq!(created["audience"], "kids");
assert_eq!(created["policy_name"], "Movies — main");
let id = created["id"].as_i64().expect("id");
payload["path"] = serde_json::json!("/mnt/media/movies/archive-4k");
payload["policy_id"] = policy_ids[1].into();
let updated: serde_json::Value = reqwest::Client::new()
.put(format!("{base}/api/roots/{id}"))
.json(&payload)
.send()
.await
.expect("update root")
.json()
.await
.expect("updated json");
assert_eq!(updated["path"], "/mnt/media/movies/archive-4k");
assert_eq!(updated["policy_name"], "Movies — kids");
let deleted = reqwest::Client::new()
.delete(format!("{base}/api/roots/{id}"))
.send()
.await
.expect("delete root");
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert_eq!(
reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("get deleted")
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
let (_dir, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
for (mut payload, field) in [
(root_input(999), "policy_id"),
(root_input(policy_ids[0]), "kind"),
] {
payload["audience"] = serde_json::json!("main");
if field == "kind" {
payload["kind"] = serde_json::json!("book");
payload["path"] = serde_json::json!("/mnt/media/movies/elsewhere");
}
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&payload)
.send()
.await
.expect("create root");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"].as_str().expect("text").starts_with(field),
"{field}: {body}"
);
}
}
#[tokio::test]
async fn a_duplicate_path_or_pair_conflicts() {
let (_dir, base) = application().await;
let mut path = root_input(1);
path["audience"] = serde_json::json!("kids");
path["path"] = serde_json::json!("/mnt/media/movies/kids");
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&path)
.send()
.await
.expect("duplicate path create");
assert_eq!(response.status(), StatusCode::CONFLICT);
let mut pair = root_input(1);
pair["audience"] = serde_json::json!("main");
pair["path"] = serde_json::json!("/mnt/media/movies/somewhere-new");
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&pair)
.send()
.await
.expect("duplicate pair create");
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn a_root_with_titles_refuses_to_die() {
let (_dir, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("roots json");
let main_root = roots[0]["id"].as_i64().expect("root id");
let created = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": main_root,
}))
.send()
.await
.expect("create movie");
assert_eq!(created.status(), StatusCode::CREATED);
let response = reqwest::Client::new()
.delete(format!("{base}/api/roots/{main_root}"))
.send()
.await
.expect("delete occupied root");
assert_eq!(response.status(), StatusCode::CONFLICT);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"].as_str().expect("text").contains("movie"),
"{body}"
);
// an empty root deletes fine
let empty_root = roots[3]["id"].as_i64().expect("tv kids root id");
let response = reqwest::Client::new()
.delete(format!("{base}/api/roots/{empty_root}"))
.send()
.await
.expect("delete empty root");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
}