diff --git a/crates/adapters/http-axum/src/api/auth.rs b/crates/adapters/http-axum/src/api/auth.rs index b3aecd34..1c7955ea 100644 --- a/crates/adapters/http-axum/src/api/auth.rs +++ b/crates/adapters/http-axum/src/api/auth.rs @@ -1,3 +1,4 @@ +use crate::extractors::Body; use crate::{errors::ApiError, state::AppState}; use api_types::{ LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, @@ -12,12 +13,12 @@ use axum::{Json, extract::State, http::StatusCode}; request_body = LoginRequest, responses( (status = 200, body = LoginResponse), - (status = 401, description = "Invalid credentials"), + (status = 401, description = "Invalid credentials", body = api_types::ErrorResponse), ) )] pub async fn login( State(state): State, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let result = login_uc::execute( &state.app_ctx.deps.auth.login, @@ -42,12 +43,12 @@ pub async fn login( request_body = RegisterRequest, responses( (status = 201, description = "User registered"), - (status = 400, description = "Invalid input"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), ) )] pub async fn register( State(state): State, - Json(req): Json, + Body(req): Body, ) -> Result { register_uc::execute( &state.app_ctx.deps.auth.register, @@ -67,12 +68,12 @@ pub async fn register( request_body = RefreshRequest, responses( (status = 200, body = RefreshResponse), - (status = 401, description = "Invalid or expired refresh token"), + (status = 401, description = "Invalid or expired refresh token", body = api_types::ErrorResponse), ) )] pub async fn refresh( State(state): State, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let result = application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token) @@ -93,7 +94,7 @@ pub async fn refresh( )] pub async fn api_logout( State(state): State, - Json(req): Json, + Body(req): Body, ) -> StatusCode { let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) .await; diff --git a/crates/adapters/http-axum/src/api/diary.rs b/crates/adapters/http-axum/src/api/diary.rs index 583ea659..6413acad 100644 --- a/crates/adapters/http-axum/src/api/diary.rs +++ b/crates/adapters/http-axum/src/api/diary.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, Params, PathId}; use crate::support::build_export_response; use crate::{ errors::ApiError, @@ -14,12 +15,7 @@ use application::diary::{ delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, queries::GetActivityFeedQuery, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use uuid::Uuid; #[utoipa::path( @@ -27,13 +23,13 @@ use uuid::Uuid; params(DiaryQueryParams), responses( (status = 200, body = DiaryResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_diary( State(state): State, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?; @@ -55,15 +51,15 @@ pub async fn get_diary( request_body = LogReviewRequest, responses( (status = 201, description = "Review created"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_review( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result { let data = LogReviewData::try_from(req).map_err(ApiError)?; log_review::execute( @@ -79,16 +75,16 @@ pub async fn post_review( params(("id" = Uuid, Path, description = "Review ID")), responses( (status = 204, description = "Review deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden", body = api_types::ErrorResponse), + (status = 404, description = "Review not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, + PathId(review_id): PathId, ) -> Result { let cmd = DeleteReviewCommand { review_id, @@ -104,18 +100,18 @@ pub async fn delete_review( params(("id" = Uuid, Path, description = "Review ID")), responses( (status = 200, description = "Review updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden", body = api_types::ErrorResponse), + (status = 404, description = "Review not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn patch_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, - Json(req): Json, + PathId(review_id): PathId, + Body(req): Body, ) -> Result { let watched_at = req .watched_at @@ -140,27 +136,37 @@ pub async fn patch_review( params(ExportQueryParams), responses( (status = 200, description = "Diary file download", content_type = "text/csv"), - (status = 400, description = "Invalid format parameter"), - (status = 401, description = "Unauthorized"), + (status = 400, description = "Invalid format parameter", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn export_diary( State(state): State, user: AuthenticatedUser, - Query(params): Query, + Params(params): Params, ) -> impl IntoResponse { + if !matches!(params.format.as_str(), "csv" | "json") { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "format must be csv or json", + ); + } build_export_response(¶ms.format, user.0.value(), &state) } #[utoipa::path( get, path = "/api/v1/activity-feed", params(ActivityFeedQueryParams), - responses((status = 200, body = ActivityFeedResponse)), + responses( + (status = 200, body = ActivityFeedResponse), + (status = 400, description = "Invalid query", body = api_types::ErrorResponse), + ), )] pub async fn get_activity_feed( State(state): State, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_feed_uc::execute( &state.app_ctx.deps.diary.get_activity_feed, diff --git a/crates/adapters/http-axum/src/api/goals.rs b/crates/adapters/http-axum/src/api/goals.rs index 963630e0..ceaac9df 100644 --- a/crates/adapters/http-axum/src/api/goals.rs +++ b/crates/adapters/http-axum/src/api/goals.rs @@ -1,21 +1,18 @@ +use crate::extractors::{Body, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::mappers::goal_with_progress_to_dto; use api_types::{ CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, UserSettingsDto, }; -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, -}; +use axum::{Json, extract::State, http::StatusCode}; use uuid::Uuid; #[utoipa::path( get, path = "/api/v1/goals", responses( (status = 200, body = GoalsResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -40,14 +37,14 @@ pub async fn list_goals( request_body = CreateGoalRequest, responses( (status = 200, body = GoalDto), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn create_goal( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let g = application::goals::create::execute( &state.app_ctx.deps.goals.command, @@ -66,16 +63,16 @@ pub async fn create_goal( request_body = UpdateGoalRequest, responses( (status = 200, body = GoalDto), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Goal not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Goal not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn update_goal( State(state): State, user: AuthenticatedUser, - Path(year): Path, - Json(req): Json, + PathId(year): PathId, + Body(req): Body, ) -> Result, ApiError> { let g = application::goals::update::execute( &state.app_ctx.deps.goals.command, @@ -93,15 +90,15 @@ pub async fn update_goal( delete, path = "/api/v1/goals/{year}", responses( (status = 204, description = "Goal deleted"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Goal not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Goal not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_goal( State(state): State, user: AuthenticatedUser, - Path(year): Path, + PathId(year): PathId, ) -> Result { application::goals::delete::execute( &state.app_ctx.deps.goals.command, @@ -118,14 +115,14 @@ pub async fn delete_goal( get, path = "/api/v1/users/{id}/goals", responses( (status = 200, body = GoalsResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_user_goals( State(state): State, AuthenticatedUser(_viewer): AuthenticatedUser, - Path(user_id): Path, + PathId(user_id): PathId, ) -> Result, ApiError> { let goals = application::goals::list::execute( &state.app_ctx.deps.goals.query, @@ -141,7 +138,7 @@ pub async fn get_user_goals( get, path = "/api/v1/settings", responses( (status = 200, body = UserSettingsDto), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -166,14 +163,14 @@ pub async fn get_settings( request_body = UpdateUserSettingsRequest, responses( (status = 204, description = "Settings updated"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn update_settings( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result { application::users::update_settings::execute( &state.app_ctx.deps.users.update_settings, diff --git a/crates/adapters/http-axum/src/api/import/profiles.rs b/crates/adapters/http-axum/src/api/import/profiles.rs index 9ede9328..4b631f6c 100644 --- a/crates/adapters/http-axum/src/api/import/profiles.rs +++ b/crates/adapters/http-axum/src/api/import/profiles.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::SaveProfileRequest; use application::import::{ @@ -5,11 +6,7 @@ use application::import::{ delete_profile as delete_import_profile, list_profiles as list_import_profiles, save_profile as save_import_profile, }; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{extract::State, http::StatusCode, response::IntoResponse}; use domain::errors::DomainError; use domain::value_objects::ImportSessionId; @@ -17,7 +14,7 @@ use domain::value_objects::ImportSessionId; get, path = "/api/v1/import/profiles", responses( (status = 200, description = "List of saved import profiles", body = inline(Vec)), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -46,15 +43,15 @@ pub async fn api_get_profiles( request_body = SaveProfileRequest, responses( (status = 200, description = "Profile saved", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Session has no mapping yet"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 422, description = "Session has no mapping yet", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_post_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { let session_id = body .session_id @@ -80,15 +77,15 @@ pub async fn api_post_profile( params(("id" = String, Path, description = "Import profile UUID")), responses( (status = 204, description = "Deleted"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Profile not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Profile not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_delete_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(profile_id_str): Path, + PathId(profile_id_str): PathId, ) -> Result { let profile_id = profile_id_str .parse::() diff --git a/crates/adapters/http-axum/src/api/import/sessions.rs b/crates/adapters/http-axum/src/api/import/sessions.rs index 96566246..0340939e 100644 --- a/crates/adapters/http-axum/src/api/import/sessions.rs +++ b/crates/adapters/http-axum/src/api/import/sessions.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, PathId}; use crate::support::str_to_domain_field; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ @@ -14,7 +15,7 @@ use application::import::{ get_session_state, }; use axum::{ - extract::{Multipart, Path, State}, + extract::{Multipart, State}, response::IntoResponse, }; use domain::errors::DomainError; @@ -29,9 +30,9 @@ use domain::value_objects::ImportSessionId; request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"), responses( (status = 200, body = SessionCreatedResponse), - (status = 400, description = "No file provided"), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Parse error"), + (status = 400, description = "No file provided", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 422, description = "Parse error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -87,15 +88,15 @@ pub async fn api_post_session( params(("id" = String, Path, description = "Import session UUID")), responses( (status = 200, body = SessionStateResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Session not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_get_session( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, + PathId(session_id_str): PathId, ) -> Result { let session_id = session_id_str .parse::() @@ -121,16 +122,16 @@ pub async fn api_get_session( request_body = ApplyMappingRequest, responses( (status = 200, description = "Mapping applied", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Mapping error"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 422, description = "Mapping error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_put_mapping( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, + PathId(session_id_str): PathId, + Body(body): Body, ) -> Result { let session_id = session_id_str .parse::() @@ -173,7 +174,7 @@ pub async fn api_put_mapping( pub async fn api_get_preview( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, + PathId(session_id_str): PathId, ) -> Result, ApiError> { let session_id = session_id_str .parse::() @@ -238,16 +239,16 @@ pub async fn api_get_preview( request_body = ConfirmRequest, responses( (status = 200, description = "Import summary", body = inline(serde_json::Value)), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Session not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_post_confirm( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, + PathId(session_id_str): PathId, + Body(body): Body, ) -> Result { let session_id = session_id_str .parse::() @@ -277,17 +278,17 @@ pub async fn api_post_confirm( ), responses( (status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)), - (status = 400, description = "Invalid ID"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session or profile not found"), - (status = 422, description = "Mapping error"), + (status = 400, description = "Invalid ID", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Session or profile not found", body = api_types::ErrorResponse), + (status = 422, description = "Mapping error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn api_apply_profile( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - Path((session_id_str, profile_id_str)): Path<(String, String)>, + PathId((session_id_str, profile_id_str)): PathId<(String, String)>, ) -> Result { let session_id = session_id_str .parse::() diff --git a/crates/adapters/http-axum/src/api/insights.rs b/crates/adapters/http-axum/src/api/insights.rs index 61211321..918b6c66 100644 --- a/crates/adapters/http-axum/src/api/insights.rs +++ b/crates/adapters/http-axum/src/api/insights.rs @@ -1,10 +1,8 @@ +use crate::extractors::Params; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse}; use application::insights::get as get_insights_uc; -use axum::{ - Json, - extract::{Query, State}, -}; +use axum::{Json, extract::State}; use domain::{errors::DomainError, models::Insight}; fn to_dto(insight: Insight) -> InsightDto { @@ -68,15 +66,15 @@ fn to_dto(insight: Insight) -> InsightDto { params(InsightsQueryParams), responses( (status = 200, body = InsightsResponse), - (status = 400, description = "Unknown time zone"), - (status = 401, description = "Unauthorized"), + (status = 400, description = "Unknown time zone", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_insights( State(state): State, user: AuthenticatedUser, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let tz: chrono_tz::Tz = match params.tz.as_deref() { None => chrono_tz::UTC, diff --git a/crates/adapters/http-axum/src/api/movies.rs b/crates/adapters/http-axum/src/api/movies.rs index fd893267..b7b50d3a 100644 --- a/crates/adapters/http-axum/src/api/movies.rs +++ b/crates/adapters/http-axum/src/api/movies.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse, @@ -12,12 +13,7 @@ use application::{ }, movies::{get_movies, queries::GetMoviesQuery, sync_poster}, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use domain::services::review_history::Trend; use uuid::Uuid; @@ -26,11 +22,12 @@ use uuid::Uuid; params(MoviesQueryParams), responses( (status = 200, body = MoviesResponse), + (status = 400, description = "Invalid query", body = api_types::ErrorResponse), ) )] pub async fn list_movies( State(state): State, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_movies::execute( &state.app_ctx.deps.movies.get_movies, @@ -61,12 +58,12 @@ pub async fn list_movies( params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = ReviewHistoryResponse), - (status = 404, description = "Movie not found"), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ) )] pub async fn get_review_history( State(state): State, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result, ApiError> { let (history, trend) = get_review_history::execute( &state.app_ctx.deps.diary.get_review_history, @@ -95,15 +92,15 @@ pub async fn get_review_history( params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 204, description = "Poster synced"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn sync_poster( State(state): State, _user: AuthenticatedUser, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result { sync_poster::execute( &state.app_ctx.deps.movies.sync_poster, @@ -118,13 +115,13 @@ pub async fn sync_poster( params(("movie_id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = MovieDetailResponse), - (status = 404, description = "Movie not found"), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ) )] pub async fn get_movie_detail( State(state): State, - Path(movie_id): Path, - Query(params): Query, + PathId(movie_id): PathId, + Params(params): Params, ) -> Result, ApiError> { let limit = params.limit.unwrap_or(20); let offset = params.offset.unwrap_or(0); @@ -175,12 +172,12 @@ pub async fn get_movie_detail( params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = MovieProfileResponse), - (status = 404, description = "No profile found for this movie"), + (status = 404, description = "No profile found for this movie", body = api_types::ErrorResponse), ) )] pub async fn get_movie_profile( State(state): State, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> impl IntoResponse { use application::movies::get_movie_profile; let query = get_movie_profile::GetMovieProfileQuery { movie_id }; @@ -243,7 +240,9 @@ pub async fn get_movie_profile( }) .into_response() } - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Ok(None) => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } + Err(e) => crate::errors::ApiError(e).into_response(), } } diff --git a/crates/adapters/http-axum/src/api/search.rs b/crates/adapters/http-axum/src/api/search.rs index 63da0f06..e0c28512 100644 --- a/crates/adapters/http-axum/src/api/search.rs +++ b/crates/adapters/http-axum/src/api/search.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Params, PathId}; use crate::state::AppState; use api_types::search::{ CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, @@ -7,11 +8,7 @@ use application::{ person::{get as get_person, get_credits as get_person_credits}, search::execute as search_uc, }; -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{extract::State, http::StatusCode, response::IntoResponse}; use domain::models::{PersonId, collections::PageParams}; #[utoipa::path( @@ -19,12 +16,13 @@ use domain::models::{PersonId, collections::PageParams}; params(api_types::search::SearchQueryParams), responses( (status = 200, body = api_types::search::SearchResponse), + (status = 400, description = "Invalid query", body = api_types::ErrorResponse), ), tag = "search", )] pub async fn get_search( State(state): State, - Query(params): Query, + Params(params): Params, ) -> impl IntoResponse { let query = domain::models::SearchQuery { text: params.q, @@ -80,7 +78,7 @@ pub async fn get_search( }, }) .into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -89,13 +87,13 @@ pub async fn get_search( params(("id" = Uuid, Path, description = "Person ID")), responses( (status = 200, body = api_types::search::PersonDto), - (status = 404, description = "Person not found"), + (status = 404, description = "Person not found", body = api_types::ErrorResponse), ), tag = "search", )] pub async fn get_person_handler( State(state): State, - Path(id): Path, + PathId(id): PathId, ) -> impl IntoResponse { match get_person::execute( &state.app_ctx.deps.person.get_person, @@ -104,8 +102,10 @@ pub async fn get_person_handler( .await { Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Ok(None) => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -114,13 +114,13 @@ pub async fn get_person_handler( params(("id" = Uuid, Path, description = "Person ID")), responses( (status = 200, body = api_types::search::PersonCreditsDto), - (status = 404, description = "Person not found"), + (status = 404, description = "Person not found", body = api_types::ErrorResponse), ), tag = "search", )] pub async fn get_person_credits_handler( State(state): State, - Path(id): Path, + PathId(id): PathId, ) -> impl IntoResponse { match get_person_credits::execute( &state.app_ctx.deps.person.get_person, @@ -155,7 +155,7 @@ pub async fn get_person_credits_handler( .collect(), }) .into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -165,10 +165,14 @@ pub async fn post_reindex_search( ) -> impl IntoResponse { let event = domain::events::DomainEvent::SearchReindexRequested; match state.app_ctx.services.event_publisher.publish(&event).await { - Ok(()) => StatusCode::ACCEPTED, + Ok(()) => StatusCode::ACCEPTED.into_response(), Err(e) => { tracing::error!("failed to publish reindex event: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR + crate::errors::refuse( + StatusCode::INTERNAL_SERVER_ERROR, + crate::errors::INTERNAL, + "internal server error", + ) } } } diff --git a/crates/adapters/http-axum/src/api/social.rs b/crates/adapters/http-axum/src/api/social.rs index be1c54ac..02cf5bb3 100644 --- a/crates/adapters/http-axum/src/api/social.rs +++ b/crates/adapters/http-axum/src/api/social.rs @@ -1,14 +1,10 @@ +use crate::extractors::{Body, Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest, PendingCountResponse, RemoteActorDto, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity}; use uuid::Uuid; @@ -29,7 +25,7 @@ fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> Remot get, path = "/api/v1/social/following", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -55,7 +51,7 @@ pub async fn get_following( get, path = "/api/v1/social/followers", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -80,7 +76,7 @@ pub async fn get_followers( pub async fn get_user_following( State(state): State, _user: AuthenticatedUser, - Path(user_id): Path, + PathId(user_id): PathId, ) -> Result, ApiError> { let instance = state.app_ctx.instance.clone(); let identities = @@ -97,7 +93,7 @@ pub async fn get_user_following( pub async fn get_user_followers( State(state): State, _user: AuthenticatedUser, - Path(user_id): Path, + PathId(user_id): PathId, ) -> Result, ApiError> { let instance = state.app_ctx.instance.clone(); let identities = @@ -116,14 +112,14 @@ pub async fn get_user_followers( request_body = FollowRequest, responses( (status = 200, description = "Follow request sent"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn follow( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { application::social::execute::execute_command( &state.app_ctx.deps.social.command, @@ -141,14 +137,14 @@ pub async fn follow( request_body = ActorUrlRequest, responses( (status = 200, description = "Unfollowed"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn unfollow( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -167,14 +163,14 @@ pub async fn unfollow( request_body = ActorUrlRequest, responses( (status = 200, description = "Follower accepted"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn accept_follower( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -193,14 +189,14 @@ pub async fn accept_follower( request_body = ActorUrlRequest, responses( (status = 200, description = "Follower rejected"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn reject_follower( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -219,14 +215,14 @@ pub async fn reject_follower( request_body = ActorUrlRequest, responses( (status = 200, description = "Follower removed"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn remove_follower( State(state): State, user: AuthenticatedUser, - Json(body): Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -244,7 +240,7 @@ pub async fn remove_follower( get, path = "/api/v1/social/followers/pending", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -270,7 +266,7 @@ pub async fn get_pending_followers( get, path = "/api/v1/social/followers/pending/count", responses( (status = 200, body = PendingCountResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -290,7 +286,7 @@ pub async fn get_pending_follower_count( get, path = "/api/v1/social/following/pending", responses( (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -322,14 +318,14 @@ pub struct RelationshipQuery { params(("actor_url" = String, Query, description = "Canonical actor URL of the target")), responses( (status = 200, body = FollowRelationResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_relationship( State(state): State, user: AuthenticatedUser, - Query(q): Query, + Params(q): Params, ) -> Result, ApiError> { let target = state.app_ctx.instance.identify(&q.actor_url); let rel = application::social::get_relation::execute( diff --git a/crates/adapters/http-axum/src/api/user_profile.rs b/crates/adapters/http-axum/src/api/user_profile.rs index 6be466c7..c12874ef 100644 --- a/crates/adapters/http-axum/src/api/user_profile.rs +++ b/crates/adapters/http-axum/src/api/user_profile.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Params, PathId}; use crate::{extractors::AuthenticatedUser, state::AppState}; use api_types::mappers::goal_with_progress_to_dto; use api_types::{ @@ -8,12 +9,7 @@ use application::users::{ get_federated_profile, get_federated_profile_stats, get_local_profile, queries::GetUserProfileQuery, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use domain::errors::DomainError; use std::str::FromStr; use uuid::Uuid; @@ -26,19 +22,25 @@ use uuid::Uuid; ), responses( (status = 200, body = UserProfileResponse), - (status = 404, description = "User not found"), + (status = 404, description = "User not found", body = api_types::ErrorResponse), ) )] pub async fn get_user_profile( State(state): State, AuthenticatedUser(viewer_id): AuthenticatedUser, - Path(user_id): Path, - Query(params): Query, + PathId(user_id): PathId, + Params(params): Params, ) -> impl IntoResponse { let view_str = params.view.as_deref().unwrap_or("recent"); let profile_view = match application::users::queries::ProfileView::from_str(view_str) { Ok(v) => v, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), + Err(_) => { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "invalid profile view", + ); + } }; let profile = match get_local_profile::execute( @@ -73,9 +75,13 @@ pub async fn get_user_profile( ) .await; } - return StatusCode::NOT_FOUND.into_response(); + return crate::errors::refuse( + StatusCode::NOT_FOUND, + crate::errors::NOT_FOUND, + "not found", + ); } - Err(e) => return crate::errors::domain_error_response(e), + Err(e) => return crate::errors::ApiError(e).into_response(), }; let view_data = if let Some(p) = profile.entries { @@ -174,7 +180,7 @@ async fn build_federated_profile_response( .await { Ok(p) => p, - Err(e) => return crate::errors::domain_error_response(e), + Err(e) => return crate::errors::ApiError(e).into_response(), }; let view_data = if let Some(p) = profile.entries { diff --git a/crates/adapters/http-axum/src/api/users.rs b/crates/adapters/http-axum/src/api/users.rs index 71589032..744c4283 100644 --- a/crates/adapters/http-axum/src/api/users.rs +++ b/crates/adapters/http-axum/src/api/users.rs @@ -1,3 +1,4 @@ +use crate::extractors::Body; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; use application::users::{ @@ -14,8 +15,8 @@ use axum::{ get, path = "/api/v1/profile", responses( (status = 200, body = ProfileResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "User not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "User not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -56,9 +57,9 @@ pub async fn get_profile( put, path = "/api/v1/profile", responses( (status = 204, description = "Profile updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 500, description = "Internal server error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -82,7 +83,7 @@ pub async fn update_profile_handler( match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -91,20 +92,26 @@ pub async fn update_profile_handler( request_body = api_types::UpdateProfileFieldsRequest, responses( (status = 204, description = "Profile fields updated"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), + (status = 400, description = "Invalid input", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 500, description = "Internal server error", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn update_profile_fields_handler( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> impl IntoResponse { let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { Some(arr) => arr.clone(), - None => return StatusCode::BAD_REQUEST.into_response(), + None => { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "body must contain a fields array", + ); + } }; let fields: Vec = raw_fields @@ -124,13 +131,16 @@ pub async fn update_profile_fields_handler( match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Err(e) => crate::errors::ApiError(e).into_response(), } } #[utoipa::path( get, path = "/api/v1/users", - responses((status = 200, body = UsersResponse)), + responses( + (status = 200, body = UsersResponse), + (status = 429, description = "Rate limited", body = api_types::ErrorResponse), + ), )] pub async fn list_users(State(state): State) -> Result, ApiError> { let result = diff --git a/crates/adapters/http-axum/src/api/watchlist.rs b/crates/adapters/http-axum/src/api/watchlist.rs index 7753d75c..18a24234 100644 --- a/crates/adapters/http-axum/src/api/watchlist.rs +++ b/crates/adapters/http-axum/src/api/watchlist.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, @@ -13,12 +14,7 @@ use application::{ remove as remove_from_watchlist, }, }; -use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use uuid::Uuid; #[utoipa::path( @@ -29,14 +25,14 @@ use uuid::Uuid; ), responses( (status = 200, body = WatchlistResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_watchlist_handler( State(state): State, user: AuthenticatedUser, - Query(params): Query, + Params(params): Params, ) -> Result, ApiError> { let page = get_watchlist::execute( &state.app_ctx.deps.watchlist.get_watchlist, @@ -69,15 +65,15 @@ pub async fn get_watchlist_handler( request_body = AddToWatchlistRequest, responses( (status = 201, description = "Added to watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_watchlist_add( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result { add_to_watchlist::execute( &state.app_ctx.deps.watchlist.add, @@ -101,15 +97,15 @@ pub async fn post_watchlist_add( params(("movie_id" = Uuid, Path, description = "Movie ID")), responses( (status = 204, description = "Removed from watchlist"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not on watchlist"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Not on watchlist", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_watchlist_entry( State(state): State, user: AuthenticatedUser, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result { remove_from_watchlist::execute( &state.app_ctx.deps.watchlist.remove_from_watchlist, @@ -127,14 +123,14 @@ pub async fn delete_watchlist_entry( params(("movie_id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = WatchlistStatusResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_watchlist_status( State(state): State, user: AuthenticatedUser, - Path(movie_id): Path, + PathId(movie_id): PathId, ) -> Result, ApiError> { let on_watchlist = is_on_watchlist::execute( &state.app_ctx.deps.watchlist.is_on_watchlist, diff --git a/crates/adapters/http-axum/src/api/webhook.rs b/crates/adapters/http-axum/src/api/webhook.rs index d0a52818..f8a43ab8 100644 --- a/crates/adapters/http-axum/src/api/webhook.rs +++ b/crates/adapters/http-axum/src/api/webhook.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, @@ -16,7 +17,7 @@ use application::integrations::{ }; use axum::{ Json, - extract::{Multipart, Path, State}, + extract::{Multipart, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, }; @@ -36,6 +37,14 @@ pub struct WebhookTokenQuery { pub token: Option, } +fn missing_token() -> axum::response::Response { + crate::errors::refuse( + StatusCode::UNAUTHORIZED, + crate::errors::UNAUTHORIZED, + "missing webhook token", + ) +} + fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option { extract_bearer_token(headers).or_else(|| query.token.clone()) } @@ -45,20 +54,20 @@ fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Opti request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"), responses( (status = 200, description = "Event accepted or ignored"), - (status = 400, description = "Invalid payload"), - (status = 401, description = "Invalid or missing webhook token"), + (status = 400, description = "Invalid payload", body = api_types::ErrorResponse), + (status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_jellyfin_webhook( State(state): State, headers: HeaderMap, - axum::extract::Query(query): axum::extract::Query, + Params(query): Params, body: axum::body::Bytes, ) -> impl IntoResponse { let token = match extract_webhook_token(&headers, &query) { Some(t) => t, - None => return StatusCode::UNAUTHORIZED, + None => return missing_token(), }; let cmd = IngestWatchEventCommand { @@ -80,20 +89,20 @@ pub async fn post_jellyfin_webhook( request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"), responses( (status = 200, description = "Event accepted or ignored"), - (status = 400, description = "Invalid payload"), - (status = 401, description = "Invalid or missing webhook token"), + (status = 400, description = "Invalid payload", body = api_types::ErrorResponse), + (status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_plex_webhook( State(state): State, headers: HeaderMap, - axum::extract::Query(query): axum::extract::Query, + Params(query): Params, mut multipart: Multipart, ) -> impl IntoResponse { let token = match extract_webhook_token(&headers, &query) { Some(t) => t, - None => return StatusCode::UNAUTHORIZED, + None => return missing_token(), }; let mut payload_bytes: Option> = None; @@ -108,7 +117,13 @@ pub async fn post_plex_webhook( let raw_payload = match payload_bytes { Some(b) => b, - None => return StatusCode::BAD_REQUEST, + None => { + return crate::errors::refuse( + StatusCode::BAD_REQUEST, + crate::errors::INVALID_INPUT, + "multipart form has no payload field", + ); + } }; let cmd = IngestWatchEventCommand { @@ -124,7 +139,7 @@ async fn run_ingest( state: &AppState, cmd: IngestWatchEventCommand, parser: &dyn domain::ports::MediaServerParser, -) -> StatusCode { +) -> axum::response::Response { match ingest_watch_event::execute( &state.app_ctx.deps.integrations.ingest_watch_event, cmd, @@ -132,8 +147,8 @@ async fn run_ingest( ) .await { - Ok(()) => StatusCode::OK, - Err(e) => crate::errors::domain_error_status(&e), + Ok(()) => StatusCode::OK.into_response(), + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -142,14 +157,14 @@ async fn run_ingest( request_body = GenerateTokenRequest, responses( (status = 200, body = GenerateTokenResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_generate_webhook_token( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let provider: WatchEventSource = req .provider @@ -182,7 +197,7 @@ pub async fn post_generate_webhook_token( get, path = "/api/v1/settings/webhook-tokens", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -216,15 +231,15 @@ pub async fn get_webhook_tokens( params(("id" = Uuid, Path, description = "Webhook token ID")), responses( (status = 204, description = "Token revoked"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Token not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Token not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_webhook_token( State(state): State, user: AuthenticatedUser, - Path(id): Path, + PathId(id): PathId, ) -> Result { let cmd = RevokeWebhookTokenCommand { user_id: user.0.value(), @@ -239,7 +254,7 @@ pub async fn delete_webhook_token( get, path = "/api/v1/watch-queue", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -273,15 +288,15 @@ pub async fn get_watch_queue( request_body = ConfirmWatchRequest, responses( (status = 200, body = ConfirmWatchResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — not your watch event"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_confirm_watch_events( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let cmd = ConfirmWatchEventsCommand { user_id: user.0.value(), @@ -307,15 +322,15 @@ pub async fn post_confirm_watch_events( request_body = DismissWatchRequest, responses( (status = 200, body = DismissWatchResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — not your watch event"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_dismiss_watch_events( State(state): State, user: AuthenticatedUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let cmd = DismissWatchEventsCommand { user_id: user.0.value(), diff --git a/crates/adapters/http-axum/src/api/wrapup.rs b/crates/adapters/http-axum/src/api/wrapup.rs index b17b1af2..00ba1b42 100644 --- a/crates/adapters/http-axum/src/api/wrapup.rs +++ b/crates/adapters/http-axum/src/api/wrapup.rs @@ -1,3 +1,4 @@ +use crate::extractors::{Body, PathId}; use crate::{ errors::ApiError, extractors::{AdminApiUser, AuthenticatedUser}, @@ -11,12 +12,7 @@ use application::wrapup::{ delete as delete_wrapup, generate, get_wrapup, list_wrapups::{self, ListWrapUpsQuery}, }; -use axum::{ - Json, - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use chrono::NaiveDate; use domain::errors::DomainError; use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; @@ -41,16 +37,16 @@ fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse { request_body = GenerateWrapUpRequest, responses( (status = 200, body = WrapUpGeneratedResponse), - (status = 400, description = "Invalid date format"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — admin only"), + (status = 400, description = "Invalid date format", body = api_types::ErrorResponse), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn post_generate( State(state): State, _admin: AdminApiUser, - Json(req): Json, + Body(req): Body, ) -> Result, ApiError> { let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d") .map_err(|_| DomainError::ValidationError("invalid start_date".into()))?; @@ -72,7 +68,7 @@ pub async fn post_generate( get, path = "/api/v1/wrapups", responses( (status = 200, body = WrapUpListResponse), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -97,15 +93,15 @@ pub async fn get_list( params(("id" = Uuid, Path, description = "Wrap-up ID")), responses( (status = 200, body = WrapUpStatusResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_status( State(state): State, _user: AuthenticatedUser, - Path(id): Path, + PathId(id): PathId, ) -> Result, ApiError> { let record = get_wrapup::execute( &state.app_ctx.deps.wrapup.get_wrapup, @@ -122,15 +118,15 @@ pub async fn get_status( responses( (status = 200, description = "Report JSON", content_type = "application/json"), (status = 202, description = "Still generating"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 404, description = "Not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn get_report( State(state): State, _user: AuthenticatedUser, - Path(id): Path, + PathId(id): PathId, ) -> impl IntoResponse { match get_wrapup::execute( &state.app_ctx.deps.wrapup.get_wrapup, @@ -143,11 +139,15 @@ pub async fn get_report( let json = serde_json::to_string(report).unwrap_or_default(); (StatusCode::OK, [("content-type", "application/json")], json).into_response() } - None => StatusCode::NOT_FOUND.into_response(), + None => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } }, Ok(Some(_)) => StatusCode::ACCEPTED.into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => crate::errors::domain_error_response(e), + Ok(None) => { + crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") + } + Err(e) => crate::errors::ApiError(e).into_response(), } } @@ -156,16 +156,16 @@ pub async fn get_report( params(("id" = Uuid, Path, description = "Wrap-up ID")), responses( (status = 204, description = "Deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden — admin only"), - (status = 404, description = "Not found"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse), + (status = 404, description = "Not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn delete_wrapup_handler( State(state): State, _admin: AdminApiUser, - Path(id): Path, + PathId(id): PathId, ) -> Result { delete_wrapup::execute( &state.app_ctx.deps.wrapup.delete_wrapup, diff --git a/crates/adapters/http-axum/src/errors.rs b/crates/adapters/http-axum/src/errors.rs index da5e6f5c..a212053a 100644 --- a/crates/adapters/http-axum/src/errors.rs +++ b/crates/adapters/http-axum/src/errors.rs @@ -1,9 +1,30 @@ +use api_types::ErrorResponse; use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; use domain::errors::DomainError; +pub const NOT_FOUND: &str = "NOT_FOUND"; +pub const INVALID_INPUT: &str = "INVALID_INPUT"; +pub const CONFLICT: &str = "CONFLICT"; +pub const UNAUTHORIZED: &str = "UNAUTHORIZED"; +pub const FORBIDDEN: &str = "FORBIDDEN"; +pub const RATE_LIMITED: &str = "RATE_LIMITED"; +pub const INTERNAL: &str = "INTERNAL"; + +/// The only constructor of JSON error bodies in this crate. +pub fn refuse(status: StatusCode, code: &'static str, message: impl Into) -> Response { + ( + status, + axum::Json(ErrorResponse { + error: message.into(), + code: code.to_string(), + }), + ) + .into_response() +} + pub fn domain_error_status(e: &DomainError) -> StatusCode { match e { DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => { @@ -38,19 +59,16 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = domain_error_status(&self.0); match &self.0 { + DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => { + refuse(status, INVALID_INPUT, self.0.to_string()) + } + DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()), + DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()), + DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()), DomainError::InfrastructureError(_) => { tracing::error!("Internal error: {:?}", self.0); - ( - status, - axum::Json(serde_json::json!({"error": "internal server error"})), - ) - .into_response() + refuse(status, INTERNAL, "internal server error") } - _ => ( - status, - axum::Json(serde_json::json!({"error": self.0.to_string()})), - ) - .into_response(), } } } diff --git a/crates/adapters/http-axum/src/extractors/json.rs b/crates/adapters/http-axum/src/extractors/json.rs new file mode 100644 index 00000000..08114170 --- /dev/null +++ b/crates/adapters/http-axum/src/extractors/json.rs @@ -0,0 +1,101 @@ +//! JSON API extractors whose rejections are refused in the one error shape. + +use axum::{ + extract::{ + FromRequest, FromRequestParts, Path, Query, Request, + rejection::{JsonRejection, PathRejection, QueryRejection}, + }, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, +}; +use serde::de::DeserializeOwned; + +use crate::errors::{INVALID_INPUT, refuse}; + +/// Malformed request (bad JSON, query or path), refused as `INVALID_INPUT`. +/// Status follows axum: 400 syntax, 415 content type, 422 data errors, 413 size. +pub struct MalformedRequest { + status: StatusCode, + message: String, +} + +impl IntoResponse for MalformedRequest { + fn into_response(self) -> Response { + refuse(self.status, INVALID_INPUT, self.message) + } +} + +impl From for MalformedRequest { + fn from(r: JsonRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +impl From for MalformedRequest { + fn from(r: QueryRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +impl From for MalformedRequest { + fn from(r: PathRejection) -> Self { + Self { + status: r.status(), + message: r.body_text(), + } + } +} + +/// `axum::Json` with refusals in the one error shape. +pub struct Body(pub T); + +impl FromRequest for Body +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = MalformedRequest; + + async fn from_request(req: Request, state: &S) -> Result { + let axum::Json(v) = axum::Json::::from_request(req, state).await?; + Ok(Self(v)) + } +} + +/// `axum::extract::Query` with refusals in the one error shape. +pub struct Params(pub T); + +impl FromRequestParts for Params +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = MalformedRequest; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Query(v) = Query::::from_request_parts(parts, state).await?; + Ok(Self(v)) + } +} + +/// `axum::extract::Path` with refusals in the one error shape. +pub struct PathId(pub T); + +impl FromRequestParts for PathId +where + S: Send + Sync, + T: DeserializeOwned + Send, +{ + type Rejection = MalformedRequest; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Path(v) = Path::::from_request_parts(parts, state).await?; + Ok(Self(v)) + } +} diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index 392ee65b..9ac5cc89 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -1,3 +1,6 @@ +mod json; +pub use json::{Body, MalformedRequest, Params, PathId}; + use axum::{ extract::{FromRef, FromRequestParts}, http::{StatusCode, header, header::AUTHORIZATION, request::Parts}, diff --git a/crates/adapters/http-axum/src/federation/blocklist_api.rs b/crates/adapters/http-axum/src/federation/blocklist_api.rs index 7f213985..6d7a950c 100644 --- a/crates/adapters/http-axum/src/federation/blocklist_api.rs +++ b/crates/adapters/http-axum/src/federation/blocklist_api.rs @@ -1,4 +1,5 @@ use crate::extractors::AdminApiUser; +use crate::extractors::{Body, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::ActorUrlRequest; use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse}; @@ -21,8 +22,8 @@ fn social_actor_to_blocked_dto( get, path = "/api/v1/admin/blocked-domains", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] @@ -48,15 +49,15 @@ pub async fn get_blocked_domains_admin( request_body = AddBlockedDomainRequest, responses( (status = 201, description = "Domain blocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn add_blocked_domain_admin( State(state): State, _admin: AdminApiUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { state .app_ctx @@ -71,15 +72,15 @@ pub async fn add_blocked_domain_admin( params(("domain" = String, Path, description = "Domain to unblock")), responses( (status = 204, description = "Domain unblocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), + (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn remove_blocked_domain_admin( State(state): State, _admin: AdminApiUser, - axum::extract::Path(domain): axum::extract::Path, + PathId(domain): PathId, ) -> Result { state .app_ctx @@ -94,14 +95,14 @@ pub async fn remove_blocked_domain_admin( request_body = ActorUrlRequest, responses( (status = 204, description = "Actor blocked"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn block_actor_api( State(state): State, user: AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -120,14 +121,14 @@ pub async fn block_actor_api( request_body = ActorUrlRequest, responses( (status = 204, description = "Actor unblocked"), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn unblock_actor_api( State(state): State, user: AuthenticatedUser, - axum::Json(body): axum::Json, + Body(body): Body, ) -> Result { let instance = state.app_ctx.instance.clone(); application::social::execute::execute_command( @@ -145,7 +146,7 @@ pub async fn unblock_actor_api( get, path = "/api/v1/social/blocked", responses( (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), + (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] diff --git a/crates/adapters/http-axum/src/openapi/mod.rs b/crates/adapters/http-axum/src/openapi/mod.rs index 79401054..947cfcc4 100644 --- a/crates/adapters/http-axum/src/openapi/mod.rs +++ b/crates/adapters/http-axum/src/openapi/mod.rs @@ -31,8 +31,15 @@ impl Modify for SecurityAddon { } } -fn build() -> utoipa::openapi::OpenApi { +pub fn spec() -> utoipa::openapi::OpenApi { let mut api = auth::AuthDoc::openapi(); + api.components + .get_or_insert_with(Default::default) + .schemas + .insert( + "ErrorResponse".into(), + ::schema(), + ); api.info = utoipa::openapi::InfoBuilder::new() .title("Movies Diary API") .version("1.0.0") @@ -56,7 +63,7 @@ fn build() -> utoipa::openapi::OpenApi { pub fn serve(router: Router) -> Router { tracing::info!("API docs at /docs (Swagger) and /scalar"); - let spec = build(); + let spec = spec(); router .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) .merge(Scalar::with_url("/scalar", spec)) diff --git a/crates/adapters/http-axum/src/router.rs b/crates/adapters/http-axum/src/router.rs index 1185bde2..44be6dd2 100644 --- a/crates/adapters/http-axum/src/router.rs +++ b/crates/adapters/http-axum/src/router.rs @@ -1,14 +1,21 @@ use std::num::NonZeroU32; -use axum::{Router, routing}; -use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; +use axum::{Router, http::StatusCode, routing}; +use axum_governor::{ + GovernorConfigBuilder, GovernorLayer, Quota, RejectionReason, extractor::PeerIp, +}; use tower_http::{ cors::CorsLayer, services::{ServeDir, ServeFile}, trace::TraceLayer, }; -use crate::{api, federation, html, state::AppState}; +use crate::{ + api, + errors::{INTERNAL, RATE_LIMITED, refuse}, + federation, html, + state::AppState, +}; pub fn build_router(state: AppState, ap_router: Router) -> Router { let rate_limit = state.app_ctx.config.rate_limit; @@ -41,6 +48,22 @@ async fn health_handler() -> axum::Json { axum::Json(serde_json::json!({ "status": "ok" })) } +/// JSON API rate-limit refusals use the one error shape. +fn slow_down(reason: RejectionReason) -> axum::response::Response { + match reason { + RejectionReason::QuotaExceeded { .. } => refuse( + StatusCode::TOO_MANY_REQUESTS, + RATE_LIMITED, + "too many requests: slow down and try again shortly", + ), + RejectionReason::KeyExtractionFailed(_) => refuse( + StatusCode::INTERNAL_SERVER_ERROR, + INTERNAL, + "the caller could not be identified for rate limiting", + ), + } +} + fn per_minute(n: u64) -> Quota { let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); Quota::requests_per_minute(n) @@ -111,6 +134,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { .with_extractor(PeerIp::default()) .expect_connect_info() .quota_default(per_minute(rate_limit)) + .error_handler(slow_down) .finish() .unwrap(); @@ -120,6 +144,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { .with_extractor(PeerIp::default()) .expect_connect_info() .quota_default(per_minute(rate_limit / 4)) + .error_handler(slow_down) .finish() .unwrap(); let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg)); diff --git a/crates/adapters/http-axum/src/tests/api_handlers.rs b/crates/adapters/http-axum/src/tests/api_handlers.rs index 94b8ee2b..893257ab 100644 --- a/crates/adapters/http-axum/src/tests/api_handlers.rs +++ b/crates/adapters/http-axum/src/tests/api_handlers.rs @@ -393,3 +393,139 @@ async fn following_collection_returns_500_on_federation_error() { assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + +// --- One refusal shape --- + +async fn refusal_body(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).expect("refusal body is JSON") +} + +#[tokio::test] +async fn a_missing_resource_is_refused_with_not_found_code() { + let state = + crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let app = Router::new() + .route( + "/api/v1/people/{id}", + get(crate::api::search::get_person_handler), + ) + .with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/people/{}", Uuid::new_v4())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body = refusal_body(resp).await; + assert_eq!(body["code"], "NOT_FOUND"); + assert!(body["error"].is_string()); +} + +#[tokio::test] +async fn a_missing_credential_is_refused_with_unauthorized_code() { + let app = Router::new() + .route( + "/api/v1/watchlist", + get(crate::api::watchlist::get_watchlist_handler), + ) + .with_state(test_app_state()); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/watchlist") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!(refusal_body(resp).await["code"], "UNAUTHORIZED"); +} + +#[tokio::test] +async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() { + let state = + crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let app = Router::new() + .route( + "/api/v1/people/{id}", + get(crate::api::search::get_person_handler), + ) + .with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/people/not-a-uuid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT"); +} + +#[tokio::test] +async fn a_malformed_json_body_is_refused_with_invalid_input_code() { + let app = Router::new() + .route( + "/api/v1/auth/login", + axum::routing::post(crate::api::auth::login), + ) + .with_state(test_app_state()); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("content-type", "application/json") + .body(Body::from("{not json")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = refusal_body(resp).await; + assert_eq!(body["code"], "INVALID_INPUT"); + assert!(body["error"].is_string()); +} + +#[tokio::test] +async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() { + let app = Router::new() + .route( + "/api/v1/auth/login", + axum::routing::post(crate::api::auth::login), + ) + .with_state(test_app_state()); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT"); +} diff --git a/crates/adapters/http-axum/tests/openapi_contract_test.rs b/crates/adapters/http-axum/tests/openapi_contract_test.rs new file mode 100644 index 00000000..a878f9a5 --- /dev/null +++ b/crates/adapters/http-axum/tests/openapi_contract_test.rs @@ -0,0 +1,93 @@ +fn spec() -> serde_json::Value { + serde_json::to_value(http_axum::openapi::spec()).unwrap() +} + +fn operations(spec: &serde_json::Value) -> Vec<(String, String, serde_json::Value)> { + spec["paths"] + .as_object() + .unwrap() + .iter() + .flat_map(|(path, methods)| { + methods + .as_object() + .unwrap() + .iter() + .map(move |(verb, op)| (verb.to_uppercase(), path.clone(), op.clone())) + }) + .collect() +} + +#[test] +fn every_operation_that_can_succeed_with_a_body_declares_its_shape() { + let spec = spec(); + let mut untyped = Vec::new(); + + for (verb, path, op) in operations(&spec) { + let responses = op["responses"].as_object().unwrap(); + let typed = responses.iter().any(|(code, r)| { + code.starts_with('2') && r["content"]["application/json"]["schema"].is_object() + }); + let bodyless = responses + .iter() + .any(|(code, r)| code.starts_with('2') && r.get("content").is_none()); + let binary = responses.iter().any(|(code, r)| { + code.starts_with('2') + && r["content"] + .as_object() + .is_some_and(|c| !c.contains_key("application/json")) + }); + if !typed && !bodyless && !binary { + untyped.push(format!("{verb} {path}")); + } + } + + assert!(untyped.is_empty(), "2xx without a schema: {untyped:?}"); +} + +#[test] +fn every_operation_documents_a_refusal() { + let spec = spec(); + let silent: Vec<_> = operations(&spec) + .into_iter() + .filter(|(_, _, op)| { + !op["responses"] + .as_object() + .unwrap() + .keys() + .any(|c| c.starts_with('4')) + }) + .map(|(v, p, _)| format!("{v} {p}")) + .collect(); + assert!(silent.is_empty(), "no 4xx documented: {silent:?}"); +} + +#[test] +fn every_authenticated_operation_documents_401() { + let spec = spec(); + let silent: Vec<_> = operations(&spec) + .into_iter() + .filter(|(_, _, op)| op.get("security").is_some() && op["responses"].get("401").is_none()) + .map(|(v, p, _)| format!("{v} {p}")) + .collect(); + assert!(silent.is_empty(), "no 401 documented: {silent:?}"); +} + +#[test] +fn every_refusal_is_the_one_error_shape() { + let spec = spec(); + let mut odd = Vec::new(); + for (verb, path, op) in operations(&spec) { + for (code, response) in op["responses"].as_object().unwrap() { + if !code.starts_with('4') && !code.starts_with('5') { + continue; + } + let named = response["content"]["application/json"]["schema"]["$ref"] + .as_str() + .unwrap_or_default(); + if named != "#/components/schemas/ErrorResponse" { + odd.push(format!("{verb} {path} -> {code}")); + } + } + } + assert!(odd.is_empty(), "refusals not ErrorResponse: {odd:?}"); +} diff --git a/crates/api-types/src/common.rs b/crates/api-types/src/common.rs index e2e7a42c..e009b46a 100644 --- a/crates/api-types/src/common.rs +++ b/crates/api-types/src/common.rs @@ -21,3 +21,12 @@ pub type ActivityFeedResponse = PaginatedResponse; pub type WatchlistResponse = PaginatedResponse; pub type PaginatedMovieHits = PaginatedResponse; pub type PaginatedPersonHits = PaginatedResponse; + +/// The one body every JSON API refusal carries. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ErrorResponse { + /// Human-readable message. + pub error: String, + /// Stable machine-readable code, e.g. `NOT_FOUND`. + pub code: String, +} diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs index de892ab4..d11d7c98 100644 --- a/crates/bootstrap/tests/route_smoke_test.rs +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -414,3 +414,23 @@ async fn openapi_paths_match_the_recorded_list() { let expected = std::fs::read_to_string(&path).unwrap(); assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}"); } + +#[cfg(feature = "federation")] +#[tokio::test] +async fn profile_page_serves_the_actor_document_to_activitypub_clients() { + let seed = seed().await; + let req = Request::builder() + .method(Method::GET) + .uri(format!("/users/{}", seed.user_id)) + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(); + let (status, headers, body) = send(&seed.router, req).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + headers.get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + let actor: serde_json::Value = serde_json::from_str(&body).expect("actor JSON"); + assert_eq!(actor["type"], "Person"); +} diff --git a/docs/adr/0014-http-adapter-and-one-refusal-shape.md b/docs/adr/0014-http-adapter-and-one-refusal-shape.md new file mode 100644 index 00000000..876a68a9 --- /dev/null +++ b/docs/adr/0014-http-adapter-and-one-refusal-shape.md @@ -0,0 +1,52 @@ +# `http-axum` is the driving adapter, and every JSON refusal has one shape + +The old `presentation` crate was renamed `http-axum` and now reads as what it is: the HTTP driving +adapter. It is split into three modules by audience: `api` (JSON under `/api/v1`), `html` (the +classic server-rendered UI, with its askama templates absorbed into `html/templates`) and +`federation` (ActivityPub-facing and federation-gated routes, compiled only with the `federation` +feature, ADR-0009). `router.rs` assembles them; handlers reach use cases only through +`app_ctx.deps` (ADR-0006). + +A JSON client previously met several failure bodies: `{"error": "..."}` from `ApiError`, bare +status codes with empty bodies from handlers that returned `StatusCode::NOT_FOUND`, plain text from +`domain_error_response`, axum's plain-text rejections for malformed `Json`/`Query`/`Path`, and the +rate limiter's own 429 text. None of the refusals appeared in the OpenAPI document. + +## Decision + +**One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the +SPA and existing clients read it); `code` is an additive, stable machine-readable field: +`NOT_FOUND`, `INVALID_INPUT`, `CONFLICT`, `UNAUTHORIZED`, `FORBIDDEN`, `RATE_LIMITED`, `INTERNAL`. + +**One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON +error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that +existed before (`InvalidRating`/`ValidationError` 400, `NotFound` 404, `Unauthorized` 401, +`Forbidden` 403, `InfrastructureError` 500 with the generic message `internal server error` and the +cause logged, never sent). + +**Extractors.** `extractors::Body`, `Params` and `PathId` wrap axum's `Json`, `Query` and +`Path` and turn their rejections into `refuse(.., INVALID_INPUT, ..)`. The status is whatever axum +chose for the rejection (400 for syntax errors, 415 wrong content type, 422 well-formed JSON of the +wrong shape, 413 oversize), so clients that already handled those statuses are unaffected; only the +body changed. JSON API handlers use them instead of the axum originals. + +**Rate limiting.** The 429 of the `/api/v1` limiters goes through `refuse` (`RATE_LIMITED`). The +HTML and ActivityPub limiters keep axum-governor's default response. + +**HTML is unchanged.** Classic handlers keep `domain_error_response` (plain status text / HTML +pages), redirects and statuses. ActivityPub-facing routes keep their status-only errors. Only JSON +responses were unified. + +**The spec says so.** Every operation documents at least one 4xx, every authenticated operation +documents 401, and every 4xx/5xx references `ErrorResponse`. `tests/openapi_contract_test.rs` +builds the spec from `http_axum::openapi::spec()` (no bootstrap) and fails when a new endpoint +skips this, together with a test that every 2xx JSON response declares a schema. + +## Consequences + +- Statuses of existing JSON endpoints are unchanged; error bodies that were empty or plain text on + `/api/v1` are now `{"error", "code"}` JSON. Bodies that were already `{"error"}` gained `code`. +- `cargo test -p http-axum` alone does not build with default features because the dev-dependency + on `bootstrap` needs its `federation` feature to match; `cargo test -p http-axum + --no-default-features --test openapi_contract_test` runs the contract test standalone, and + `cargo test --workspace` runs it with federation on.