refactor: single refusal shape with openapi contract test

This commit is contained in:
2026-10-25 21:55:38 +01:00
parent 816d39ffd6
commit c8d9138349
25 changed files with 764 additions and 273 deletions

View File

@@ -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<AppState>,
Json(req): Json<LoginRequest>,
Body(req): Body<LoginRequest>,
) -> Result<Json<LoginResponse>, 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<AppState>,
Json(req): Json<RegisterRequest>,
Body(req): Body<RegisterRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Json(req): Json<RefreshRequest>,
Body(req): Body<RefreshRequest>,
) -> Result<Json<RefreshResponse>, 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<AppState>,
Json(req): Json<LogoutRequest>,
Body(req): Body<LogoutRequest>,
) -> StatusCode {
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
.await;

View File

@@ -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<AppState>,
Query(params): Query<DiaryQueryParams>,
Params(params): Params<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<LogReviewRequest>,
Body(req): Body<LogReviewRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>,
PathId(review_id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>,
Json(req): Json<EditReviewRequest>,
PathId(review_id): PathId<Uuid>,
Body(req): Body<EditReviewRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Query(params): Query<ExportQueryParams>,
Params(params): Params<ExportQueryParams>,
) -> 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(&params.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<AppState>,
Query(params): Query<ActivityFeedQueryParams>,
Params(params): Params<ActivityFeedQueryParams>,
) -> Result<Json<ActivityFeedResponse>, ApiError> {
let page = get_feed_uc::execute(
&state.app_ctx.deps.diary.get_activity_feed,

View File

@@ -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<AppState>,
user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>,
Body(req): Body<CreateGoalRequest>,
) -> Result<Json<GoalDto>, 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<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
Json(req): Json<UpdateGoalRequest>,
PathId(year): PathId<u16>,
Body(req): Body<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, 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<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
PathId(year): PathId<u16>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>,
PathId(user_id): PathId<Uuid>,
) -> Result<Json<GoalsResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<UpdateUserSettingsRequest>,
Body(req): Body<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute(
&state.app_ctx.deps.users.update_settings,

View File

@@ -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<serde_json::Value>)),
(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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<SaveProfileRequest>,
Body(body): Body<SaveProfileRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(profile_id_str): Path<String>,
PathId(profile_id_str): PathId<String>,
) -> Result<impl IntoResponse, ApiError> {
let profile_id = profile_id_str
.parse::<uuid::Uuid>()

View File

@@ -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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
PathId(session_id_str): PathId<String>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
@@ -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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ApplyMappingRequest>,
PathId(session_id_str): PathId<String>,
Body(body): Body<ApplyMappingRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
@@ -173,7 +174,7 @@ pub async fn api_put_mapping(
pub async fn api_get_preview(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
PathId(session_id_str): PathId<String>,
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
@@ -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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ConfirmRequest>,
PathId(session_id_str): PathId<String>,
Body(body): Body<ConfirmRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
@@ -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<AppState>,
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<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()

View File

@@ -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<AppState>,
user: AuthenticatedUser,
Query(params): Query<InsightsQueryParams>,
Params(params): Params<InsightsQueryParams>,
) -> Result<Json<InsightsResponse>, ApiError> {
let tz: chrono_tz::Tz = match params.tz.as_deref() {
None => chrono_tz::UTC,

View File

@@ -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<AppState>,
Query(params): Query<MoviesQueryParams>,
Params(params): Params<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, 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<AppState>,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, 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<AppState>,
_user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Path(movie_id): Path<Uuid>,
Query(params): Query<PaginationQueryParams>,
PathId(movie_id): PathId<Uuid>,
Params(params): Params<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, 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<AppState>,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> 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(),
}
}

View File

@@ -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<AppState>,
Query(params): Query<SearchQueryParams>,
Params(params): Params<SearchQueryParams>,
) -> 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<AppState>,
Path(id): Path<uuid::Uuid>,
PathId(id): PathId<uuid::Uuid>,
) -> 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<AppState>,
Path(id): Path<uuid::Uuid>,
PathId(id): PathId<uuid::Uuid>,
) -> 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",
)
}
}
}

View File

@@ -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<AppState>,
_user: AuthenticatedUser,
Path(user_id): Path<Uuid>,
PathId(user_id): PathId<Uuid>,
) -> Result<Json<ActorListResponse>, 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<AppState>,
_user: AuthenticatedUser,
Path(user_id): Path<Uuid>,
PathId(user_id): PathId<Uuid>,
) -> Result<Json<ActorListResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<FollowRequest>,
Body(body): Body<FollowRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Query(q): Query<RelationshipQuery>,
Params(q): Params<RelationshipQuery>,
) -> Result<Json<FollowRelationResponse>, ApiError> {
let target = state.app_ctx.instance.identify(&q.actor_url);
let rel = application::social::get_relation::execute(

View File

@@ -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<AppState>,
AuthenticatedUser(viewer_id): AuthenticatedUser,
Path(user_id): Path<Uuid>,
Query(params): Query<UserProfileQueryParams>,
PathId(user_id): PathId<Uuid>,
Params(params): Params<UserProfileQueryParams>,
) -> 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 {

View File

@@ -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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<serde_json::Value>,
Body(body): Body<serde_json::Value>,
) -> 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<domain::models::ProfileField> = 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<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let result =

View File

@@ -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<AppState>,
user: AuthenticatedUser,
Query(params): Query<PaginationQueryParams>,
Params(params): Params<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>,
Body(req): Body<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
&state.app_ctx.deps.watchlist.is_on_watchlist,

View File

@@ -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<String>,
}
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<String> {
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<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
Params(query): Params<WebhookTokenQuery>,
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<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
Params(query): Params<WebhookTokenQuery>,
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<Vec<u8>> = 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<GenerateTokenRequest>,
Body(req): Body<GenerateTokenRequest>,
) -> Result<Json<GenerateTokenResponse>, 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<WebhookTokenDto>),
(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<AppState>,
user: AuthenticatedUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
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<WatchQueueEntryDto>),
(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<AppState>,
user: AuthenticatedUser,
Json(req): Json<ConfirmWatchRequest>,
Body(req): Body<ConfirmWatchRequest>,
) -> Result<Json<ConfirmWatchResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<DismissWatchRequest>,
Body(req): Body<DismissWatchRequest>,
) -> Result<Json<DismissWatchResponse>, ApiError> {
let cmd = DismissWatchEventsCommand {
user_id: user.0.value(),

View File

@@ -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<AppState>,
_admin: AdminApiUser,
Json(req): Json<GenerateWrapUpRequest>,
Body(req): Body<GenerateWrapUpRequest>,
) -> Result<Json<WrapUpGeneratedResponse>, 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<AppState>,
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> Result<Json<WrapUpStatusResponse>, 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<AppState>,
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> 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<AppState>,
_admin: AdminApiUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_wrapup::execute(
&state.app_ctx.deps.wrapup.delete_wrapup,

View File

@@ -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<String>) -> 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(),
}
}
}

View File

@@ -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<JsonRejection> for MalformedRequest {
fn from(r: JsonRejection) -> Self {
Self {
status: r.status(),
message: r.body_text(),
}
}
}
impl From<QueryRejection> for MalformedRequest {
fn from(r: QueryRejection) -> Self {
Self {
status: r.status(),
message: r.body_text(),
}
}
}
impl From<PathRejection> 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<T>(pub T);
impl<S, T> FromRequest<S> for Body<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = MalformedRequest;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let axum::Json(v) = axum::Json::<T>::from_request(req, state).await?;
Ok(Self(v))
}
}
/// `axum::extract::Query` with refusals in the one error shape.
pub struct Params<T>(pub T);
impl<S, T> FromRequestParts<S> for Params<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = MalformedRequest;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Query(v) = Query::<T>::from_request_parts(parts, state).await?;
Ok(Self(v))
}
}
/// `axum::extract::Path` with refusals in the one error shape.
pub struct PathId<T>(pub T);
impl<S, T> FromRequestParts<S> for PathId<T>
where
S: Send + Sync,
T: DeserializeOwned + Send,
{
type Rejection = MalformedRequest;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Path(v) = Path::<T>::from_request_parts(parts, state).await?;
Ok(Self(v))
}
}

View File

@@ -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},

View File

@@ -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<BlockedDomainResponse>),
(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<AppState>,
_admin: AdminApiUser,
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
Body(body): Body<AddBlockedDomainRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
_admin: AdminApiUser,
axum::extract::Path(domain): axum::extract::Path<String>,
PathId(domain): PathId<String>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<BlockedActorResponse>),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]

View File

@@ -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(),
<api_types::ErrorResponse as utoipa::PartialSchema>::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))

View File

@@ -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<serde_json::Value> {
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<AppState> {
.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<AppState> {
.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));

View File

@@ -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");
}

View File

@@ -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:?}");
}