From 7b9b0f9ffe74b8ee8bfe83419b9f52f3d7ef9b53 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 4 Jun 2026 01:59:09 +0200 Subject: [PATCH] feat: API preview endpoint for import sessions --- crates/presentation/src/handlers/import.rs | 64 ++++++++++++++++++++++ crates/presentation/src/routes.rs | 38 ++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index 235edfd..68f8446 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -635,6 +635,70 @@ pub async fn api_put_mapping( } } +pub async fn api_get_preview( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid session id"})), + ) + .into_response(); + }; + match state + .app_ctx + .repos + .import_session + .get(&session_id, &user_id) + .await + { + Ok(Some(session)) => { + let annotated: Vec = + session.row_results.unwrap_or_default(); + let rows: Vec = annotated + .iter() + .enumerate() + .map(|(i, a)| { + use domain::models::import::RowResult; + match &a.result { + RowResult::Valid(row) => serde_json::json!({ + "index": i, + "status": if a.is_duplicate { "duplicate" } else { "valid" }, + "title": row.title, + "release_year": row.release_year, + "director": row.director, + "rating": row.rating, + "watched_at": row.watched_at, + "comment": row.comment, + }), + RowResult::Invalid { errors, .. } => serde_json::json!({ + "index": i, + "status": "invalid", + "errors": errors, + }), + } + }) + .collect(); + axum::Json(serde_json::json!({ "rows": rows })).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + axum::Json(serde_json::json!({"error": "session not found"})), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + #[utoipa::path( post, path = "/api/v1/import/sessions/{id}/confirm", params(("id" = String, Path, description = "Import session UUID")), diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index f5a80d0..4f5b2e1 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -2,7 +2,7 @@ use std::num::NonZeroU32; use axum::{Router, routing}; use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; -use tower_http::{services::ServeDir, trace::TraceLayer}; +use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer}; use crate::{handlers, state::AppState}; @@ -241,6 +241,37 @@ fn federation_html_routes() -> Router { ) } +fn cors_layer() -> CorsLayer { + use axum::http::{HeaderName, Method}; + use tower_http::cors::AllowOrigin; + + let origins = std::env::var("CORS_ORIGINS").unwrap_or_default(); + let layer = CorsLayer::new() + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([ + HeaderName::from_static("content-type"), + HeaderName::from_static("authorization"), + ]); + + if origins.is_empty() || origins == "*" { + layer.allow_origin(AllowOrigin::any()) + } else { + let parsed: Vec<_> = origins + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + layer + .allow_origin(AllowOrigin::list(parsed)) + .allow_credentials(true) + } +} + fn api_routes(rate_limit: u64) -> Router { let cfg = GovernorConfigBuilder::default() .with_extractor(PeerIp::default()) @@ -294,6 +325,10 @@ fn api_routes(rate_limit: u64) -> Router { "/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping), ) + .route( + "/import/sessions/{id}/preview", + routing::get(handlers::import::api_get_preview), + ) .route( "/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm), @@ -397,6 +432,7 @@ fn api_routes(rate_limit: u64) -> Router { Router::new() .nest("/api/v1", base.layer(GovernorLayer::new(cfg))) .nest("/api/v1", webhook_routes) + .layer(cors_layer()) } #[cfg(feature = "federation")]