refactor: Phase E — types de réponses API standardisés + SVGs inline → Icon
E1 - API responses: - Crée responses.rs avec OkResponse, DeletedResponse, UpdatedResponse, RevokedResponse, UnlinkedResponse, StatusResponse (6 tests de sérialisation) - Remplace ~15 json!() inline par des types structurés dans books, libraries, tokens, users, handlers, anilist, metadata, download_detection, torrent_import - Signatures de retour des handlers typées (plus de serde_json::Value) E2 - SVGs → Icon component: - Ajoute icon "lock" au composant Icon - Remplace ~30 SVGs inline par <Icon> dans 9 composants (FolderPicker, FolderBrowser, LiveSearchForm, JobRow, LibraryActions, ReadingStatusModal, EditBookForm, EditSeriesForm, UserSwitcher) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -433,7 +433,7 @@ pub async fn link_series(
|
||||
pub async fn unlink_series(
|
||||
State(state): State<AppState>,
|
||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::UnlinkedResponse>, ApiError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2",
|
||||
)
|
||||
@@ -446,7 +446,7 @@ pub async fn unlink_series(
|
||||
return Err(ApiError::not_found("AniList link not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"unlinked": true})))
|
||||
Ok(Json(crate::responses::UnlinkedResponse::new()))
|
||||
}
|
||||
|
||||
/// Toggle AniList sync for a library
|
||||
|
||||
@@ -669,7 +669,7 @@ pub async fn get_thumbnail(
|
||||
pub async fn delete_book(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||
// Fetch the book and its file path
|
||||
let row = sqlx::query(
|
||||
"SELECT b.library_id, b.thumbnail_path, bf.abs_path \
|
||||
@@ -727,5 +727,5 @@ pub async fn delete_book(
|
||||
id, scan_job_id, library_id
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
Ok(Json(crate::responses::OkResponse::new()))
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ pub async fn delete_available_download(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
axum::extract::Query(query): axum::extract::Query<DeleteAvailableQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||
if let Some(release_idx) = query.release {
|
||||
// Remove a single release from the JSON array
|
||||
let row = sqlx::query("SELECT available_releases FROM available_downloads WHERE id = $1")
|
||||
@@ -486,7 +486,7 @@ pub async fn delete_available_download(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
Ok(Json(crate::responses::OkResponse::new()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -17,9 +17,9 @@ pub async fn docs_redirect() -> impl axum::response::IntoResponse {
|
||||
axum::response::Redirect::to("/swagger-ui/")
|
||||
}
|
||||
|
||||
pub async fn ready(State(state): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
pub async fn ready(State(state): State<AppState>) -> Result<Json<crate::responses::StatusResponse>, ApiError> {
|
||||
sqlx::query("SELECT 1").execute(&state.pool).await?;
|
||||
Ok(Json(serde_json::json!({"status": "ready"})))
|
||||
Ok(Json(crate::responses::StatusResponse::new("ready")))
|
||||
}
|
||||
|
||||
pub async fn metrics(State(state): State<AppState>) -> String {
|
||||
|
||||
@@ -188,7 +188,7 @@ pub async fn create_library(
|
||||
pub async fn delete_library(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
@@ -198,7 +198,7 @@ pub async fn delete_library(
|
||||
return Err(ApiError::not_found("library not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||
}
|
||||
|
||||
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {
|
||||
|
||||
@@ -19,6 +19,7 @@ mod pages;
|
||||
mod prowlarr;
|
||||
mod qbittorrent;
|
||||
mod reading_progress;
|
||||
mod responses;
|
||||
mod torrent_import;
|
||||
mod reading_status_match;
|
||||
mod reading_status_push;
|
||||
|
||||
@@ -413,7 +413,7 @@ pub async fn approve_metadata(
|
||||
pub async fn reject_metadata(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::StatusResponse>, ApiError> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE external_metadata_links SET status = 'rejected', updated_at = NOW() WHERE id = $1",
|
||||
)
|
||||
@@ -425,7 +425,7 @@ pub async fn reject_metadata(
|
||||
return Err(ApiError::not_found("link not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"status": "rejected"})))
|
||||
Ok(Json(crate::responses::StatusResponse::new("rejected")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -571,7 +571,7 @@ pub async fn get_missing_books(
|
||||
pub async fn delete_metadata_link(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM external_metadata_links WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
@@ -581,7 +581,7 @@ pub async fn delete_metadata_link(
|
||||
return Err(ApiError::not_found("link not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id.to_string()})))
|
||||
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
133
apps/api/src/responses.rs
Normal file
133
apps/api/src/responses.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Simple acknowledgment response.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct OkResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
impl OkResponse {
|
||||
pub fn new() -> Self {
|
||||
Self { ok: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for resource deletion operations.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DeletedResponse {
|
||||
pub deleted: bool,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl DeletedResponse {
|
||||
pub fn new(id: Uuid) -> Self {
|
||||
Self { deleted: true, id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for resource update operations.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UpdatedResponse {
|
||||
pub updated: bool,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl UpdatedResponse {
|
||||
pub fn new(id: Uuid) -> Self {
|
||||
Self { updated: true, id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for token revocation.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct RevokedResponse {
|
||||
pub revoked: bool,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl RevokedResponse {
|
||||
pub fn new(id: Uuid) -> Self {
|
||||
Self { revoked: true, id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for unlinking operations (e.g., AniList).
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UnlinkedResponse {
|
||||
pub unlinked: bool,
|
||||
}
|
||||
|
||||
impl UnlinkedResponse {
|
||||
pub fn new() -> Self {
|
||||
Self { unlinked: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple status response.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct StatusResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl StatusResponse {
|
||||
pub fn new(status: &str) -> Self {
|
||||
Self {
|
||||
status: status.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ok_response_serializes() {
|
||||
let r = OkResponse::new();
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"ok": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_response_serializes() {
|
||||
let id = Uuid::nil();
|
||||
let r = DeletedResponse::new(id);
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json["deleted"], true);
|
||||
assert_eq!(json["id"], id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_response_serializes() {
|
||||
let id = Uuid::nil();
|
||||
let r = UpdatedResponse::new(id);
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json["updated"], true);
|
||||
assert_eq!(json["id"], id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_response_serializes() {
|
||||
let id = Uuid::nil();
|
||||
let r = RevokedResponse::new(id);
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json["revoked"], true);
|
||||
assert_eq!(json["id"], id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlinked_response_serializes() {
|
||||
let r = UnlinkedResponse::new();
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"unlinked": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_response_serializes() {
|
||||
let r = StatusResponse::new("ready");
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"status": "ready"}));
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
|
||||
pub async fn revoke_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::RevokedResponse>, ApiError> {
|
||||
let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
@@ -186,7 +186,7 @@ pub async fn revoke_token(
|
||||
return Err(ApiError::not_found("token not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
||||
Ok(Json(crate::responses::RevokedResponse::new(id)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -216,7 +216,7 @@ pub async fn update_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<UpdateTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::UpdatedResponse>, ApiError> {
|
||||
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
|
||||
.bind(input.user_id)
|
||||
.bind(id)
|
||||
@@ -227,7 +227,7 @@ pub async fn update_token(
|
||||
return Err(ApiError::not_found("token not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||
Ok(Json(crate::responses::UpdatedResponse::new(id)))
|
||||
}
|
||||
|
||||
/// Permanently delete a revoked API token
|
||||
@@ -249,7 +249,7 @@ pub async fn update_token(
|
||||
pub async fn delete_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM api_tokens WHERE id = $1 AND revoked_at IS NOT NULL")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
@@ -259,5 +259,5 @@ pub async fn delete_token(
|
||||
return Err(ApiError::not_found("token not found or not revoked"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||
}
|
||||
|
||||
@@ -55,14 +55,14 @@ struct ImportedFile {
|
||||
pub async fn notify_torrent_done(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<TorrentNotifyRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||
if body.hash.is_empty() {
|
||||
return Err(ApiError::bad_request("hash is required"));
|
||||
}
|
||||
|
||||
if !is_torrent_import_enabled(&state.pool).await {
|
||||
info!("Torrent import disabled, ignoring notification for hash {}", body.hash);
|
||||
return Ok(Json(serde_json::json!({ "ok": true })));
|
||||
return Ok(Json(crate::responses::OkResponse::new()));
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
@@ -74,7 +74,7 @@ pub async fn notify_torrent_done(
|
||||
|
||||
let Some(row) = row else {
|
||||
info!("Torrent notification for unknown hash {}, ignoring", body.hash);
|
||||
return Ok(Json(serde_json::json!({ "ok": true })));
|
||||
return Ok(Json(crate::responses::OkResponse::new()));
|
||||
};
|
||||
|
||||
let torrent_id: Uuid = row.get("id");
|
||||
@@ -96,7 +96,7 @@ pub async fn notify_torrent_done(
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
Ok(Json(crate::responses::OkResponse::new()))
|
||||
}
|
||||
|
||||
/// List recent torrent downloads (admin).
|
||||
@@ -145,7 +145,7 @@ pub async fn list_torrent_downloads(
|
||||
pub async fn delete_torrent_download(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||
let row = sqlx::query("SELECT qb_hash, status FROM torrent_downloads WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
@@ -187,7 +187,7 @@ pub async fn delete_torrent_download(
|
||||
.await?;
|
||||
|
||||
info!("Deleted torrent download {id}");
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
Ok(Json(crate::responses::OkResponse::new()))
|
||||
}
|
||||
|
||||
// ─── Background poller ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -136,7 +136,7 @@ pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<CreateUserRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::UpdatedResponse>, ApiError> {
|
||||
if input.username.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("username is required"));
|
||||
}
|
||||
@@ -159,7 +159,7 @@ pub async fn update_user(
|
||||
return Err(ApiError::not_found("user not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||
Ok(Json(crate::responses::UpdatedResponse::new(id)))
|
||||
}
|
||||
|
||||
/// Delete a reader user (cascades on tokens and reading progress)
|
||||
@@ -181,7 +181,7 @@ pub async fn update_user(
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
@@ -191,5 +191,5 @@ pub async fn delete_user(
|
||||
return Err(ApiError::not_found("user not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user