feat: frontend-ready backend — pagination, auto-derivatives, list endpoints, bulk ops, OpenAPI

Pagination: count_by_owner + count_search on AssetRepository,
timeline/search return real total count (not page len).

Auto-derivatives: worker enqueues GenerateDerivative when
ExtractMetadata job completes, closing the upload→thumbnail gap.

List endpoints: GET /albums, GET /stacks with user scoping.
ListAlbumsHandler, ListStacksHandler, find_by_owner on AssetStackRepository.

Tag filtering: tag_name field on AssetFilters, JOIN asset_tags+tags
in postgres search/count queries.

Bulk operations: POST /assets/bulk-delete, POST /assets/bulk-tag.

Album update: PUT /albums/{id} with UpdateAlbumHandler (title, description).

OpenAPI: utoipa annotations on all 47 endpoints + all request/response
schemas registered. Scalar UI at /scalar covers full API.
This commit is contained in:
2026-05-31 23:06:25 +02:00
parent bcaf49cc81
commit 7b5bb66b37
33 changed files with 1048 additions and 72 deletions

View File

@@ -1,10 +1,12 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use api_types::requests::UpdateAlbumRequest;
use api_types::{
requests::{AlbumEntryRequest, CreateAlbumRequest},
responses::AlbumResponse,
};
use application::organization::{
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ManageAlbumEntriesCommand,
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ListAlbumsQuery, ManageAlbumEntriesCommand,
UpdateAlbumCommand,
};
use axum::{
Json,
@@ -13,6 +15,35 @@ use axum::{
};
use domain::value_objects::SystemId;
#[utoipa::path(
get, path = "/api/v1/albums",
security(("bearer_token" = [])),
responses(
(status = 200, description = "List of albums", body = Vec<AlbumResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_albums(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<Vec<AlbumResponse>>, AppError> {
let query = ListAlbumsQuery {
user_id: claims.user_id,
};
let albums = state.organization.list_albums.execute(query).await?;
let resp = albums.iter().map(AlbumResponse::from_domain).collect();
Ok(Json(resp))
}
#[utoipa::path(
post, path = "/api/v1/albums",
request_body = CreateAlbumRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Album created", body = AlbumResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn create_album(
State(state): State<AppState>,
claims: JwtClaims,
@@ -29,6 +60,15 @@ pub async fn create_album(
))
}
#[utoipa::path(
get, path = "/api/v1/albums/{id}",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Album ID")),
responses(
(status = 200, description = "Album details", body = AlbumResponse),
(status = 404, description = "Not found")
)
)]
pub async fn get_album(
State(state): State<AppState>,
claims: JwtClaims,
@@ -42,6 +82,42 @@ pub async fn get_album(
Ok(Json(AlbumResponse::from_domain(&album)))
}
#[utoipa::path(
put, path = "/api/v1/albums/{id}",
request_body = UpdateAlbumRequest,
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Album ID")),
responses(
(status = 200, description = "Album updated", body = AlbumResponse),
(status = 404, description = "Not found")
)
)]
pub async fn update_album(
State(state): State<AppState>,
claims: JwtClaims,
Path((album_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<UpdateAlbumRequest>,
) -> Result<Json<AlbumResponse>, AppError> {
let cmd = UpdateAlbumCommand {
album_id: SystemId::from_uuid(album_id),
user_id: claims.user_id,
title: req.title,
description: req.description,
};
let album = state.organization.update_album.execute(cmd).await?;
Ok(Json(AlbumResponse::from_domain(&album)))
}
#[utoipa::path(
post, path = "/api/v1/albums/{id}/entries",
request_body = AlbumEntryRequest,
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Album ID")),
responses(
(status = 201, description = "Entry added", body = AlbumResponse),
(status = 404, description = "Not found")
)
)]
pub async fn add_entry(
State(state): State<AppState>,
claims: JwtClaims,
@@ -62,6 +138,18 @@ pub async fn add_entry(
))
}
#[utoipa::path(
delete, path = "/api/v1/albums/{id}/entries/{asset_id}",
security(("bearer_token" = [])),
params(
("id" = uuid::Uuid, Path, description = "Album ID"),
("asset_id" = uuid::Uuid, Path, description = "Asset ID")
),
responses(
(status = 200, description = "Entry removed", body = AlbumResponse),
(status = 404, description = "Not found")
)
)]
pub async fn remove_entry(
State(state): State<AppState>,
claims: JwtClaims,

View File

@@ -44,10 +44,29 @@ pub struct SearchParams {
pub date_from: Option<String>,
pub date_to: Option<String>,
pub is_processed: Option<bool>,
pub tag: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[utoipa::path(
get, path = "/api/v1/assets",
security(("bearer_token" = [])),
params(
("type" = Option<String>, Query, description = "Asset type filter"),
("mime_type" = Option<String>, Query, description = "MIME type filter"),
("date_from" = Option<String>, Query, description = "Start date (YYYY-MM-DD)"),
("date_to" = Option<String>, Query, description = "End date (YYYY-MM-DD)"),
("is_processed" = Option<bool>, Query, description = "Processed filter"),
("tag" = Option<String>, Query, description = "Tag name filter"),
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Search results", body = TimelineResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn search_assets(
State(state): State<AppState>,
claims: JwtClaims,
@@ -89,6 +108,7 @@ pub async fn search_assets(
date_from,
date_to,
is_processed: params.is_processed,
tag_name: params.tag,
};
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
@@ -100,15 +120,27 @@ pub async fn search_assets(
limit,
offset,
};
let results = state.catalog.search_assets.execute(query).await?;
let total = results.len();
let assets = results
let result = state.catalog.search_assets.execute(query).await?;
let assets = result
.items
.iter()
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
.collect();
Ok(Json(TimelineResponse { assets, total }))
Ok(Json(TimelineResponse {
assets,
total: result.total,
}))
}
#[utoipa::path(
post, path = "/api/v1/assets/ingest",
security(("bearer_token" = [])),
request_body(content_type = "multipart/form-data"),
responses(
(status = 201, description = "Asset ingested", body = IngestResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn ingest(
State(state): State<AppState>,
claims: JwtClaims,
@@ -136,6 +168,15 @@ pub async fn ingest(
))
}
#[utoipa::path(
get, path = "/api/v1/assets/{id}",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 200, description = "Asset details", body = AssetResponse),
(status = 404, description = "Not found")
)
)]
pub async fn get_asset(
State(state): State<AppState>,
claims: JwtClaims,
@@ -149,6 +190,18 @@ pub async fn get_asset(
Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
}
#[utoipa::path(
get, path = "/api/v1/assets/timeline",
security(("bearer_token" = [])),
params(
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Timeline view", body = TimelineResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn timeline(
State(state): State<AppState>,
claims: JwtClaims,
@@ -160,15 +213,28 @@ pub async fn timeline(
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
offset: params.offset.unwrap_or(0),
};
let results = state.catalog.get_timeline.execute(query).await?;
let total = results.len();
let assets = results
let result = state.catalog.get_timeline.execute(query).await?;
let assets = result
.items
.iter()
.map(|(asset, meta)| AssetResponse::from_domain(asset, meta))
.collect();
Ok(Json(TimelineResponse { assets, total }))
Ok(Json(TimelineResponse {
assets,
total: result.total,
}))
}
#[utoipa::path(
put, path = "/api/v1/assets/{id}/metadata",
request_body = api_types::requests::UpdateMetadataRequest,
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 200, description = "Metadata updated"),
(status = 404, description = "Not found")
)
)]
pub async fn update_metadata(
State(state): State<AppState>,
claims: JwtClaims,
@@ -189,6 +255,15 @@ pub async fn update_metadata(
Ok(Json(serde_json::json!({ "status": "updated" })))
}
#[utoipa::path(
get, path = "/api/v1/assets/{id}/file",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 200, description = "File content", content_type = "application/octet-stream"),
(status = 404, description = "Not found")
)
)]
pub async fn serve_file(
State(state): State<AppState>,
claims: JwtClaims,
@@ -212,6 +287,16 @@ pub async fn serve_file(
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string())))
}
#[utoipa::path(
post, path = "/api/v1/assets/{id}/tags",
request_body = TagAssetRequest,
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 201, description = "Tag applied", body = TagResponse),
(status = 404, description = "Not found")
)
)]
pub async fn tag_asset(
State(state): State<AppState>,
claims: JwtClaims,
@@ -227,6 +312,15 @@ pub async fn tag_asset(
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag))))
}
#[utoipa::path(
delete, path = "/api/v1/assets/{id}",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 204, description = "Asset deleted"),
(status = 404, description = "Not found")
)
)]
pub async fn delete_asset(
State(state): State<AppState>,
claims: JwtClaims,
@@ -240,6 +334,18 @@ pub async fn delete_asset(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/assets/{id}/derivatives/{profile}",
security(("bearer_token" = [])),
params(
("id" = uuid::Uuid, Path, description = "Asset ID"),
("profile" = String, Path, description = "Derivative profile")
),
responses(
(status = 200, description = "Derivative content", content_type = "application/octet-stream"),
(status = 404, description = "Not found")
)
)]
pub async fn serve_derivative(
State(state): State<AppState>,
claims: JwtClaims,
@@ -262,6 +368,15 @@ pub async fn serve_derivative(
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))
}
#[utoipa::path(
post, path = "/api/v1/assets/register",
request_body = RegisterAssetRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Asset registered", body = AssetResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn register_asset(
State(state): State<AppState>,
claims: JwtClaims,
@@ -283,3 +398,56 @@ pub async fn register_asset(
Json(AssetResponse::from_domain(&asset, &StructuredData::new())),
))
}
#[utoipa::path(
post, path = "/api/v1/assets/bulk-delete",
request_body = api_types::requests::BulkDeleteRequest,
security(("bearer_token" = [])),
responses(
(status = 200, description = "Assets deleted"),
(status = 401, description = "Unauthorized")
)
)]
pub async fn bulk_delete(
State(state): State<AppState>,
claims: JwtClaims,
Json(req): Json<api_types::requests::BulkDeleteRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut deleted = 0u32;
for id in req.asset_ids {
let cmd = DeleteAssetCommand {
asset_id: SystemId::from_uuid(id),
deleted_by: claims.user_id,
};
state.catalog.delete_asset.execute(cmd).await?;
deleted += 1;
}
Ok(Json(serde_json::json!({ "deleted": deleted })))
}
#[utoipa::path(
post, path = "/api/v1/assets/bulk-tag",
request_body = api_types::requests::BulkTagRequest,
security(("bearer_token" = [])),
responses(
(status = 200, description = "Assets tagged"),
(status = 401, description = "Unauthorized")
)
)]
pub async fn bulk_tag(
State(state): State<AppState>,
claims: JwtClaims,
Json(req): Json<api_types::requests::BulkTagRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut tagged = 0u32;
for id in req.asset_ids {
let cmd = application::organization::TagAssetCommand {
asset_id: SystemId::from_uuid(id),
tag_name: req.tag_name.clone(),
user_id: claims.user_id,
};
state.organization.tag_asset.execute(cmd).await?;
tagged += 1;
}
Ok(Json(serde_json::json!({ "tagged": tagged })))
}

View File

@@ -92,6 +92,14 @@ pub async fn me(
Ok(Json(UserResponse::from_domain(&user)))
}
#[utoipa::path(
post, path = "/api/v1/auth/refresh",
request_body = RefreshTokenRequest,
responses(
(status = 200, description = "Token refreshed", body = AuthResponse),
(status = 401, description = "Invalid refresh token")
)
)]
pub async fn refresh(
State(state): State<AppState>,
ValidatedJson(req): ValidatedJson<RefreshTokenRequest>,
@@ -113,6 +121,14 @@ pub async fn refresh(
}))
}
#[utoipa::path(
post, path = "/api/v1/auth/logout",
security(("bearer_token" = [])),
responses(
(status = 204, description = "Logged out"),
(status = 401, description = "Unauthorized")
)
)]
pub async fn logout(
State(state): State<AppState>,
claims: JwtClaims,

View File

@@ -19,6 +19,18 @@ pub struct ListDuplicatesParams {
pub offset: Option<u32>,
}
#[utoipa::path(
get, path = "/api/v1/duplicates",
security(("bearer_token" = [])),
params(
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Duplicate groups", body = Vec<DuplicateGroupResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_duplicates(
State(state): State<AppState>,
claims: JwtClaims,
@@ -37,6 +49,16 @@ pub async fn list_duplicates(
Ok(Json(resp))
}
#[utoipa::path(
post, path = "/api/v1/duplicates/{id}/resolve",
request_body = ResolveDuplicateRequest,
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Duplicate group ID")),
responses(
(status = 200, description = "Duplicate resolved"),
(status = 404, description = "Not found")
)
)]
pub async fn resolve_duplicate(
State(state): State<AppState>,
claims: JwtClaims,

View File

@@ -40,6 +40,19 @@ pub struct ListJobsParams {
pub offset: Option<u32>,
}
#[utoipa::path(
get, path = "/api/v1/jobs",
security(("bearer_token" = [])),
params(
("status" = Option<String>, Query, description = "Status filter"),
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Job list", body = JobListResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_jobs(
State(state): State<AppState>,
claims: JwtClaims,
@@ -59,6 +72,15 @@ pub async fn list_jobs(
}))
}
#[utoipa::path(
post, path = "/api/v1/jobs",
request_body = EnqueueJobRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Job enqueued", body = JobResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn enqueue_job(
State(state): State<AppState>,
claims: JwtClaims,
@@ -82,6 +104,15 @@ pub async fn enqueue_job(
Ok((StatusCode::CREATED, Json(JobResponse::from_domain(&job))))
}
#[utoipa::path(
post, path = "/api/v1/jobs/{id}/start",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Job ID")),
responses(
(status = 200, description = "Job started", body = JobResponse),
(status = 404, description = "Not found")
)
)]
pub async fn start_job(
State(state): State<AppState>,
claims: JwtClaims,
@@ -95,6 +126,16 @@ pub async fn start_job(
Ok(Json(JobResponse::from_domain(&job)))
}
#[utoipa::path(
post, path = "/api/v1/jobs/{id}/complete",
request_body = CompleteJobRequest,
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Job ID")),
responses(
(status = 200, description = "Job completed", body = JobResponse),
(status = 404, description = "Not found")
)
)]
pub async fn complete_job(
State(state): State<AppState>,
claims: JwtClaims,
@@ -110,6 +151,16 @@ pub async fn complete_job(
Ok(Json(JobResponse::from_domain(&job)))
}
#[utoipa::path(
post, path = "/api/v1/jobs/{id}/fail",
request_body = FailJobRequest,
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Job ID")),
responses(
(status = 200, description = "Job failed", body = JobResponse),
(status = 404, description = "Not found")
)
)]
pub async fn fail_job(
State(state): State<AppState>,
claims: JwtClaims,
@@ -125,6 +176,15 @@ pub async fn fail_job(
Ok(Json(JobResponse::from_domain(&job)))
}
#[utoipa::path(
get, path = "/api/v1/jobs/batches/{id}",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Batch ID")),
responses(
(status = 200, description = "Batch progress", body = BatchProgressResponse),
(status = 404, description = "Not found")
)
)]
pub async fn batch_progress(
State(state): State<AppState>,
claims: JwtClaims,
@@ -138,6 +198,15 @@ pub async fn batch_progress(
Ok(Json(BatchProgressResponse::from_domain(&progress)))
}
#[utoipa::path(
post, path = "/api/v1/plugins",
request_body = ManagePluginRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Plugin managed", body = PluginResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn manage_plugin(
State(state): State<AppState>,
claims: JwtClaims,
@@ -182,6 +251,15 @@ pub async fn manage_plugin(
))
}
#[utoipa::path(
post, path = "/api/v1/pipelines",
request_body = ConfigurePipelineRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Pipeline configured", body = PipelineResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn configure_pipeline(
State(state): State<AppState>,
claims: JwtClaims,

View File

@@ -15,6 +15,15 @@ use domain::value_objects::{DateTimeStamp, SystemId};
const DEFAULT_ACCESS_LEVEL: &str = "view_only";
#[utoipa::path(
post, path = "/api/v1/sharing",
request_body = ShareResourceRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Resource shared", body = ShareScopeResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn share_resource(
State(state): State<AppState>,
claims: JwtClaims,
@@ -38,6 +47,15 @@ pub async fn share_resource(
))
}
#[utoipa::path(
post, path = "/api/v1/sharing/links",
request_body = GenerateShareLinkRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Share link generated", body = ShareLinkResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn generate_link(
State(state): State<AppState>,
claims: JwtClaims,
@@ -65,6 +83,15 @@ pub async fn generate_link(
))
}
#[utoipa::path(
delete, path = "/api/v1/sharing/{id}",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Share scope ID")),
responses(
(status = 204, description = "Share revoked"),
(status = 404, description = "Not found")
)
)]
pub async fn revoke(
State(state): State<AppState>,
claims: JwtClaims,
@@ -78,6 +105,14 @@ pub async fn revoke(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/sharing/access/{token}",
params(("token" = String, Path, description = "Share token")),
responses(
(status = 200, description = "Shared resource", body = SharedResourceResponse),
(status = 404, description = "Invalid token")
)
)]
pub async fn access_by_token(
State(state): State<AppState>,
Path((token,)): Path<(String,)>,

View File

@@ -10,6 +10,15 @@ use axum::{
};
use domain::value_objects::SystemId;
#[utoipa::path(
post, path = "/api/v1/sidecar/export/{asset_id}",
security(("bearer_token" = [])),
params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 200, description = "Sidecar exported", body = SidecarExportResponse),
(status = 404, description = "Not found")
)
)]
pub async fn export_sidecar(
State(state): State<AppState>,
claims: JwtClaims,
@@ -23,6 +32,14 @@ pub async fn export_sidecar(
Ok(Json(SidecarExportResponse::from_domain(&record)))
}
#[utoipa::path(
post, path = "/api/v1/sidecar/detect-changes",
security(("bearer_token" = [])),
responses(
(status = 200, description = "Changes detected", body = DetectChangesResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn detect_changes(
State(state): State<AppState>,
claims: JwtClaims,
@@ -38,6 +55,15 @@ pub async fn detect_changes(
}))
}
#[utoipa::path(
post, path = "/api/v1/sidecar/import/{asset_id}",
security(("bearer_token" = [])),
params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 200, description = "Sidecar imported", body = SidecarImportResponse),
(status = 404, description = "Not found")
)
)]
pub async fn import_sidecar(
State(state): State<AppState>,
claims: JwtClaims,
@@ -54,6 +80,16 @@ pub async fn import_sidecar(
}))
}
#[utoipa::path(
post, path = "/api/v1/sidecar/resolve/{asset_id}",
request_body = api_types::requests::ResolveConflictRequest,
security(("bearer_token" = [])),
params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")),
responses(
(status = 200, description = "Conflict resolved", body = SidecarExportResponse),
(status = 404, description = "Not found")
)
)]
pub async fn resolve_conflict(
State(state): State<AppState>,
claims: JwtClaims,
@@ -70,6 +106,14 @@ pub async fn resolve_conflict(
Ok(Json(SidecarExportResponse::from_domain(&record)))
}
#[utoipa::path(
post, path = "/api/v1/sidecar/full-export",
security(("bearer_token" = [])),
responses(
(status = 200, description = "Full export completed", body = DetectChangesResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn full_export(
State(state): State<AppState>,
claims: JwtClaims,
@@ -83,6 +127,14 @@ pub async fn full_export(
}))
}
#[utoipa::path(
post, path = "/api/v1/sidecar/full-import",
security(("bearer_token" = [])),
responses(
(status = 200, description = "Full import completed", body = DetectChangesResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn full_import(
State(state): State<AppState>,
claims: JwtClaims,

View File

@@ -1,7 +1,7 @@
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::{requests::CreateStackRequest, responses::StackResponse};
use application::catalog::{
CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery,
CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery, ListStacksQuery,
};
use axum::{
Json,
@@ -10,6 +10,35 @@ use axum::{
};
use domain::value_objects::SystemId;
#[utoipa::path(
get, path = "/api/v1/stacks",
security(("bearer_token" = [])),
responses(
(status = 200, description = "List of stacks", body = Vec<StackResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_stacks(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<Vec<StackResponse>>, AppError> {
let query = ListStacksQuery {
owner_id: claims.user_id,
};
let stacks = state.catalog.list_stacks.execute(query).await?;
let resp = stacks.iter().map(StackResponse::from_domain).collect();
Ok(Json(resp))
}
#[utoipa::path(
post, path = "/api/v1/stacks",
request_body = CreateStackRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Stack created", body = StackResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn create_stack(
State(state): State<AppState>,
claims: JwtClaims,
@@ -38,6 +67,15 @@ pub async fn create_stack(
))
}
#[utoipa::path(
get, path = "/api/v1/stacks/{id}",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Stack ID")),
responses(
(status = 200, description = "Stack details", body = StackResponse),
(status = 404, description = "Not found")
)
)]
pub async fn get_stack(
State(state): State<AppState>,
claims: JwtClaims,
@@ -51,6 +89,15 @@ pub async fn get_stack(
Ok(Json(StackResponse::from_domain(&stack)))
}
#[utoipa::path(
delete, path = "/api/v1/stacks/{id}",
security(("bearer_token" = [])),
params(("id" = uuid::Uuid, Path, description = "Stack ID")),
responses(
(status = 204, description = "Stack deleted"),
(status = 404, description = "Not found")
)
)]
pub async fn delete_stack(
State(state): State<AppState>,
claims: JwtClaims,
@@ -64,6 +111,14 @@ pub async fn delete_stack(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post, path = "/api/v1/stacks/detect-live-photos",
security(("bearer_token" = [])),
responses(
(status = 200, description = "Detected live photo stacks", body = Vec<StackResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn detect_live_photos(
State(state): State<AppState>,
claims: JwtClaims,

View File

@@ -11,6 +11,15 @@ use axum::{
};
use domain::value_objects::SystemId;
#[utoipa::path(
post, path = "/api/v1/storage/volumes",
request_body = RegisterVolumeRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Volume registered", body = VolumeResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn register_volume(
State(state): State<AppState>,
claims: JwtClaims,
@@ -29,6 +38,15 @@ pub async fn register_volume(
))
}
#[utoipa::path(
post, path = "/api/v1/storage/library-paths",
request_body = RegisterLibraryPathRequest,
security(("bearer_token" = [])),
responses(
(status = 201, description = "Library path registered", body = LibraryPathResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn register_library_path(
State(state): State<AppState>,
claims: JwtClaims,
@@ -51,6 +69,18 @@ pub async fn register_library_path(
const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes";
const DEFAULT_QUOTA_AMOUNT: u64 = 0;
#[utoipa::path(
get, path = "/api/v1/storage/quota",
security(("bearer_token" = [])),
params(
("usage_type" = Option<String>, Query, description = "Usage type"),
("amount" = Option<u64>, Query, description = "Requested amount")
),
responses(
(status = 200, description = "Quota check result", body = QuotaCheckResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn check_quota(
State(state): State<AppState>,
claims: JwtClaims,

View File

@@ -13,15 +13,111 @@ use utoipa_scalar::{Scalar, Servable};
crate::handlers::auth::register,
crate::handlers::auth::login,
crate::handlers::auth::me,
crate::handlers::auth::refresh,
crate::handlers::auth::logout,
crate::handlers::albums::list_albums,
crate::handlers::albums::create_album,
crate::handlers::albums::get_album,
crate::handlers::albums::update_album,
crate::handlers::albums::add_entry,
crate::handlers::albums::remove_entry,
crate::handlers::assets::search_assets,
crate::handlers::assets::ingest,
crate::handlers::assets::timeline,
crate::handlers::assets::get_asset,
crate::handlers::assets::update_metadata,
crate::handlers::assets::serve_file,
crate::handlers::assets::serve_derivative,
crate::handlers::assets::tag_asset,
crate::handlers::assets::delete_asset,
crate::handlers::assets::register_asset,
crate::handlers::assets::bulk_delete,
crate::handlers::assets::bulk_tag,
crate::handlers::stacks::list_stacks,
crate::handlers::stacks::create_stack,
crate::handlers::stacks::get_stack,
crate::handlers::stacks::delete_stack,
crate::handlers::stacks::detect_live_photos,
crate::handlers::duplicates::list_duplicates,
crate::handlers::duplicates::resolve_duplicate,
crate::handlers::sharing::share_resource,
crate::handlers::sharing::generate_link,
crate::handlers::sharing::revoke,
crate::handlers::sharing::access_by_token,
crate::handlers::storage::register_volume,
crate::handlers::storage::register_library_path,
crate::handlers::storage::check_quota,
crate::handlers::processing::list_jobs,
crate::handlers::processing::enqueue_job,
crate::handlers::processing::start_job,
crate::handlers::processing::complete_job,
crate::handlers::processing::fail_job,
crate::handlers::processing::batch_progress,
crate::handlers::processing::manage_plugin,
crate::handlers::processing::configure_pipeline,
crate::handlers::sidecar::export_sidecar,
crate::handlers::sidecar::detect_changes,
crate::handlers::sidecar::import_sidecar,
crate::handlers::sidecar::resolve_conflict,
crate::handlers::sidecar::full_export,
crate::handlers::sidecar::full_import,
),
components(schemas(
api_types::requests::RegisterRequest,
api_types::requests::LoginRequest,
api_types::requests::RefreshTokenRequest,
api_types::requests::CreateAlbumRequest,
api_types::requests::UpdateAlbumRequest,
api_types::requests::AlbumEntryRequest,
api_types::requests::RegisterAssetRequest,
api_types::requests::UpdateMetadataRequest,
api_types::requests::TagAssetRequest,
api_types::requests::BulkDeleteRequest,
api_types::requests::BulkTagRequest,
api_types::requests::CreateStackRequest,
api_types::requests::StackMemberRequest,
api_types::requests::ResolveDuplicateRequest,
api_types::requests::ShareResourceRequest,
api_types::requests::GenerateShareLinkRequest,
api_types::requests::RegisterVolumeRequest,
api_types::requests::RegisterLibraryPathRequest,
api_types::requests::CheckQuotaParams,
api_types::requests::EnqueueJobRequest,
api_types::requests::CompleteJobRequest,
api_types::requests::FailJobRequest,
api_types::requests::ManagePluginRequest,
api_types::requests::ConfigurePipelineRequest,
api_types::requests::PipelineStepRequest,
api_types::responses::AuthResponse,
api_types::responses::UserResponse,
api_types::responses::AlbumResponse,
api_types::responses::AssetResponse,
api_types::responses::TimelineResponse,
api_types::responses::IngestResponse,
api_types::responses::TagResponse,
api_types::responses::StackResponse,
api_types::responses::StackMemberResponse,
api_types::responses::DuplicateGroupResponse,
api_types::responses::DuplicateCandidateResponse,
api_types::responses::ShareScopeResponse,
api_types::responses::ShareLinkResponse,
api_types::responses::SharedResourceResponse,
api_types::responses::VolumeResponse,
api_types::responses::LibraryPathResponse,
api_types::responses::QuotaCheckResponse,
api_types::responses::JobResponse,
api_types::responses::JobListResponse,
api_types::responses::BatchProgressResponse,
api_types::responses::PluginResponse,
api_types::responses::PipelineResponse,
api_types::responses::SidecarExportResponse,
api_types::responses::DetectChangesResponse,
api_types::responses::SidecarImportResponse,
api_types::requests::ResolveConflictRequest,
)),
modifiers(&SecurityAddon),
info(title = "k-template", version = "0.1.0")
info(title = "K-Photos API", version = "0.1.0",
description = "Self-hosted photo management API")
)]
pub struct ApiDoc;

View File

@@ -26,8 +26,14 @@ fn protected_routes(state: &AppState) -> Router<AppState> {
.route("/auth/me", get(auth::me))
.route("/auth/logout", post(auth::logout))
// albums
.route("/albums", post(albums::create_album))
.route("/albums/{id}", get(albums::get_album))
.route(
"/albums",
get(albums::list_albums).post(albums::create_album),
)
.route(
"/albums/{id}",
get(albums::get_album).put(albums::update_album),
)
.route("/albums/{id}/entries", post(albums::add_entry))
.route(
"/albums/{id}/entries/{asset_id}",
@@ -49,8 +55,13 @@ fn protected_routes(state: &AppState) -> Router<AppState> {
get(assets::serve_derivative),
)
.route("/assets/{id}/tags", post(assets::tag_asset))
.route("/assets/bulk-delete", post(assets::bulk_delete))
.route("/assets/bulk-tag", post(assets::bulk_tag))
// stacks
.route("/stacks", post(stacks::create_stack))
.route(
"/stacks",
get(stacks::list_stacks).post(stacks::create_stack),
)
.route(
"/stacks/detect-live-photos",
post(stacks::detect_live_photos),

View File

@@ -4,15 +4,16 @@ use application::{
catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
SearchAssetsHandler, UpdateMetadataHandler,
ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler,
ResolveDuplicateHandler, SearchAssetsHandler, UpdateMetadataHandler,
},
identity::{
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
RegisterUserHandler,
},
organization::{
CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler,
CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
},
processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
@@ -59,13 +60,16 @@ pub struct CatalogHandlers {
pub get_stack: Arc<GetStackHandler>,
pub delete_stack: Arc<DeleteStackHandler>,
pub detect_live_photos: Arc<DetectLivePhotosHandler>,
pub list_stacks: Arc<ListStacksHandler>,
}
#[derive(Clone)]
pub struct OrganizationHandlers {
pub create_album: Arc<CreateAlbumHandler>,
pub get_album: Arc<GetAlbumHandler>,
pub list_albums: Arc<ListAlbumsHandler>,
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,
pub update_album: Arc<UpdateAlbumHandler>,
pub tag_asset: Arc<TagAssetHandler>,
}