From fa501706cd44a918c0cf245f3783f0003a450f57 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 9 May 2026 21:29:20 +0200 Subject: [PATCH] feat: add documentation crate and integrate OpenAPI specifications - Added a new crate `doc` for API documentation. - Integrated `utoipa` for OpenAPI support in the presentation layer. - Updated routes to include social features (follow, unfollow, etc.) and diary export. - Enhanced API request and response structures with new DTOs for social interactions. - Updated `Cargo.toml` files to include new dependencies and features. - Modified Dockerfile to copy the new documentation crate. - Refactored existing handlers and routes to accommodate new API endpoints. - Updated tests to cover new functionality and ensure proper API behavior. --- Cargo.lock | 202 ++++++++++++++++ Cargo.toml | 3 + Dockerfile | 1 + crates/adapters/activitypub-base/Cargo.toml | 2 +- crates/doc/Cargo.toml | 13 ++ crates/doc/src/lib.rs | 16 ++ crates/presentation/Cargo.toml | 4 +- crates/presentation/src/dtos.rs | 47 +++- crates/presentation/src/handlers.rs | 240 +++++++++++++++++++- crates/presentation/src/lib.rs | 1 + crates/presentation/src/main.rs | 6 +- crates/presentation/src/openapi.rs | 65 ++++++ crates/presentation/src/routes.rs | 7 + crates/presentation/tests/api_test.rs | 6 +- crates/tui/src/app.rs | 35 +-- crates/tui/src/client.rs | 167 +++++++++++++- 16 files changed, 777 insertions(+), 38 deletions(-) create mode 100644 crates/doc/Cargo.toml create mode 100644 crates/doc/src/lib.rs create mode 100644 crates/presentation/src/openapi.rs diff --git a/Cargo.lock b/Cargo.lock index 6621e65..ac04d3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,12 @@ dependencies = [ "url", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aes" version = "0.8.4" @@ -304,6 +310,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -952,6 +967,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1148,6 +1172,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1255,6 +1290,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "doc" +version = "0.1.0" +dependencies = [ + "axum", + "tracing", + "utoipa", + "utoipa-scalar", + "utoipa-swagger-ui", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1496,6 +1542,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.11.1" @@ -2522,6 +2578,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -3064,6 +3130,7 @@ dependencies = [ "auth", "axum", "chrono", + "doc", "domain", "dotenvy", "event-publisher", @@ -3087,6 +3154,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", "uuid", ] @@ -3570,6 +3638,40 @@ dependencies = [ "quick-xml", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3920,6 +4022,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -4926,6 +5034,68 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.23.1" @@ -5816,12 +5986,44 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.11.0" diff --git a/Cargo.toml b/Cargo.toml index 495e12b..2e3e7a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/domain", "crates/presentation", "crates/tui", + "crates/doc", ] resolver = "2" @@ -39,6 +40,7 @@ sqlx = { version = "0.8.6", features = [ ] } reqwest = { version = "0.13", features = ["json", "query"] } object_store = { version = "0.11", features = ["aws"] } +axum = { version = "0.8.8", features = ["macros"] } domain = { path = "crates/domain" } application = { path = "crates/application" } @@ -55,3 +57,4 @@ sqlite-federation = { path = "crates/adapters/sqlite-federation" } template-askama = { path = "crates/adapters/template-askama" } activitypub = { path = "crates/adapters/activitypub" } activitypub-base = { path = "crates/adapters/activitypub-base" } +doc = { path = "crates/doc" } diff --git a/Dockerfile b/Dockerfile index f061276..04a391b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askam COPY crates/application/Cargo.toml crates/application/Cargo.toml COPY crates/domain/Cargo.toml crates/domain/Cargo.toml COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml +COPY crates/doc/Cargo.toml crates/doc/Cargo.toml COPY crates/tui/Cargo.toml crates/tui/Cargo.toml # Stub every crate so cargo can resolve and fetch deps diff --git a/crates/adapters/activitypub-base/Cargo.toml b/crates/adapters/activitypub-base/Cargo.toml index fcc3694..c83befd 100644 --- a/crates/adapters/activitypub-base/Cargo.toml +++ b/crates/adapters/activitypub-base/Cargo.toml @@ -14,8 +14,8 @@ thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } +axum = { workspace = true } activitypub_federation = "0.7.0-beta.11" url = { version = "2", features = ["serde"] } enum_delegate = "0.2" -axum = "0.8" diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml new file mode 100644 index 0000000..1cd6d13 --- /dev/null +++ b/crates/doc/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "doc" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { workspace = true } +tracing = { workspace = true } +utoipa = { version = "5.5.0", features = ["axum_extras"] } +utoipa-scalar = { version = "0.3.0", features = [ + "axum", +], default-features = false } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 0000000..249d970 --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1,16 @@ +use axum::Router; +use utoipa::openapi::OpenApi; +use utoipa_scalar::{Scalar, Servable}; +use utoipa_swagger_ui::SwaggerUi; + +pub trait ApiDocExt { + fn with_api_doc(self, spec: OpenApi) -> Self; +} + +impl ApiDocExt for Router { + fn with_api_doc(self, spec: OpenApi) -> Self { + tracing::info!("API docs at /docs (Swagger) and /scalar"); + self.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) + .merge(Scalar::with_url("/scalar", spec)) + } +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index cb65d4c..9cd81ef 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -4,9 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = { version = "0.8.8", features = ["macros"] } tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] } +axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } @@ -33,6 +33,8 @@ template-askama = { workspace = true } event-publisher = { workspace = true } rss = { workspace = true } export = { workspace = true } +doc = { workspace = true } +utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } infer = "0.19.0" percent-encoding = "2" diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index f7f1b3c..f568c69 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -18,7 +18,8 @@ where } } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct DiaryQueryParams { pub limit: Option, pub offset: Option, @@ -66,7 +67,7 @@ pub struct DeleteRedirectForm { pub redirect_after: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct LogReviewRequest { pub external_metadata_id: Option, pub manual_title: Option, @@ -77,7 +78,7 @@ pub struct LogReviewRequest { pub watched_at: String, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct MovieDto { pub id: Uuid, pub title: String, @@ -86,7 +87,7 @@ pub struct MovieDto { pub poster_path: Option, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct ReviewDto { pub id: Uuid, pub rating: u8, @@ -94,13 +95,13 @@ pub struct ReviewDto { pub watched_at: String, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct DiaryEntryDto { pub movie: MovieDto, pub review: ReviewDto, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct DiaryResponse { pub items: Vec, pub total_count: u64, @@ -108,20 +109,20 @@ pub struct DiaryResponse { pub offset: u32, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct ReviewHistoryResponse { pub movie: MovieDto, pub viewings: Vec, pub trend: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct LoginRequest { pub email: String, pub password: String, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct LoginResponse { pub token: String, pub user_id: Uuid, @@ -129,7 +130,7 @@ pub struct LoginResponse { pub expires_at: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct RegisterRequest { pub email: String, pub username: String, @@ -259,8 +260,32 @@ pub struct ProfileQueryParams { pub error: Option, } -#[derive(serde::Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] +pub struct FollowRequest { + pub handle: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ActorUrlRequest { + pub actor_url: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RemoteActorDto { + pub handle: String, + pub display_name: Option, + pub url: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ActorListResponse { + pub actors: Vec, +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct ExportQueryParams { + /// Output format: `csv` (default) or `json` #[serde(default = "default_export_format")] pub format: String, } diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 77af374..50888c5 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -862,8 +862,9 @@ pub mod api { use crate::{ dtos::{ - DiaryEntryDto, DiaryQueryParams, DiaryResponse, ExportQueryParams, LogReviewData, - LogReviewRequest, LoginRequest, LoginResponse, MovieDto, RegisterRequest, ReviewDto, + ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryQueryParams, DiaryResponse, + ExportQueryParams, FollowRequest, LogReviewData, LogReviewRequest, LoginRequest, + LoginResponse, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, ReviewHistoryResponse, }, errors::ApiError, @@ -871,6 +872,15 @@ pub mod api { state::AppState, }; + #[utoipa::path( + get, path = "/api/v1/diary", + params(DiaryQueryParams), + responses( + (status = 200, body = DiaryResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] pub async fn get_diary( State(state): State, Query(params): Query, @@ -885,6 +895,14 @@ pub mod api { })) } + #[utoipa::path( + get, path = "/api/v1/movies/{id}/history", + params(("id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = ReviewHistoryResponse), + (status = 404, description = "Movie not found"), + ) + )] pub async fn get_review_history( State(state): State, Path(movie_id): Path, @@ -904,6 +922,16 @@ pub mod api { })) } + #[utoipa::path( + post, path = "/api/v1/reviews", + request_body = LogReviewRequest, + responses( + (status = 201, description = "Review created"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] pub async fn post_review( State(state): State, user: AuthenticatedUser, @@ -914,6 +942,16 @@ pub mod api { Ok(StatusCode::CREATED) } + #[utoipa::path( + post, path = "/api/v1/movies/{id}/sync-poster", + params(("id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 204, description = "Poster synced"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Movie not found"), + ), + security(("bearer_auth" = [])) + )] pub async fn sync_poster( State(state): State, _user: AuthenticatedUser, @@ -948,6 +986,14 @@ pub mod api { Ok(StatusCode::NO_CONTENT) } + #[utoipa::path( + post, path = "/api/v1/auth/login", + request_body = LoginRequest, + responses( + (status = 200, body = LoginResponse), + (status = 401, description = "Invalid credentials"), + ) + )] pub async fn login( State(state): State, Json(req): Json, @@ -968,6 +1014,14 @@ pub mod api { })) } + #[utoipa::path( + post, path = "/api/v1/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, description = "User registered"), + (status = 400, description = "Invalid input"), + ) + )] pub async fn register( State(state): State, Json(req): Json, @@ -984,6 +1038,17 @@ pub mod api { Ok(StatusCode::CREATED) } + #[utoipa::path( + delete, path = "/api/v1/reviews/{id}", + params(("id" = Uuid, Path, description = "Review ID")), + responses( + (status = 204, description = "Review deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Review not found"), + ), + security(("bearer_auth" = [])) + )] pub async fn delete_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, @@ -1030,6 +1095,177 @@ pub mod api { } } + fn ap_err(e: anyhow::Error) -> impl IntoResponse { + tracing::error!("ActivityPub error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR + } + + #[utoipa::path( + get, path = "/api/v1/social/following", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] + pub async fn get_following( + State(state): State, + user: AuthenticatedUser, + ) -> impl IntoResponse { + match state.ap_service.get_following(user.0.value()).await { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } + } + + #[utoipa::path( + get, path = "/api/v1/social/followers", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] + pub async fn get_followers( + State(state): State, + user: AuthenticatedUser, + ) -> impl IntoResponse { + match state.ap_service.get_accepted_followers(user.0.value()).await { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } + } + + #[utoipa::path( + post, path = "/api/v1/social/follow", + request_body = FollowRequest, + responses( + (status = 200, description = "Follow request sent"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] + pub async fn follow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, + ) -> impl IntoResponse { + match state.ap_service.follow(user.0.value(), &body.handle).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } + } + + #[utoipa::path( + post, path = "/api/v1/social/unfollow", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Unfollowed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] + pub async fn unfollow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, + ) -> impl IntoResponse { + match state.ap_service.unfollow(user.0.value(), &body.actor_url).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } + } + + #[utoipa::path( + post, path = "/api/v1/social/followers/accept", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower accepted"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] + pub async fn accept_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, + ) -> impl IntoResponse { + match state.ap_service.accept_follower(user.0.value(), &body.actor_url).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } + } + + #[utoipa::path( + post, path = "/api/v1/social/followers/reject", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower rejected"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] + pub async fn reject_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, + ) -> impl IntoResponse { + match state.ap_service.reject_follower(user.0.value(), &body.actor_url).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } + } + + #[utoipa::path( + post, path = "/api/v1/social/followers/remove", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower removed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] + pub async fn remove_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, + ) -> impl IntoResponse { + match state.ap_service.remove_follower(user.0.value(), &body.actor_url).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } + } + + #[utoipa::path( + get, path = "/api/v1/diary/export", + params(ExportQueryParams), + responses( + (status = 200, description = "Diary file download", content_type = "text/csv"), + (status = 400, description = "Invalid format parameter"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) + )] pub async fn export_diary( State(state): State, user: AuthenticatedUser, diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 4cf11d8..c8014cd 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -3,6 +3,7 @@ pub mod errors; pub mod event_handlers; pub mod extractors; pub mod handlers; +pub mod openapi; pub mod ports; pub mod routes; pub mod state; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 7cda957..3de2094 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -25,7 +25,9 @@ use sqlite::{SqliteMovieRepository, SqliteUserRepository}; use sqlite_federation::SqliteFederationRepository; use template_askama::AskamaHtmlRenderer; -use presentation::{routes, state::AppState}; +use doc::ApiDocExt; +use presentation::{openapi::ApiDoc, routes, state::AppState}; +use utoipa::OpenApi as _; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -36,7 +38,7 @@ async fn main() -> anyhow::Result<()> { .await .context("Failed to wire dependencies")?; - let app = routes::build_router(state, ap_router); + let app = routes::build_router(state, ap_router).with_api_doc(ApiDoc::openapi()); let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); diff --git a/crates/presentation/src/openapi.rs b/crates/presentation/src/openapi.rs new file mode 100644 index 0000000..da3e78f --- /dev/null +++ b/crates/presentation/src/openapi.rs @@ -0,0 +1,65 @@ +use utoipa::{ + Modify, OpenApi, + openapi::security::{Http, HttpAuthScheme, SecurityScheme}, +}; + +use crate::dtos::{ + ActorListResponse, ActorUrlRequest, DiaryEntryDto, DiaryResponse, FollowRequest, LoginRequest, + LoginResponse, LogReviewRequest, MovieDto, RegisterRequest, RemoteActorDto, ReviewDto, + ReviewHistoryResponse, +}; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + } +} + +#[derive(OpenApi)] +#[openapi( + info( + title = "Movies Diary API", + version = "1.0.0", + description = "REST API for Movies Diary. Authenticate with `POST /api/v1/auth/login` to get a Bearer token." + ), + paths( + crate::handlers::api::get_diary, + crate::handlers::api::get_review_history, + crate::handlers::api::post_review, + crate::handlers::api::delete_review, + crate::handlers::api::sync_poster, + crate::handlers::api::login, + crate::handlers::api::register, + crate::handlers::api::export_diary, + crate::handlers::api::get_following, + crate::handlers::api::get_followers, + crate::handlers::api::follow, + crate::handlers::api::unfollow, + crate::handlers::api::accept_follower, + crate::handlers::api::reject_follower, + crate::handlers::api::remove_follower, + ), + components(schemas( + DiaryResponse, + DiaryEntryDto, + MovieDto, + ReviewDto, + LogReviewRequest, + LoginRequest, + LoginResponse, + RegisterRequest, + ReviewHistoryResponse, + ActorListResponse, + RemoteActorDto, + FollowRequest, + ActorUrlRequest, + )), + modifiers(&SecurityAddon), +)] +pub struct ApiDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index b2867b9..117796e 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -176,6 +176,13 @@ fn api_routes(rate_limit: u64) -> Router { .route("/auth/login", routing::post(handlers::api::login)) .route("/auth/register", routing::post(handlers::api::register)) .route("/diary/export", routing::get(handlers::api::export_diary)) + .route("/social/following", routing::get(handlers::api::get_following)) + .route("/social/followers", routing::get(handlers::api::get_followers)) + .route("/social/follow", routing::post(handlers::api::follow)) + .route("/social/unfollow", routing::post(handlers::api::unfollow)) + .route("/social/followers/accept", routing::post(handlers::api::accept_follower)) + .route("/social/followers/reject", routing::post(handlers::api::reject_follower)) + .route("/social/followers/remove", routing::post(handlers::api::remove_follower)) .route_layer(auth_rate_limit), ) } diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index c247b9c..8615942 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -167,7 +167,7 @@ async fn get_api_diary_returns_empty_list() { let response = app .oneshot( Request::builder() - .uri("/api/diary") + .uri("/api/v1/diary") .body(Body::empty()) .unwrap(), ) @@ -192,7 +192,7 @@ async fn post_api_reviews_without_auth_returns_401() { .oneshot( Request::builder() .method("POST") - .uri("/api/reviews") + .uri("/api/v1/reviews") .header("content-type", "application/json") .body(Body::from( r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#, @@ -212,7 +212,7 @@ async fn post_api_auth_login_unknown_user_returns_401() { .oneshot( Request::builder() .method("POST") - .uri("/api/auth/login") + .uri("/api/v1/auth/login") .header("content-type", "application/json") .body(Body::from(r#"{"email":"a@b.com","password":"x"}"#)) .unwrap(), diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 4e9642b..b44b18d 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -266,6 +266,8 @@ pub enum Command { ClearToken, } +// Matches the export CSV column order: +// title,year,director,rating,comment,watched_at,external_metadata_id pub fn parse_csv(content: &str) -> Vec { let mut rdr = csv::Reader::from_reader(content.as_bytes()); let mut rows = Vec::new(); @@ -285,10 +287,11 @@ pub fn parse_csv(content: &str) -> Vec { let title = record.get(0).unwrap_or("").trim().to_string(); let year_str = record.get(1).unwrap_or("").trim().to_string(); - let external_id = record.get(2).unwrap_or("").trim().to_string(); + let director = record.get(2).unwrap_or("").trim().to_string(); let rating_str = record.get(3).unwrap_or("").trim().to_string(); - let watched_at = record.get(4).unwrap_or("").trim().to_string(); - let comment = record.get(5).unwrap_or("").trim().to_string(); + let comment = record.get(4).unwrap_or("").trim().to_string(); + let watched_at = record.get(5).unwrap_or("").trim().to_string(); + let external_id = record.get(6).unwrap_or("").trim().to_string(); if title.is_empty() && external_id.is_empty() { rows.push(ParsedRow { @@ -349,12 +352,9 @@ pub fn parse_csv(content: &str) -> Vec { }, manual_title: if title.is_empty() { None } else { Some(title) }, manual_release_year, + manual_director: if director.is_empty() { None } else { Some(director) }, rating, - comment: if comment.is_empty() { - None - } else { - Some(comment) - }, + comment: if comment.is_empty() { None } else { Some(comment) }, watched_at, }), }); @@ -843,6 +843,7 @@ pub fn update(app: &mut App, action: Action) -> Vec { external_metadata_id: ext_id, manual_title: title, manual_release_year: year, + manual_director: None, rating, comment, watched_at, @@ -1366,6 +1367,7 @@ mod tests { external_metadata_id: None, manual_title: Some("The Matrix".into()), manual_release_year: None, + manual_director: None, rating: 5, comment: None, watched_at: "1999-03-31T00:00:00".into(), @@ -1387,6 +1389,7 @@ mod tests { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, + manual_director: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into(), @@ -1395,6 +1398,7 @@ mod tests { external_metadata_id: None, manual_title: Some("B".into()), manual_release_year: None, + manual_director: None, rating: 4, comment: None, watched_at: "2024-01-02T00:00:00".into(), @@ -1422,6 +1426,7 @@ mod tests { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, + manual_director: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into(), @@ -1481,20 +1486,24 @@ mod tests { // ── parse_csv ───────────────────────────────────────────────────────────── + // CSV column order matches the export format: + // title,year,director,rating,comment,watched_at,external_metadata_id + #[test] fn parse_csv_valid_row_with_title() { - let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,1999,,5,1999-03-31T00:00:00,\n"; + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,1999,Wachowski,5,,1999-03-31T00:00:00,\n"; let rows = parse_csv(csv); assert_eq!(rows.len(), 1); assert!(rows[0].result.is_ok()); let req = rows[0].result.as_ref().unwrap(); assert_eq!(req.manual_title.as_deref(), Some("The Matrix")); + assert_eq!(req.manual_director.as_deref(), Some("Wachowski")); assert_eq!(req.rating, 5); } #[test] fn parse_csv_row_missing_title_and_id_is_error() { - let csv = "title,year,external_id,rating,watched_at,comment\n,,,5,2024-01-01T00:00:00,\n"; + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\n,,,5,,2024-01-01T00:00:00,\n"; let rows = parse_csv(csv); assert_eq!(rows.len(), 1); assert!(rows[0].result.is_err()); @@ -1502,14 +1511,14 @@ mod tests { #[test] fn parse_csv_invalid_rating_is_error() { - let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,,,9,2024-01-01T00:00:00,\n"; + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,,,9,,2024-01-01T00:00:00,\n"; let rows = parse_csv(csv); assert!(rows[0].result.is_err()); } #[test] fn parse_csv_with_external_id_only() { - let csv = "title,year,external_id,rating,watched_at,comment\n,,tt0133093,5,1999-03-31T00:00:00,\n"; + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\n,,,5,,1999-03-31T00:00:00,tt0133093\n"; let rows = parse_csv(csv); assert!(rows[0].result.is_ok()); let req = rows[0].result.as_ref().unwrap(); @@ -1519,7 +1528,7 @@ mod tests { #[test] fn parse_csv_rating_zero_is_valid() { - let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,,,0,2024-01-01T00:00:00,\n"; + let csv = "title,year,director,rating,comment,watched_at,external_metadata_id\nThe Matrix,,,0,,2024-01-01T00:00:00,\n"; let rows = parse_csv(csv); assert_eq!(rows.len(), 1); assert!(rows[0].result.is_ok()); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 3d14fa8..ba1246d 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -22,6 +22,8 @@ pub struct LogReviewRequest { pub manual_title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub manual_release_year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub manual_director: Option, pub rating: u8, #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, @@ -63,6 +65,28 @@ pub struct ReviewHistoryResponse { pub trend: String, } +#[derive(Debug, Clone, Serialize)] +pub struct FollowRequest { + pub handle: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ActorUrlRequest { + pub actor_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoteActorDto { + pub handle: String, + pub display_name: Option, + pub url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ActorListResponse { + pub actors: Vec, +} + // ── Error ───────────────────────────────────────────────────────────────────── #[derive(Debug, thiserror::Error)] @@ -119,10 +143,16 @@ impl ApiClient { self.base_url.read().unwrap().clone() } + fn api(&self, path: &str) -> String { + format!("{}/api/v1{}", self.url(), path) + } + + // ── Auth ────────────────────────────────────────────────────────────────── + pub async fn login(&self, email: &str, password: &str) -> Result { let resp = self .http - .post(format!("{}/api/auth/login", self.url())) + .post(self.api("/auth/login")) .json(&LoginRequest { email: email.into(), password: password.into(), @@ -132,6 +162,8 @@ impl ApiClient { Ok(check_status(resp).await?.json().await?) } + // ── Diary ───────────────────────────────────────────────────────────────── + pub async fn get_diary( &self, token: &str, @@ -140,7 +172,7 @@ impl ApiClient { ) -> Result { let resp = self .http - .get(format!("{}/api/diary", self.url())) + .get(self.api("/diary")) .query(&[("offset", offset), ("limit", limit)]) .bearer_auth(token) .send() @@ -148,6 +180,23 @@ impl ApiClient { Ok(check_status(resp).await?.json().await?) } + pub async fn export_diary( + &self, + token: &str, + format: &str, + ) -> Result, ApiError> { + let resp = self + .http + .get(self.api("/diary/export")) + .query(&[("format", format)]) + .bearer_auth(token) + .send() + .await?; + Ok(check_status(resp).await?.bytes().await?.to_vec()) + } + + // ── Reviews ─────────────────────────────────────────────────────────────── + pub async fn get_movie_history( &self, token: &str, @@ -155,7 +204,7 @@ impl ApiClient { ) -> Result { let resp = self .http - .get(format!("{}/api/movies/{}/history", self.url(), movie_id)) + .get(self.api(&format!("/movies/{movie_id}/history"))) .bearer_auth(token) .send() .await?; @@ -165,7 +214,7 @@ impl ApiClient { pub async fn create_review(&self, token: &str, req: &LogReviewRequest) -> Result<(), ApiError> { let resp = self .http - .post(format!("{}/api/reviews", self.url())) + .post(self.api("/reviews")) .bearer_auth(token) .json(req) .send() @@ -177,13 +226,95 @@ impl ApiClient { pub async fn delete_review(&self, token: &str, review_id: Uuid) -> Result<(), ApiError> { let resp = self .http - .delete(format!("{}/api/reviews/{}", self.url(), review_id)) + .delete(self.api(&format!("/reviews/{review_id}"))) .bearer_auth(token) .send() .await?; check_status(resp).await?; Ok(()) } + + // ── Social (ActivityPub) ────────────────────────────────────────────────── + + pub async fn get_following(&self, token: &str) -> Result { + let resp = self + .http + .get(self.api("/social/following")) + .bearer_auth(token) + .send() + .await?; + Ok(check_status(resp).await?.json().await?) + } + + pub async fn get_followers(&self, token: &str) -> Result { + let resp = self + .http + .get(self.api("/social/followers")) + .bearer_auth(token) + .send() + .await?; + Ok(check_status(resp).await?.json().await?) + } + + pub async fn follow(&self, token: &str, handle: &str) -> Result<(), ApiError> { + let resp = self + .http + .post(self.api("/social/follow")) + .bearer_auth(token) + .json(&FollowRequest { handle: handle.into() }) + .send() + .await?; + check_status(resp).await?; + Ok(()) + } + + pub async fn unfollow(&self, token: &str, actor_url: &str) -> Result<(), ApiError> { + let resp = self + .http + .post(self.api("/social/unfollow")) + .bearer_auth(token) + .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .send() + .await?; + check_status(resp).await?; + Ok(()) + } + + pub async fn accept_follower(&self, token: &str, actor_url: &str) -> Result<(), ApiError> { + let resp = self + .http + .post(self.api("/social/followers/accept")) + .bearer_auth(token) + .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .send() + .await?; + check_status(resp).await?; + Ok(()) + } + + pub async fn reject_follower(&self, token: &str, actor_url: &str) -> Result<(), ApiError> { + let resp = self + .http + .post(self.api("/social/followers/reject")) + .bearer_auth(token) + .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .send() + .await?; + check_status(resp).await?; + Ok(()) + } + + pub async fn remove_follower(&self, token: &str, actor_url: &str) -> Result<(), ApiError> { + let resp = self + .http + .post(self.api("/social/followers/remove")) + .bearer_auth(token) + .json(&ActorUrlRequest { actor_url: actor_url.into() }) + .send() + .await?; + check_status(resp).await?; + Ok(()) + } } #[cfg(test)] @@ -209,6 +340,7 @@ mod tests { external_metadata_id: None, manual_title: Some("The Matrix".into()), manual_release_year: None, + manual_director: None, rating: 5, comment: None, watched_at: "2024-01-15T20:00:00".into(), @@ -216,15 +348,40 @@ mod tests { let json = serde_json::to_string(&req).unwrap(); assert!(!json.contains("external_metadata_id")); assert!(!json.contains("manual_release_year")); + assert!(!json.contains("manual_director")); assert!(json.contains("\"manual_title\":\"The Matrix\"")); assert!(json.contains("\"rating\":5")); } + #[test] + fn log_review_request_includes_director_when_set() { + let req = LogReviewRequest { + external_metadata_id: None, + manual_title: Some("Dune".into()), + manual_release_year: Some(2021), + manual_director: Some("Denis Villeneuve".into()), + rating: 5, + comment: None, + watched_at: "2024-01-15T20:00:00".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"manual_director\":\"Denis Villeneuve\"")); + } + + #[test] + fn api_client_builds_versioned_urls() { + let client = ApiClient::new("http://localhost:3000"); + assert_eq!(client.api("/diary"), "http://localhost:3000/api/v1/diary"); + assert_eq!(client.api("/auth/login"), "http://localhost:3000/api/v1/auth/login"); + assert_eq!(client.api("/social/follow"), "http://localhost:3000/api/v1/social/follow"); + } + #[test] fn api_client_update_url() { let client = ApiClient::new("http://localhost:3000"); assert!(client.url().contains("3000")); client.update_url("http://localhost:8080"); assert!(client.url().contains("8080")); + assert_eq!(client.api("/diary"), "http://localhost:8080/api/v1/diary"); } }