refactor: deps cleanup, split openapi, extract api-types crate

This commit is contained in:
2026-05-12 11:54:00 +02:00
parent 2d6121239f
commit 99ce81efe5
46 changed files with 695 additions and 808 deletions

52
Cargo.lock generated
View File

@@ -15,7 +15,6 @@ dependencies = [
"domain",
"serde",
"serde_json",
"tokio",
"tracing",
"url",
"uuid",
@@ -31,10 +30,8 @@ dependencies = [
"axum",
"chrono",
"enum_delegate",
"reqwest 0.13.3",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
@@ -288,6 +285,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api-types"
version = "0.1.0"
dependencies = [
"serde",
"utoipa",
"uuid",
]
[[package]]
name = "apple-native-keyring-store"
version = "1.0.0"
@@ -1443,17 +1449,6 @@ 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"
@@ -1467,7 +1462,6 @@ dependencies = [
name = "domain"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"email_address",
@@ -1655,7 +1649,6 @@ dependencies = [
name = "event-payload"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"domain",
"serde",
@@ -1671,7 +1664,6 @@ dependencies = [
"domain",
"futures",
"tokio",
"tracing",
]
[[package]]
@@ -2427,7 +2419,6 @@ dependencies = [
"csv",
"domain",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
@@ -2913,9 +2904,7 @@ dependencies = [
"domain",
"event-payload",
"futures",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
@@ -3470,16 +3459,13 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"domain",
"event-payload",
"futures",
"serde",
"serde_json",
"sqlx",
"tokio",
"tracing",
"uuid",
]
[[package]]
@@ -3493,7 +3479,6 @@ dependencies = [
"chrono",
"domain",
"sqlx",
"tokio",
"tracing",
"uuid",
]
@@ -3528,13 +3513,13 @@ version = "0.1.0"
dependencies = [
"activitypub",
"anyhow",
"api-types",
"application",
"async-trait",
"auth",
"axum",
"axum-governor",
"chrono",
"doc",
"domain",
"dotenvy",
"export",
@@ -3557,13 +3542,14 @@ dependencies = [
"sqlite-federation",
"sqlx",
"template-askama",
"thiserror 2.0.18",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"utoipa",
"utoipa-scalar",
"utoipa-swagger-ui",
"uuid",
]
@@ -4057,7 +4043,6 @@ name = "rss"
version = "0.1.0"
dependencies = [
"application",
"chrono",
"domain",
"rss 2.0.12",
]
@@ -4625,16 +4610,13 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"domain",
"event-payload",
"futures",
"serde",
"serde_json",
"sqlx",
"tokio",
"tracing",
"uuid",
]
[[package]]
@@ -4997,7 +4979,6 @@ dependencies = [
"askama",
"chrono",
"domain",
"serde",
"uuid",
]
@@ -5424,6 +5405,7 @@ name = "tui"
version = "0.1.0"
dependencies = [
"anyhow",
"api-types",
"apple-native-keyring-store",
"csv",
"directories",
@@ -5432,7 +5414,6 @@ dependencies = [
"reqwest 0.13.3",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"uuid",
@@ -6333,13 +6314,10 @@ dependencies = [
"activitypub",
"anyhow",
"application",
"async-trait",
"auth",
"chrono",
"domain",
"dotenvy",
"export",
"futures",
"image-storage",
"importer",
"metadata",
@@ -6349,17 +6327,13 @@ dependencies = [
"postgres",
"postgres-event-queue",
"postgres-federation",
"serde",
"serde_json",
"sqlite",
"sqlite-event-queue",
"sqlite-federation",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]

View File

@@ -19,18 +19,18 @@ members = [
"crates/adapters/export",
"crates/adapters/event-payload",
"crates/adapters/nats",
"crates/api-types",
"crates/application",
"crates/domain",
"crates/presentation",
"crates/tui",
"crates/doc",
"crates/worker",
"crates/adapters/importer",
]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1.0", features = ["full"] }
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
futures = "0.3"
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] }
@@ -53,6 +53,7 @@ object_store = { version = "0.11", features = ["aws"] }
axum = { version = "0.8.8", features = ["macros", "multipart"] }
csv = "1"
api-types = { path = "crates/api-types" }
domain = { path = "crates/domain" }
application = { path = "crates/application" }
presentation = { path = "crates/presentation" }
@@ -71,7 +72,6 @@ postgres-federation = { path = "crates/adapters/postgres-federation" }
template-askama = { path = "crates/adapters/template-askama" }
activitypub = { path = "crates/adapters/activitypub" }
activitypub-base = { path = "crates/adapters/activitypub-base" }
doc = { path = "crates/doc" }
event-payload = { path = "crates/adapters/event-payload" }
nats = { path = "crates/adapters/nats" }
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }

View File

@@ -27,10 +27,10 @@ COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Car
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml
COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
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
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml

View File

@@ -24,9 +24,10 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
Hexagonal (Ports & Adapters) with Domain-Driven Design:
```
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui
domain — pure types and trait definitions, no external deps
application — use cases / business logic orchestration
presentation — Axum HTTP router, composition root for the HTTP process
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
worker — standalone worker binary (event consumer, poster sync, federation)
adapters/
auth — JWT issuance and validation (Argon2 passwords)
@@ -44,13 +45,12 @@ adapters/
sqlite-event-queue — durable polling event queue backed by SQLite
postgres-event-queue — durable polling event queue backed by PostgreSQL
nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (tests only)
event-publisher — in-memory event channel (used in tests)
activitypub — ActivityPub federation wiring (follow, inbox/outbox, actor)
activitypub-base — core ActivityPub protocol types and service
sqlite-federation — SQLite-backed federation repository
postgres-federation — PostgreSQL-backed federation repository
docOpenAPI spec assembly and Swagger UI / Scalar serving
tui — terminal UI client (ratatui)
tuiterminal UI client (ratatui); shares api-types with presentation for typed API access
```
## Prerequisites

View File

@@ -7,10 +7,8 @@ edition = "2024"
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }

View File

@@ -7,7 +7,6 @@ edition = "2024"
activitypub-base = { workspace = true }
domain = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }

View File

@@ -8,5 +8,4 @@ domain = { workspace = true }
serde = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }

View File

@@ -7,5 +7,4 @@ edition = "2024"
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
futures = { workspace = true }

View File

@@ -9,6 +9,5 @@ xlsx = ["dep:calamine"]
[dependencies]
domain = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
csv = { workspace = true }
calamine = { version = "0.26", optional = true }

View File

@@ -10,9 +10,7 @@ domain = { workspace = true }
event-payload = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -9,10 +9,7 @@ domain = { workspace = true }
event-payload = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -20,5 +20,3 @@ tracing = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -5,6 +5,5 @@ edition = "2024"
[dependencies]
rss-feed = { package = "rss", version = "2" }
chrono = { workspace = true }
domain = { workspace = true }
application = { workspace = true }

View File

@@ -9,10 +9,7 @@ domain = { workspace = true }
event-payload = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -6,7 +6,6 @@ edition = "2024"
[dependencies]
askama = { version = "0.16.0" }
serde = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,9 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
uuid = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }

View File

@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LoginResponse {
pub token: String,
pub user_id: Uuid,
pub email: String,
pub expires_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
}

View File

@@ -0,0 +1,7 @@
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PaginationQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}

View File

@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::movies::{MovieDto, ReviewDto};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LogReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub external_metadata_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_release_year: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_director: Option<String>,
pub rating: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiaryEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct DiaryQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<String>,
pub movie_id: Option<Uuid>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ActivityFeedQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FeedEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
pub user_email: String,
pub user_display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActivityFeedResponse {
pub items: Vec<FeedEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, 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,
}
fn default_export_format() -> String {
"csv".to_string()
}

View File

@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SessionCreatedResponse {
pub session_id: String,
pub columns: Vec<String>,
pub sample_rows: Vec<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SessionStateResponse {
pub session_id: String,
pub columns: Vec<String>,
pub has_mappings: bool,
pub row_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ApiFieldMapping {
/// Column name in the source file
pub source_column: String,
/// Domain field: title | release_year | director | rating | watched_at | comment | external_metadata_id
pub domain_field: String,
/// For rating fields: multiply raw value by this factor (e.g. 0.5 for 10-point → 5-point scale)
pub rating_scale: Option<f64>,
/// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y")
pub date_format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ApplyMappingRequest {
pub mappings: Vec<ApiFieldMapping>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ConfirmRequest {
/// Indices (0-based) of rows from the mapping preview to import
pub confirmed_indices: Vec<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SaveProfileRequest {
/// Session UUID whose current field_mappings to save
pub session_id: String,
/// Human-readable profile name (e.g. "Letterboxd")
pub name: String,
}

View File

@@ -0,0 +1,15 @@
pub mod auth;
pub mod common;
pub mod diary;
pub mod import;
pub mod movies;
pub mod social;
pub mod users;
pub use auth::*;
pub use common::*;
pub use diary::*;
pub use import::*;
pub use movies::*;
pub use social::*;
pub use users::*;

View File

@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MovieDto {
pub id: Uuid,
pub title: String,
pub release_year: u16,
pub director: Option<String>,
pub poster_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReviewDto {
pub id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReviewHistoryResponse {
pub movie: MovieDto,
pub viewings: Vec<ReviewDto>,
pub trend: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MovieStatsDto {
pub total_count: u64,
pub avg_rating: Option<f64>,
pub federated_count: u64,
pub rating_histogram: [u64; 5],
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SocialReviewDto {
pub user_display: String,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
pub is_federated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SocialFeedResponse {
pub items: Vec<SocialReviewDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MovieDetailResponse {
pub movie: MovieDto,
pub stats: MovieStatsDto,
pub reviews: SocialFeedResponse,
}

View File

@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FollowRequest {
pub handle: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActorUrlRequest {
pub actor_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteActorDto {
pub handle: String,
pub display_name: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActorListResponse {
pub actors: Vec<RemoteActorDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BlockedDomainResponse {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AddBlockedDomainRequest {
pub domain: String,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BlockedActorResponse {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}

View File

@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::diary::{DiaryEntryDto, DiaryResponse};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSummaryDto {
pub id: Uuid,
pub email: String,
pub total_movies: i64,
pub avg_rating: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UsersResponse {
pub users: Vec<UserSummaryDto>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct UserProfileQueryParams {
/// One of: `recent` (default), `ratings`, `history`, `trends`
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserStatsDto {
pub total_movies: i64,
pub avg_rating: Option<f64>,
pub favorite_director: Option<String>,
pub most_active_month: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MonthActivityDto {
pub year_month: String,
pub month_label: String,
pub count: i64,
pub entries: Vec<DiaryEntryDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MonthlyRatingDto {
pub year_month: String,
pub month_label: String,
pub avg_rating: f64,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DirectorStatDto {
pub director: String,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserTrendsDto {
pub monthly_ratings: Vec<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>,
pub max_director_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserProfileResponse {
pub user_id: Uuid,
pub username: String,
pub stats: UserStatsDto,
pub following_count: usize,
pub followers_count: usize,
/// Populated for view=recent and view=ratings
pub entries: Option<DiaryResponse>,
/// Populated for view=history
pub history: Option<Vec<MonthActivityDto>>,
/// Populated for view=trends
pub trends: Option<UserTrendsDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileResponse {
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
}

View File

@@ -1,13 +0,0 @@
[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"] }

View File

@@ -1,16 +0,0 @@
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))
}
}

View File

@@ -7,7 +7,6 @@ edition = "2024"
uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
futures = { workspace = true }

View File

@@ -33,7 +33,6 @@ axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tokio = { workspace = true }
@@ -42,6 +41,7 @@ uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
api-types = { workspace = true }
domain = { workspace = true }
application = { workspace = true }
auth = { workspace = true }
@@ -52,10 +52,11 @@ template-askama = { workspace = true }
nats = { workspace = true, optional = true }
rss = { workspace = true }
export = { workspace = true }
doc = { workspace = true }
importer = { workspace = true }
sqlx = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
# Optional — database backends
sqlite = { workspace = true, optional = true }

View File

@@ -1,11 +1,13 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use uuid::Uuid;
use application::{commands::LogReviewCommand, queries::GetDiaryQuery};
use domain::{errors::DomainError, models::SortDirection};
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
use api_types::{DiaryQueryParams, LogReviewRequest};
pub fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
@@ -18,15 +20,6 @@ where
}
}
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct DiaryQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<String>,
pub movie_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct LogReviewForm {
#[serde(default, deserialize_with = "empty_string_as_none")]
@@ -67,7 +60,7 @@ pub struct ErrorQuery {
pub error: Option<String>,
}
#[derive(serde::Deserialize, Default)]
#[derive(Deserialize, Default)]
pub struct FeedQueryParams {
#[serde(default)]
pub filter: String,
@@ -87,74 +80,60 @@ pub struct DeleteRedirectForm {
pub csrf_token: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct LogReviewRequest {
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
#[derive(Deserialize)]
pub struct FollowForm {
pub handle: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct MovieDto {
pub id: Uuid,
pub title: String,
pub release_year: u16,
pub director: Option<String>,
pub poster_path: Option<String>,
#[derive(Deserialize)]
pub struct UnfollowForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ReviewDto {
pub id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
#[derive(Deserialize)]
pub struct FollowerActionForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct DiaryEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
#[derive(Deserialize)]
pub struct BlockDomainForm {
pub domain: String,
#[serde(default)]
pub reason: Option<String>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
#[derive(Deserialize)]
pub struct RemoveDomainForm {
pub domain: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ReviewHistoryResponse {
pub movie: MovieDto,
pub viewings: Vec<ReviewDto>,
pub trend: String,
#[derive(Deserialize)]
pub struct ActorUrlForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct LoginResponse {
pub token: String,
pub user_id: Uuid,
pub email: String,
pub expires_at: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
#[derive(Deserialize, Default)]
pub struct ProfileQueryParams {
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub error: Option<String>,
#[serde(default)]
pub sort_by: String,
#[serde(default)]
pub search: String,
}
pub struct LogReviewData {
@@ -239,283 +218,22 @@ impl LogReviewData {
}
}
impl From<DiaryQueryParams> for GetDiaryQuery {
fn from(p: DiaryQueryParams) -> Self {
GetDiaryQuery {
limit: p.limit,
offset: p.offset,
sort_by: p.sort_by.as_deref().map(|s| {
if s == "asc" {
SortDirection::Ascending
} else {
SortDirection::Descending
}
}),
movie_id: p.movie_id,
user_id: None,
}
pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery {
GetDiaryQuery {
limit: p.limit,
offset: p.offset,
sort_by: p.sort_by.as_deref().map(|s| {
if s == "asc" {
SortDirection::Ascending
} else {
SortDirection::Descending
}
}),
movie_id: p.movie_id,
user_id: None,
}
}
#[derive(Deserialize)]
pub struct FollowForm {
pub handle: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct UnfollowForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct FollowerActionForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct BlockDomainForm {
pub domain: String,
#[serde(default)]
pub reason: Option<String>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct RemoveDomainForm {
pub domain: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct ActorUrlForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(serde::Deserialize, Default)]
pub struct ProfileQueryParams {
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub error: Option<String>,
#[serde(default)]
pub sort_by: String,
#[serde(default)]
pub search: String,
}
// ── Activity feed ─────────────────────────────────────────────────────────────
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ActivityFeedQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct FeedEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
pub user_email: String,
pub user_display_name: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ActivityFeedResponse {
pub items: Vec<FeedEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
// ── Users ──────────────────────────────────────────────────────────────────────
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserSummaryDto {
pub id: Uuid,
pub email: String,
pub total_movies: i64,
pub avg_rating: Option<f64>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UsersResponse {
pub users: Vec<UserSummaryDto>,
}
// ── User profile ───────────────────────────────────────────────────────────────
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct UserProfileQueryParams {
/// One of: `recent` (default), `ratings`, `history`, `trends`
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserStatsDto {
pub total_movies: i64,
pub avg_rating: Option<f64>,
pub favorite_director: Option<String>,
pub most_active_month: Option<String>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct MonthActivityDto {
pub year_month: String,
pub month_label: String,
pub count: i64,
pub entries: Vec<DiaryEntryDto>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct MonthlyRatingDto {
pub year_month: String,
pub month_label: String,
pub avg_rating: f64,
pub count: i64,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct DirectorStatDto {
pub director: String,
pub count: i64,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserTrendsDto {
pub monthly_ratings: Vec<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>,
pub max_director_count: i64,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserProfileResponse {
pub user_id: Uuid,
pub username: String,
pub stats: UserStatsDto,
pub following_count: usize,
pub followers_count: usize,
/// Populated for view=recent and view=ratings
pub entries: Option<DiaryResponse>,
/// Populated for view=history
pub history: Option<Vec<MonthActivityDto>>,
/// Populated for view=trends
pub trends: Option<UserTrendsDto>,
}
#[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<String>,
pub url: String,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ActorListResponse {
pub actors: Vec<RemoteActorDto>,
}
#[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,
}
fn default_export_format() -> String {
"csv".to_string()
}
#[derive(serde::Deserialize, Default)]
pub struct PaginationQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct ProfileResponse {
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieStatsDto {
pub total_count: u64,
pub avg_rating: Option<f64>,
pub federated_count: u64,
pub rating_histogram: [u64; 5],
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialReviewDto {
pub user_display: String,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
pub is_federated: bool,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialFeedResponse {
pub items: Vec<SocialReviewDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieDetailResponse {
pub movie: MovieDto,
pub stats: MovieStatsDto,
pub reviews: SocialFeedResponse,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct BlockedDomainResponse {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct AddBlockedDomainRequest {
pub domain: String,
pub reason: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct BlockedActorResponse {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -602,7 +320,7 @@ mod tests {
offset: None,
movie_id: None,
};
let query = GetDiaryQuery::from(params);
let query = to_diary_query(params);
assert!(matches!(
query.sort_by,
Some(domain::models::SortDirection::Ascending)
@@ -617,7 +335,7 @@ mod tests {
offset: None,
movie_id: None,
};
let query = GetDiaryQuery::from(params);
let query = to_diary_query(params);
assert!(matches!(
query.sort_by,
Some(domain::models::SortDirection::Descending)

View File

@@ -31,19 +31,22 @@ use domain::{
};
#[cfg(feature = "federation")]
use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto};
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, DiaryResponse,
DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewRequest, LoginRequest, LoginResponse,
MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto,
PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto,
UserSummaryDto, UserTrendsDto, UsersResponse,
};
use crate::{
dtos::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, ProfileResponse,
RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
},
errors::ApiError,
extractors::AuthenticatedUser,
forms::{to_diary_query, LogReviewData},
state::AppState,
};
@@ -60,7 +63,7 @@ pub async fn get_diary(
State(state): State<AppState>,
Query(params): Query<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, ApiError> {
let page = get_diary::execute(&state.app_ctx, params.into()).await?;
let page = get_diary::execute(&state.app_ctx, to_diary_query(params)).await?;
Ok(Json(DiaryResponse {
items: page.items.iter().map(entry_to_dto).collect(),
@@ -415,7 +418,7 @@ fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
#[utoipa::path(
get, path = "/api/v1/admin/blocked-domains",
responses(
(status = 200, body = Vec<crate::dtos::BlockedDomainResponse>),
(status = 200, body = Vec<BlockedDomainResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — admin only"),
),
@@ -427,9 +430,9 @@ pub async fn get_blocked_domains_admin(
) -> impl IntoResponse {
match state.ap_service.get_blocked_domains().await {
Ok(domains) => {
let response: Vec<crate::dtos::BlockedDomainResponse> = domains
let response: Vec<BlockedDomainResponse> = domains
.into_iter()
.map(|d| crate::dtos::BlockedDomainResponse {
.map(|d| BlockedDomainResponse {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
@@ -444,7 +447,7 @@ pub async fn get_blocked_domains_admin(
#[cfg(feature = "federation")]
#[utoipa::path(
post, path = "/api/v1/admin/blocked-domains",
request_body = crate::dtos::AddBlockedDomainRequest,
request_body = AddBlockedDomainRequest,
responses(
(status = 201, description = "Domain blocked"),
(status = 401, description = "Unauthorized"),
@@ -455,7 +458,7 @@ pub async fn get_blocked_domains_admin(
pub async fn add_blocked_domain_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
axum::Json(body): axum::Json<crate::dtos::AddBlockedDomainRequest>,
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
) -> impl IntoResponse {
match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await {
Ok(()) => StatusCode::CREATED.into_response(),
@@ -531,7 +534,7 @@ pub async fn unblock_actor_api(
#[utoipa::path(
get, path = "/api/v1/social/blocked",
responses(
(status = 200, body = Vec<crate::dtos::BlockedActorResponse>),
(status = 200, body = Vec<BlockedActorResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
@@ -542,9 +545,9 @@ pub async fn get_blocked_actors_api(
) -> impl IntoResponse {
match state.ap_service.get_blocked_actors(user.0.value()).await {
Ok(actors) => {
let response: Vec<crate::dtos::BlockedActorResponse> = actors
let response: Vec<BlockedActorResponse> = actors
.into_iter()
.map(|a| crate::dtos::BlockedActorResponse {
.map(|a| BlockedActorResponse {
url: a.url,
handle: a.handle,
display_name: a.display_name,

View File

@@ -30,12 +30,10 @@ use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId};
#[cfg(feature = "federation")]
use crate::dtos::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm};
use crate::forms::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm};
use crate::{
csrf::CsrfToken,
dtos::{
ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm,
},
forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm},
extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser},
state::AppState,
};
@@ -280,7 +278,7 @@ pub async fn post_delete_review(
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(review_id): Path<Uuid>,
Form(form): Form<crate::dtos::DeleteRedirectForm>,
Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
@@ -311,7 +309,7 @@ pub async fn post_delete_review(
pub async fn get_export(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<crate::dtos::ExportQueryParams>,
Query(params): Query<api_types::ExportQueryParams>,
) -> impl IntoResponse {
let format = match params.format.as_str() {
"csv" => ExportFormat::Csv,
@@ -504,7 +502,7 @@ pub async fn get_user_profile(
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<crate::dtos::ProfileQueryParams>,
Query(params): Query<crate::forms::ProfileQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
// Content negotiation: AP clients request application/activity+json
@@ -800,7 +798,7 @@ pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ErrorQuery>,
Query(params): Query<crate::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
@@ -850,7 +848,7 @@ pub async fn get_followers_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ErrorQuery>,
Query(params): Query<crate::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
@@ -935,7 +933,7 @@ pub async fn get_movie_detail(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<crate::dtos::PaginationQueryParams>,
Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await;

View File

@@ -4,7 +4,11 @@ use axum::{
http::StatusCode,
response::{Html, IntoResponse, Redirect},
};
use serde::{Deserialize, Serialize};
use api_types::{
ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse,
SessionStateResponse,
};
use serde::Deserialize;
use std::collections::HashMap;
use application::{
@@ -465,13 +469,6 @@ pub async fn get_import_done(
// ── REST API handlers ──────────────────────────────────────────────────────
#[derive(Serialize, utoipa::ToSchema)]
pub struct SessionCreatedResponse {
pub session_id: String,
pub columns: Vec<String>,
pub sample_rows: Vec<Vec<String>>,
}
#[utoipa::path(
post, path = "/api/v1/import/sessions",
request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"),
@@ -544,14 +541,6 @@ pub async fn api_post_session(
}
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct SessionStateResponse {
pub session_id: String,
pub columns: Vec<String>,
pub has_mappings: bool,
pub row_count: usize,
}
#[utoipa::path(
get, path = "/api/v1/import/sessions/{id}",
params(("id" = String, Path, description = "Import session UUID")),
@@ -607,23 +596,6 @@ pub async fn api_get_session(
}
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ApiFieldMapping {
/// Column name in the source file
pub source_column: String,
/// Domain field: title | release_year | director | rating | watched_at | comment | external_metadata_id
pub domain_field: String,
/// For rating fields: multiply raw value by this factor (e.g. 0.5 for 10-point → 5-point scale)
pub rating_scale: Option<f64>,
/// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y")
pub date_format: Option<String>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ApplyMappingRequest {
pub mappings: Vec<ApiFieldMapping>,
}
#[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/mapping",
params(("id" = String, Path, description = "Import session UUID")),
@@ -692,12 +664,6 @@ pub async fn api_put_mapping(
}
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ConfirmRequest {
/// Indices (0-based) of rows from the mapping preview to import
pub confirmed_indices: Vec<usize>,
}
#[utoipa::path(
post, path = "/api/v1/import/sessions/{id}/confirm",
params(("id" = String, Path, description = "Import session UUID")),
@@ -776,14 +742,6 @@ pub async fn api_get_profiles(
}
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct SaveProfileRequest {
/// Session UUID whose current field_mappings to save
pub session_id: String,
/// Human-readable profile name (e.g. "Letterboxd")
pub name: String,
}
#[utoipa::path(
post, path = "/api/v1/import/profiles",
request_body = SaveProfileRequest,

View File

@@ -1,5 +1,5 @@
pub mod csrf;
pub mod dtos;
pub mod forms;
pub mod errors;
pub mod extractors;
pub mod handlers;

View File

@@ -11,9 +11,7 @@ use importer::ImporterDocumentParser;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer;
use doc::ApiDocExt;
use presentation::{openapi::ApiDoc, routes, state::AppState};
use utoipa::OpenApi as _;
use presentation::{openapi, routes, state::AppState};
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
@@ -29,7 +27,7 @@ async fn main() -> anyhow::Result<()> {
.await
.context("Failed to wire dependencies")?;
let app = routes::build_router(state, ap_router).with_api_doc(ApiDoc::openapi());
let app = openapi::serve(routes::build_router(state, ap_router));
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());

View File

@@ -1,188 +0,0 @@
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
use crate::dtos::{
ActivityFeedResponse, DiaryEntryDto, DiaryResponse,
DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest,
MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto,
ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse,
SocialReviewDto, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
};
use crate::handlers::import::{
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
SessionCreatedResponse, SessionStateResponse,
};
#[cfg(feature = "federation")]
use crate::dtos::{
ActorListResponse, ActorUrlRequest, BlockedActorResponse, BlockedDomainResponse,
AddBlockedDomainRequest, FollowRequest, RemoteActorDto,
};
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)),
);
}
}
#[cfg(not(feature = "federation"))]
#[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::get_movie_detail,
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_activity_feed,
crate::handlers::api::list_users,
crate::handlers::api::get_user_profile,
crate::handlers::import::api_post_session,
crate::handlers::import::api_get_session,
crate::handlers::import::api_put_mapping,
crate::handlers::import::api_post_confirm,
crate::handlers::import::api_get_profiles,
crate::handlers::import::api_post_profile,
crate::handlers::import::api_delete_profile,
crate::handlers::api::get_profile,
crate::handlers::api::update_profile_handler,
),
components(schemas(
DiaryResponse,
DiaryEntryDto,
MovieDto,
ReviewDto,
LogReviewRequest,
LoginRequest,
LoginResponse,
RegisterRequest,
ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActivityFeedResponse,
FeedEntryDto,
UsersResponse,
UserSummaryDto,
UserProfileResponse,
UserStatsDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
ProfileResponse,
SessionCreatedResponse,
SessionStateResponse,
ApiFieldMapping,
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
)),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;
#[cfg(feature = "federation")]
#[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::get_movie_detail,
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_activity_feed,
crate::handlers::api::list_users,
crate::handlers::api::get_user_profile,
crate::handlers::api::get_following,
crate::handlers::api::get_followers,
crate::handlers::api::get_pending_followers,
crate::handlers::api::follow,
crate::handlers::api::unfollow,
crate::handlers::api::accept_follower,
crate::handlers::api::reject_follower,
crate::handlers::api::remove_follower,
crate::handlers::api::get_profile,
crate::handlers::api::update_profile_handler,
crate::handlers::api::get_blocked_domains_admin,
crate::handlers::api::add_blocked_domain_admin,
crate::handlers::api::remove_blocked_domain_admin,
crate::handlers::api::block_actor_api,
crate::handlers::api::unblock_actor_api,
crate::handlers::api::get_blocked_actors_api,
crate::handlers::import::api_post_session,
crate::handlers::import::api_get_session,
crate::handlers::import::api_put_mapping,
crate::handlers::import::api_post_confirm,
crate::handlers::import::api_get_profiles,
crate::handlers::import::api_post_profile,
crate::handlers::import::api_delete_profile,
),
components(schemas(
DiaryResponse,
DiaryEntryDto,
MovieDto,
ReviewDto,
LogReviewRequest,
LoginRequest,
LoginResponse,
RegisterRequest,
ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActorListResponse,
RemoteActorDto,
FollowRequest,
ActorUrlRequest,
ProfileResponse,
BlockedDomainResponse,
AddBlockedDomainRequest,
BlockedActorResponse,
ActivityFeedResponse,
FeedEntryDto,
UsersResponse,
UserSummaryDto,
UserProfileResponse,
UserStatsDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
SessionCreatedResponse,
SessionStateResponse,
ApiFieldMapping,
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
)),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;

View File

@@ -0,0 +1,12 @@
use api_types::{LoginRequest, LoginResponse, RegisterRequest};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::login,
crate::handlers::api::register,
),
components(schemas(LoginRequest, LoginResponse, RegisterRequest)),
)]
pub struct AuthDoc;

View File

@@ -0,0 +1,22 @@
use api_types::{ActivityFeedResponse, DiaryEntryDto, DiaryResponse, FeedEntryDto, LogReviewRequest, ReviewDto};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::get_diary,
crate::handlers::api::post_review,
crate::handlers::api::delete_review,
crate::handlers::api::export_diary,
crate::handlers::api::get_activity_feed,
),
components(schemas(
DiaryResponse,
DiaryEntryDto,
ReviewDto,
LogReviewRequest,
ActivityFeedResponse,
FeedEntryDto,
)),
)]
pub struct DiaryDoc;

View File

@@ -0,0 +1,27 @@
use api_types::{
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
SessionCreatedResponse, SessionStateResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::import::api_post_session,
crate::handlers::import::api_get_session,
crate::handlers::import::api_put_mapping,
crate::handlers::import::api_post_confirm,
crate::handlers::import::api_get_profiles,
crate::handlers::import::api_post_profile,
crate::handlers::import::api_delete_profile,
),
components(schemas(
SessionCreatedResponse,
SessionStateResponse,
ApiFieldMapping,
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
)),
)]
pub struct ImportDoc;

View File

@@ -0,0 +1,51 @@
mod auth;
mod diary;
mod import;
mod movies;
mod social;
mod users;
use axum::Router;
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
use utoipa_scalar::{Scalar, Servable};
use utoipa_swagger_ui::SwaggerUi;
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)),
);
}
}
fn build() -> utoipa::openapi::OpenApi {
let mut api = auth::AuthDoc::openapi();
api.info = utoipa::openapi::InfoBuilder::new()
.title("Movies Diary API")
.version("1.0.0")
.description(Some("REST API for Movies Diary. Authenticate with `POST /api/v1/auth/login` to get a Bearer token."))
.build();
api.merge(diary::DiaryDoc::openapi());
api.merge(movies::MoviesDoc::openapi());
api.merge(users::UsersDoc::openapi());
api.merge(import::ImportDoc::openapi());
#[cfg(feature = "federation")]
api.merge(social::SocialDoc::openapi());
SecurityAddon.modify(&mut api);
api
}
pub fn serve(router: Router) -> Router {
tracing::info!("API docs at /docs (Swagger) and /scalar");
let spec = build();
router
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
.merge(Scalar::with_url("/scalar", spec))
}

View File

@@ -0,0 +1,27 @@
use api_types::{
DirectorStatDto, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto,
MovieStatsDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserTrendsDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::get_movie_detail,
crate::handlers::api::get_review_history,
crate::handlers::api::sync_poster,
),
components(schemas(
MovieDto,
MovieDetailResponse,
MovieStatsDto,
ReviewHistoryResponse,
SocialFeedResponse,
SocialReviewDto,
MonthActivityDto,
MonthlyRatingDto,
DirectorStatDto,
UserTrendsDto,
)),
)]
pub struct MoviesDoc;

View File

@@ -0,0 +1,38 @@
#[cfg(feature = "federation")]
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
};
#[cfg(feature = "federation")]
use utoipa::OpenApi;
#[cfg(feature = "federation")]
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::get_following,
crate::handlers::api::get_followers,
crate::handlers::api::get_pending_followers,
crate::handlers::api::follow,
crate::handlers::api::unfollow,
crate::handlers::api::accept_follower,
crate::handlers::api::reject_follower,
crate::handlers::api::remove_follower,
crate::handlers::api::get_blocked_domains_admin,
crate::handlers::api::add_blocked_domain_admin,
crate::handlers::api::remove_blocked_domain_admin,
crate::handlers::api::block_actor_api,
crate::handlers::api::unblock_actor_api,
crate::handlers::api::get_blocked_actors_api,
),
components(schemas(
ActorListResponse,
RemoteActorDto,
FollowRequest,
ActorUrlRequest,
BlockedDomainResponse,
AddBlockedDomainRequest,
BlockedActorResponse,
)),
)]
pub struct SocialDoc;

View File

@@ -0,0 +1,20 @@
use api_types::{ProfileResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::list_users,
crate::handlers::api::get_user_profile,
crate::handlers::api::get_profile,
crate::handlers::api::update_profile_handler,
),
components(schemas(
UsersResponse,
UserSummaryDto,
UserProfileResponse,
UserStatsDto,
ProfileResponse,
)),
)]
pub struct UsersDoc;

View File

@@ -24,6 +24,7 @@ apple-native-keyring-store = { version = "1.0.0", optional = true, features = [
zbus-secret-service-keyring-store = { version = "1.0.0", optional = true }
windows-native-keyring-store = { version = "1.0.0", optional = true }
api-types = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -32,5 +33,3 @@ anyhow = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -1,4 +1,4 @@
use crate::client::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse};
use api_types::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse};
use crate::config::Config;
use uuid::Uuid;
@@ -995,7 +995,7 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{DiaryEntryDto, MovieDto, ReviewDto};
use api_types::{DiaryEntryDto, MovieDto, ReviewDto};
use uuid::Uuid;
fn setup_app() -> App {
@@ -1038,6 +1038,7 @@ mod tests {
title: "The Matrix".into(),
release_year: 1999,
director: None,
poster_path: None,
},
review: ReviewDto {
id: Uuid::new_v4(),

View File

@@ -1,92 +1,9 @@
use serde::{Deserialize, Serialize};
use api_types::{
ActorListResponse, ActorUrlRequest, DiaryResponse, FollowRequest, LogReviewRequest,
LoginRequest, LoginResponse, ReviewHistoryResponse,
};
use uuid::Uuid;
// ── DTOs (mirror backend dtos.rs exactly) ────────────────────────────────────
#[derive(Debug, Clone, Serialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoginResponse {
pub token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogReviewRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub external_metadata_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_release_year: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_director: Option<String>,
pub rating: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DiaryEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MovieDto {
pub id: Uuid,
pub title: String,
pub release_year: u16,
pub director: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReviewDto {
pub id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReviewHistoryResponse {
pub movie: MovieDto,
pub viewings: Vec<ReviewDto>,
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<String>,
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActorListResponse {
pub actors: Vec<RemoteActorDto>,
}
// ── Error ─────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]

View File

@@ -17,16 +17,9 @@ domain = { workspace = true }
application = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
futures = { workspace = true }
dotenvy = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
auth = { workspace = true }
metadata = { workspace = true }
poster-fetcher = { workspace = true }