feat(api): owner tags and filtered movie views (#70)
This commit was merged in pull request #70.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::owners::Owner;
|
||||
use crate::state::{AppState, MovieCommand};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -84,6 +85,7 @@ pub struct ErrorBody {
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
NotFound,
|
||||
OwnerNotFound,
|
||||
Conflict(String),
|
||||
Invalid(String),
|
||||
Unavailable,
|
||||
@@ -94,6 +96,7 @@ impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error) = match self {
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
|
||||
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||
Self::Unavailable => (
|
||||
@@ -150,7 +153,7 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> {
|
||||
pub(crate) fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> {
|
||||
state
|
||||
.database()
|
||||
.map(arr_db::Db::pool)
|
||||
@@ -163,18 +166,34 @@ async fn load_movie(state: &AppState, id: i64) -> Result<Movie, ApiError> {
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ListMoviesQuery {
|
||||
/// Restrict to movies tagged with this owner (DESIGN.md §4.3).
|
||||
pub owner_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies", tag = "movies",
|
||||
params(ListMoviesQuery),
|
||||
responses(
|
||||
(status = 200, body = [Movie]),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Movie>>, ApiError> {
|
||||
let movies = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at FROM movies ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListMoviesQuery>,
|
||||
) -> Result<Json<Vec<Movie>>, ApiError> {
|
||||
let movies = if let Some(owner_id) = query.owner_id {
|
||||
sqlx::query_as!(Movie, r#"SELECT m.id AS "id!: i64", m.tmdb_id AS "tmdb_id!: i64", m.title AS "title!: String", m.year, m.original_language, m.root_id AS "root_id!: i64", m.wanted AS "wanted!: bool", m.overrides AS "overrides!: serde_json::Value", m.state AS "state!: String", m.blocked AS "blocked!: bool", m.search_attempts AS "search_attempts!: i64", m.last_searched_at FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id"#, owner_id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at FROM movies ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
};
|
||||
Ok(Json(movies))
|
||||
}
|
||||
|
||||
@@ -407,6 +426,93 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}/owners", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
responses(
|
||||
(status = 200, body = [Owner]),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn list_owners(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<Owner>>, ApiError> {
|
||||
load_movie(&state, id).await?;
|
||||
let owners = sqlx::query_as!(
|
||||
Owner,
|
||||
r#"SELECT o.id AS "id!: i64", o.name AS "name!: String", o.ntfy_topic AS "ntfy_topic!: String"
|
||||
FROM owners o
|
||||
JOIN title_owners t ON t.owner_id = o.id
|
||||
WHERE t.title_kind = 'movie' AND t.title_id = ?
|
||||
ORDER BY o.name"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
Ok(Json(owners))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/movies/{movie_id}/owners/{owner_id}", tag = "movies",
|
||||
params(("movie_id" = i64, Path), ("owner_id" = i64, Path)),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn tag_owner(
|
||||
State(state): State<AppState>,
|
||||
Path((movie_id, owner_id)): Path<(i64, i64)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
load_movie(&state, movie_id).await?;
|
||||
let owner_exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM owners WHERE id = ?) AS 'exists!: bool'",
|
||||
owner_id
|
||||
)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
if !owner_exists {
|
||||
return Err(ApiError::OwnerNotFound);
|
||||
}
|
||||
sqlx::query!(
|
||||
"INSERT INTO title_owners (title_kind, title_id, owner_id) VALUES ('movie', ?, ?) ON CONFLICT DO NOTHING",
|
||||
movie_id, owner_id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/movies/{movie_id}/owners/{owner_id}", tag = "movies",
|
||||
params(("movie_id" = i64, Path), ("owner_id" = i64, Path)),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn untag_owner(
|
||||
State(state): State<AppState>,
|
||||
Path((movie_id, owner_id)): Path<(i64, i64)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
load_movie(&state, movie_id).await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM title_owners WHERE title_kind = 'movie' AND title_id = ? AND owner_id = ?",
|
||||
movie_id,
|
||||
owner_id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user