9fa1f0cc51
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).
499 lines
17 KiB
Rust
499 lines
17 KiB
Rust
//! 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::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
|
|
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
|
|
/// 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,
|
|
}
|
|
|
|
/// 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(
|
|
(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 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(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::{router, AppState, Upstreams};
|
|
use axum::http::StatusCode;
|
|
|
|
async fn application() -> (tempfile::TempDir, String) {
|
|
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") });
|
|
(dir, format!("http://{address}"))
|
|
}
|
|
|
|
#[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()
|
|
.await
|
|
.expect("json");
|
|
assert_eq!(roots.len(), 4, "the seeded movie and TV 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")));
|
|
assert_eq!(roots[2]["kind"], "tv");
|
|
assert_eq!(roots[2]["policy_name"], "TV — main");
|
|
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);
|
|
}
|
|
}
|