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 crate::{errors::ApiError, state::AppState};
use api_types::{ use api_types::{
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
@@ -12,12 +13,12 @@ use axum::{Json, extract::State, http::StatusCode};
request_body = LoginRequest, request_body = LoginRequest,
responses( responses(
(status = 200, body = LoginResponse), (status = 200, body = LoginResponse),
(status = 401, description = "Invalid credentials"), (status = 401, description = "Invalid credentials", body = api_types::ErrorResponse),
) )
)] )]
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<LoginRequest>, Body(req): Body<LoginRequest>,
) -> Result<Json<LoginResponse>, ApiError> { ) -> Result<Json<LoginResponse>, ApiError> {
let result = login_uc::execute( let result = login_uc::execute(
&state.app_ctx.deps.auth.login, &state.app_ctx.deps.auth.login,
@@ -42,12 +43,12 @@ pub async fn login(
request_body = RegisterRequest, request_body = RegisterRequest,
responses( responses(
(status = 201, description = "User registered"), (status = 201, description = "User registered"),
(status = 400, description = "Invalid input"), (status = 400, description = "Invalid input", body = api_types::ErrorResponse),
) )
)] )]
pub async fn register( pub async fn register(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RegisterRequest>, Body(req): Body<RegisterRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
register_uc::execute( register_uc::execute(
&state.app_ctx.deps.auth.register, &state.app_ctx.deps.auth.register,
@@ -67,12 +68,12 @@ pub async fn register(
request_body = RefreshRequest, request_body = RefreshRequest,
responses( responses(
(status = 200, body = RefreshResponse), (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( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RefreshRequest>, Body(req): Body<RefreshRequest>,
) -> Result<Json<RefreshResponse>, ApiError> { ) -> Result<Json<RefreshResponse>, ApiError> {
let result = let result =
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token) 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( pub async fn api_logout(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<LogoutRequest>, Body(req): Body<LogoutRequest>,
) -> StatusCode { ) -> StatusCode {
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
.await; .await;

View File

@@ -1,3 +1,4 @@
use crate::extractors::{Body, Params, PathId};
use crate::support::build_export_response; use crate::support::build_export_response;
use crate::{ use crate::{
errors::ApiError, errors::ApiError,
@@ -14,12 +15,7 @@ use application::diary::{
delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
queries::GetActivityFeedQuery, queries::GetActivityFeedQuery,
}; };
use axum::{ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use uuid::Uuid; use uuid::Uuid;
#[utoipa::path( #[utoipa::path(
@@ -27,13 +23,13 @@ use uuid::Uuid;
params(DiaryQueryParams), params(DiaryQueryParams),
responses( responses(
(status = 200, body = DiaryResponse), (status = 200, body = DiaryResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_diary( pub async fn get_diary(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<DiaryQueryParams>, Params(params): Params<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, ApiError> { ) -> Result<Json<DiaryResponse>, ApiError> {
let page = let page =
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?; 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, request_body = LogReviewRequest,
responses( responses(
(status = 201, description = "Review created"), (status = 201, description = "Review created"),
(status = 400, description = "Invalid input"), (status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_review( pub async fn post_review(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<LogReviewRequest>, Body(req): Body<LogReviewRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let data = LogReviewData::try_from(req).map_err(ApiError)?; let data = LogReviewData::try_from(req).map_err(ApiError)?;
log_review::execute( log_review::execute(
@@ -79,16 +75,16 @@ pub async fn post_review(
params(("id" = Uuid, Path, description = "Review ID")), params(("id" = Uuid, Path, description = "Review ID")),
responses( responses(
(status = 204, description = "Review deleted"), (status = 204, description = "Review deleted"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden"), (status = 403, description = "Forbidden", body = api_types::ErrorResponse),
(status = 404, description = "Review not found"), (status = 404, description = "Review not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_review( pub async fn delete_review(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>, PathId(review_id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cmd = DeleteReviewCommand { let cmd = DeleteReviewCommand {
review_id, review_id,
@@ -104,18 +100,18 @@ pub async fn delete_review(
params(("id" = Uuid, Path, description = "Review ID")), params(("id" = Uuid, Path, description = "Review ID")),
responses( responses(
(status = 200, description = "Review updated"), (status = 200, description = "Review updated"),
(status = 400, description = "Invalid input"), (status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden"), (status = 403, description = "Forbidden", body = api_types::ErrorResponse),
(status = 404, description = "Review not found"), (status = 404, description = "Review not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn patch_review( pub async fn patch_review(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>, PathId(review_id): PathId<Uuid>,
Json(req): Json<EditReviewRequest>, Body(req): Body<EditReviewRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let watched_at = req let watched_at = req
.watched_at .watched_at
@@ -140,27 +136,37 @@ pub async fn patch_review(
params(ExportQueryParams), params(ExportQueryParams),
responses( responses(
(status = 200, description = "Diary file download", content_type = "text/csv"), (status = 200, description = "Diary file download", content_type = "text/csv"),
(status = 400, description = "Invalid format parameter"), (status = 400, description = "Invalid format parameter", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn export_diary( pub async fn export_diary(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Query(params): Query<ExportQueryParams>, Params(params): Params<ExportQueryParams>,
) -> impl IntoResponse { ) -> 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) build_export_response(&params.format, user.0.value(), &state)
} }
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/activity-feed", get, path = "/api/v1/activity-feed",
params(ActivityFeedQueryParams), 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( pub async fn get_activity_feed(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<ActivityFeedQueryParams>, Params(params): Params<ActivityFeedQueryParams>,
) -> Result<Json<ActivityFeedResponse>, ApiError> { ) -> Result<Json<ActivityFeedResponse>, ApiError> {
let page = get_feed_uc::execute( let page = get_feed_uc::execute(
&state.app_ctx.deps.diary.get_activity_feed, &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 crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::mappers::goal_with_progress_to_dto; use api_types::mappers::goal_with_progress_to_dto;
use api_types::{ use api_types::{
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
UserSettingsDto, UserSettingsDto,
}; };
use axum::{ use axum::{Json, extract::State, http::StatusCode};
Json,
extract::{Path, State},
http::StatusCode,
};
use uuid::Uuid; use uuid::Uuid;
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/goals", get, path = "/api/v1/goals",
responses( responses(
(status = 200, body = GoalsResponse), (status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -40,14 +37,14 @@ pub async fn list_goals(
request_body = CreateGoalRequest, request_body = CreateGoalRequest,
responses( responses(
(status = 200, body = GoalDto), (status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_goal( pub async fn create_goal(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>, Body(req): Body<CreateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> { ) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute( let g = application::goals::create::execute(
&state.app_ctx.deps.goals.command, &state.app_ctx.deps.goals.command,
@@ -66,16 +63,16 @@ pub async fn create_goal(
request_body = UpdateGoalRequest, request_body = UpdateGoalRequest,
responses( responses(
(status = 200, body = GoalDto), (status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Goal not found"), (status = 404, description = "Goal not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn update_goal( pub async fn update_goal(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Path(year): Path<u16>, PathId(year): PathId<u16>,
Json(req): Json<UpdateGoalRequest>, Body(req): Body<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> { ) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute( let g = application::goals::update::execute(
&state.app_ctx.deps.goals.command, &state.app_ctx.deps.goals.command,
@@ -93,15 +90,15 @@ pub async fn update_goal(
delete, path = "/api/v1/goals/{year}", delete, path = "/api/v1/goals/{year}",
responses( responses(
(status = 204, description = "Goal deleted"), (status = 204, description = "Goal deleted"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Goal not found"), (status = 404, description = "Goal not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_goal( pub async fn delete_goal(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Path(year): Path<u16>, PathId(year): PathId<u16>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
application::goals::delete::execute( application::goals::delete::execute(
&state.app_ctx.deps.goals.command, &state.app_ctx.deps.goals.command,
@@ -118,14 +115,14 @@ pub async fn delete_goal(
get, path = "/api/v1/users/{id}/goals", get, path = "/api/v1/users/{id}/goals",
responses( responses(
(status = 200, body = GoalsResponse), (status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_user_goals( pub async fn get_user_goals(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser, AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>, PathId(user_id): PathId<Uuid>,
) -> Result<Json<GoalsResponse>, ApiError> { ) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute( let goals = application::goals::list::execute(
&state.app_ctx.deps.goals.query, &state.app_ctx.deps.goals.query,
@@ -141,7 +138,7 @@ pub async fn get_user_goals(
get, path = "/api/v1/settings", get, path = "/api/v1/settings",
responses( responses(
(status = 200, body = UserSettingsDto), (status = 200, body = UserSettingsDto),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -166,14 +163,14 @@ pub async fn get_settings(
request_body = UpdateUserSettingsRequest, request_body = UpdateUserSettingsRequest,
responses( responses(
(status = 204, description = "Settings updated"), (status = 204, description = "Settings updated"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn update_settings( pub async fn update_settings(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<UpdateUserSettingsRequest>, Body(req): Body<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute( application::users::update_settings::execute(
&state.app_ctx.deps.users.update_settings, &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 crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::SaveProfileRequest; use api_types::SaveProfileRequest;
use application::import::{ use application::import::{
@@ -5,11 +6,7 @@ use application::import::{
delete_profile as delete_import_profile, list_profiles as list_import_profiles, delete_profile as delete_import_profile, list_profiles as list_import_profiles,
save_profile as save_import_profile, save_profile as save_import_profile,
}; };
use axum::{ use axum::{extract::State, http::StatusCode, response::IntoResponse};
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::value_objects::ImportSessionId; use domain::value_objects::ImportSessionId;
@@ -17,7 +14,7 @@ use domain::value_objects::ImportSessionId;
get, path = "/api/v1/import/profiles", get, path = "/api/v1/import/profiles",
responses( responses(
(status = 200, description = "List of saved import profiles", body = inline(Vec<serde_json::Value>)), (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" = [])) security(("bearer_auth" = []))
)] )]
@@ -46,15 +43,15 @@ pub async fn api_get_profiles(
request_body = SaveProfileRequest, request_body = SaveProfileRequest,
responses( responses(
(status = 200, description = "Profile saved", body = inline(serde_json::Value)), (status = 200, description = "Profile saved", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 422, description = "Session has no mapping yet"), (status = 422, description = "Session has no mapping yet", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn api_post_profile( pub async fn api_post_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<SaveProfileRequest>, Body(body): Body<SaveProfileRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let session_id = body let session_id = body
.session_id .session_id
@@ -80,15 +77,15 @@ pub async fn api_post_profile(
params(("id" = String, Path, description = "Import profile UUID")), params(("id" = String, Path, description = "Import profile UUID")),
responses( responses(
(status = 204, description = "Deleted"), (status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Profile not found"), (status = 404, description = "Profile not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn api_delete_profile( pub async fn api_delete_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(profile_id_str): Path<String>, PathId(profile_id_str): PathId<String>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let profile_id = profile_id_str let profile_id = profile_id_str
.parse::<uuid::Uuid>() .parse::<uuid::Uuid>()

View File

@@ -1,3 +1,4 @@
use crate::extractors::{Body, PathId};
use crate::support::str_to_domain_field; use crate::support::str_to_domain_field;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{ use api_types::{
@@ -14,7 +15,7 @@ use application::import::{
get_session_state, get_session_state,
}; };
use axum::{ use axum::{
extract::{Multipart, Path, State}, extract::{Multipart, State},
response::IntoResponse, response::IntoResponse,
}; };
use domain::errors::DomainError; 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)"), request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"),
responses( responses(
(status = 200, body = SessionCreatedResponse), (status = 200, body = SessionCreatedResponse),
(status = 400, description = "No file provided"), (status = 400, description = "No file provided", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 422, description = "Parse error"), (status = 422, description = "Parse error", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -87,15 +88,15 @@ pub async fn api_post_session(
params(("id" = String, Path, description = "Import session UUID")), params(("id" = String, Path, description = "Import session UUID")),
responses( responses(
(status = 200, body = SessionStateResponse), (status = 200, body = SessionStateResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Session not found"), (status = 404, description = "Session not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn api_get_session( pub async fn api_get_session(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>, PathId(session_id_str): PathId<String>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str let session_id = session_id_str
.parse::<uuid::Uuid>() .parse::<uuid::Uuid>()
@@ -121,16 +122,16 @@ pub async fn api_get_session(
request_body = ApplyMappingRequest, request_body = ApplyMappingRequest,
responses( responses(
(status = 200, description = "Mapping applied", body = inline(serde_json::Value)), (status = 200, description = "Mapping applied", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 422, description = "Mapping error"), (status = 422, description = "Mapping error", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn api_put_mapping( pub async fn api_put_mapping(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>, PathId(session_id_str): PathId<String>,
axum::Json(body): axum::Json<ApplyMappingRequest>, Body(body): Body<ApplyMappingRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str let session_id = session_id_str
.parse::<uuid::Uuid>() .parse::<uuid::Uuid>()
@@ -173,7 +174,7 @@ pub async fn api_put_mapping(
pub async fn api_get_preview( pub async fn api_get_preview(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>, PathId(session_id_str): PathId<String>,
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> { ) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
let session_id = session_id_str let session_id = session_id_str
.parse::<uuid::Uuid>() .parse::<uuid::Uuid>()
@@ -238,16 +239,16 @@ pub async fn api_get_preview(
request_body = ConfirmRequest, request_body = ConfirmRequest,
responses( responses(
(status = 200, description = "Import summary", body = inline(serde_json::Value)), (status = 200, description = "Import summary", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Session not found"), (status = 404, description = "Session not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn api_post_confirm( pub async fn api_post_confirm(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>, PathId(session_id_str): PathId<String>,
axum::Json(body): axum::Json<ConfirmRequest>, Body(body): Body<ConfirmRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str let session_id = session_id_str
.parse::<uuid::Uuid>() .parse::<uuid::Uuid>()
@@ -277,17 +278,17 @@ pub async fn api_post_confirm(
), ),
responses( responses(
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)), (status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
(status = 400, description = "Invalid ID"), (status = 400, description = "Invalid ID", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Session or profile not found"), (status = 404, description = "Session or profile not found", body = api_types::ErrorResponse),
(status = 422, description = "Mapping error"), (status = 422, description = "Mapping error", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn api_apply_profile( pub async fn api_apply_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, 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> { ) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str let session_id = session_id_str
.parse::<uuid::Uuid>() .parse::<uuid::Uuid>()

View File

@@ -1,10 +1,8 @@
use crate::extractors::Params;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse}; use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
use application::insights::get as get_insights_uc; use application::insights::get as get_insights_uc;
use axum::{ use axum::{Json, extract::State};
Json,
extract::{Query, State},
};
use domain::{errors::DomainError, models::Insight}; use domain::{errors::DomainError, models::Insight};
fn to_dto(insight: Insight) -> InsightDto { fn to_dto(insight: Insight) -> InsightDto {
@@ -68,15 +66,15 @@ fn to_dto(insight: Insight) -> InsightDto {
params(InsightsQueryParams), params(InsightsQueryParams),
responses( responses(
(status = 200, body = InsightsResponse), (status = 200, body = InsightsResponse),
(status = 400, description = "Unknown time zone"), (status = 400, description = "Unknown time zone", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_insights( pub async fn get_insights(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Query(params): Query<InsightsQueryParams>, Params(params): Params<InsightsQueryParams>,
) -> Result<Json<InsightsResponse>, ApiError> { ) -> Result<Json<InsightsResponse>, ApiError> {
let tz: chrono_tz::Tz = match params.tz.as_deref() { let tz: chrono_tz::Tz = match params.tz.as_deref() {
None => chrono_tz::UTC, None => chrono_tz::UTC,

View File

@@ -1,3 +1,4 @@
use crate::extractors::{Params, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{ use api_types::{
CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse, CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse,
@@ -12,12 +13,7 @@ use application::{
}, },
movies::{get_movies, queries::GetMoviesQuery, sync_poster}, movies::{get_movies, queries::GetMoviesQuery, sync_poster},
}; };
use axum::{ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use domain::services::review_history::Trend; use domain::services::review_history::Trend;
use uuid::Uuid; use uuid::Uuid;
@@ -26,11 +22,12 @@ use uuid::Uuid;
params(MoviesQueryParams), params(MoviesQueryParams),
responses( responses(
(status = 200, body = MoviesResponse), (status = 200, body = MoviesResponse),
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
) )
)] )]
pub async fn list_movies( pub async fn list_movies(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<MoviesQueryParams>, Params(params): Params<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, ApiError> { ) -> Result<Json<MoviesResponse>, ApiError> {
let page = get_movies::execute( let page = get_movies::execute(
&state.app_ctx.deps.movies.get_movies, &state.app_ctx.deps.movies.get_movies,
@@ -61,12 +58,12 @@ pub async fn list_movies(
params(("id" = Uuid, Path, description = "Movie ID")), params(("id" = Uuid, Path, description = "Movie ID")),
responses( responses(
(status = 200, body = ReviewHistoryResponse), (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( pub async fn get_review_history(
State(state): State<AppState>, State(state): State<AppState>,
Path(movie_id): Path<Uuid>, PathId(movie_id): PathId<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, ApiError> { ) -> Result<Json<ReviewHistoryResponse>, ApiError> {
let (history, trend) = get_review_history::execute( let (history, trend) = get_review_history::execute(
&state.app_ctx.deps.diary.get_review_history, &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")), params(("id" = Uuid, Path, description = "Movie ID")),
responses( responses(
(status = 204, description = "Poster synced"), (status = 204, description = "Poster synced"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Movie not found"), (status = 404, description = "Movie not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn sync_poster( pub async fn sync_poster(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthenticatedUser, _user: AuthenticatedUser,
Path(movie_id): Path<Uuid>, PathId(movie_id): PathId<Uuid>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
sync_poster::execute( sync_poster::execute(
&state.app_ctx.deps.movies.sync_poster, &state.app_ctx.deps.movies.sync_poster,
@@ -118,13 +115,13 @@ pub async fn sync_poster(
params(("movie_id" = Uuid, Path, description = "Movie ID")), params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses( responses(
(status = 200, body = MovieDetailResponse), (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( pub async fn get_movie_detail(
State(state): State<AppState>, State(state): State<AppState>,
Path(movie_id): Path<Uuid>, PathId(movie_id): PathId<Uuid>,
Query(params): Query<PaginationQueryParams>, Params(params): Params<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, ApiError> { ) -> Result<Json<MovieDetailResponse>, ApiError> {
let limit = params.limit.unwrap_or(20); let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
@@ -175,12 +172,12 @@ pub async fn get_movie_detail(
params(("id" = Uuid, Path, description = "Movie ID")), params(("id" = Uuid, Path, description = "Movie ID")),
responses( responses(
(status = 200, body = MovieProfileResponse), (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( pub async fn get_movie_profile(
State(state): State<AppState>, State(state): State<AppState>,
Path(movie_id): Path<Uuid>, PathId(movie_id): PathId<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
use application::movies::get_movie_profile; use application::movies::get_movie_profile;
let query = get_movie_profile::GetMovieProfileQuery { movie_id }; let query = get_movie_profile::GetMovieProfileQuery { movie_id };
@@ -243,7 +240,9 @@ pub async fn get_movie_profile(
}) })
.into_response() .into_response()
} }
Ok(None) => StatusCode::NOT_FOUND.into_response(), Ok(None) => {
Err(e) => crate::errors::domain_error_response(e), 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 crate::state::AppState;
use api_types::search::{ use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
@@ -7,11 +8,7 @@ use application::{
person::{get as get_person, get_credits as get_person_credits}, person::{get as get_person, get_credits as get_person_credits},
search::execute as search_uc, search::execute as search_uc,
}; };
use axum::{ use axum::{extract::State, http::StatusCode, response::IntoResponse};
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use domain::models::{PersonId, collections::PageParams}; use domain::models::{PersonId, collections::PageParams};
#[utoipa::path( #[utoipa::path(
@@ -19,12 +16,13 @@ use domain::models::{PersonId, collections::PageParams};
params(api_types::search::SearchQueryParams), params(api_types::search::SearchQueryParams),
responses( responses(
(status = 200, body = api_types::search::SearchResponse), (status = 200, body = api_types::search::SearchResponse),
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
), ),
tag = "search", tag = "search",
)] )]
pub async fn get_search( pub async fn get_search(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<SearchQueryParams>, Params(params): Params<SearchQueryParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let query = domain::models::SearchQuery { let query = domain::models::SearchQuery {
text: params.q, text: params.q,
@@ -80,7 +78,7 @@ pub async fn get_search(
}, },
}) })
.into_response(), .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")), params(("id" = Uuid, Path, description = "Person ID")),
responses( responses(
(status = 200, body = api_types::search::PersonDto), (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", tag = "search",
)] )]
pub async fn get_person_handler( pub async fn get_person_handler(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, PathId(id): PathId<uuid::Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match get_person::execute( match get_person::execute(
&state.app_ctx.deps.person.get_person, &state.app_ctx.deps.person.get_person,
@@ -104,8 +102,10 @@ pub async fn get_person_handler(
.await .await
{ {
Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(), Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(), Ok(None) => {
Err(e) => crate::errors::domain_error_response(e), 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")), params(("id" = Uuid, Path, description = "Person ID")),
responses( responses(
(status = 200, body = api_types::search::PersonCreditsDto), (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", tag = "search",
)] )]
pub async fn get_person_credits_handler( pub async fn get_person_credits_handler(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, PathId(id): PathId<uuid::Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match get_person_credits::execute( match get_person_credits::execute(
&state.app_ctx.deps.person.get_person, &state.app_ctx.deps.person.get_person,
@@ -155,7 +155,7 @@ pub async fn get_person_credits_handler(
.collect(), .collect(),
}) })
.into_response(), .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 { ) -> impl IntoResponse {
let event = domain::events::DomainEvent::SearchReindexRequested; let event = domain::events::DomainEvent::SearchReindexRequested;
match state.app_ctx.services.event_publisher.publish(&event).await { match state.app_ctx.services.event_publisher.publish(&event).await {
Ok(()) => StatusCode::ACCEPTED, Ok(()) => StatusCode::ACCEPTED.into_response(),
Err(e) => { Err(e) => {
tracing::error!("failed to publish reindex event: {:?}", 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 crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{ use api_types::{
ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest, ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest,
PendingCountResponse, RemoteActorDto, PendingCountResponse, RemoteActorDto,
}; };
use axum::{ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity}; use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity};
use uuid::Uuid; use uuid::Uuid;
@@ -29,7 +25,7 @@ fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> Remot
get, path = "/api/v1/social/following", get, path = "/api/v1/social/following",
responses( responses(
(status = 200, body = ActorListResponse), (status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -55,7 +51,7 @@ pub async fn get_following(
get, path = "/api/v1/social/followers", get, path = "/api/v1/social/followers",
responses( responses(
(status = 200, body = ActorListResponse), (status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -80,7 +76,7 @@ pub async fn get_followers(
pub async fn get_user_following( pub async fn get_user_following(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthenticatedUser, _user: AuthenticatedUser,
Path(user_id): Path<Uuid>, PathId(user_id): PathId<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> { ) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
let identities = let identities =
@@ -97,7 +93,7 @@ pub async fn get_user_following(
pub async fn get_user_followers( pub async fn get_user_followers(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthenticatedUser, _user: AuthenticatedUser,
Path(user_id): Path<Uuid>, PathId(user_id): PathId<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> { ) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
let identities = let identities =
@@ -116,14 +112,14 @@ pub async fn get_user_followers(
request_body = FollowRequest, request_body = FollowRequest,
responses( responses(
(status = 200, description = "Follow request sent"), (status = 200, description = "Follow request sent"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn follow( pub async fn follow(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(body): Json<FollowRequest>, Body(body): Body<FollowRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
application::social::execute::execute_command( application::social::execute::execute_command(
&state.app_ctx.deps.social.command, &state.app_ctx.deps.social.command,
@@ -141,14 +137,14 @@ pub async fn follow(
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
responses( responses(
(status = 200, description = "Unfollowed"), (status = 200, description = "Unfollowed"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn unfollow( pub async fn unfollow(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>, Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command( application::social::execute::execute_command(
@@ -167,14 +163,14 @@ pub async fn unfollow(
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
responses( responses(
(status = 200, description = "Follower accepted"), (status = 200, description = "Follower accepted"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn accept_follower( pub async fn accept_follower(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>, Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command( application::social::execute::execute_command(
@@ -193,14 +189,14 @@ pub async fn accept_follower(
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
responses( responses(
(status = 200, description = "Follower rejected"), (status = 200, description = "Follower rejected"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn reject_follower( pub async fn reject_follower(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>, Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command( application::social::execute::execute_command(
@@ -219,14 +215,14 @@ pub async fn reject_follower(
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
responses( responses(
(status = 200, description = "Follower removed"), (status = 200, description = "Follower removed"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn remove_follower( pub async fn remove_follower(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>, Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command( application::social::execute::execute_command(
@@ -244,7 +240,7 @@ pub async fn remove_follower(
get, path = "/api/v1/social/followers/pending", get, path = "/api/v1/social/followers/pending",
responses( responses(
(status = 200, body = ActorListResponse), (status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -270,7 +266,7 @@ pub async fn get_pending_followers(
get, path = "/api/v1/social/followers/pending/count", get, path = "/api/v1/social/followers/pending/count",
responses( responses(
(status = 200, body = PendingCountResponse), (status = 200, body = PendingCountResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -290,7 +286,7 @@ pub async fn get_pending_follower_count(
get, path = "/api/v1/social/following/pending", get, path = "/api/v1/social/following/pending",
responses( responses(
(status = 200, body = ActorListResponse), (status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -322,14 +318,14 @@ pub struct RelationshipQuery {
params(("actor_url" = String, Query, description = "Canonical actor URL of the target")), params(("actor_url" = String, Query, description = "Canonical actor URL of the target")),
responses( responses(
(status = 200, body = FollowRelationResponse), (status = 200, body = FollowRelationResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_relationship( pub async fn get_relationship(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Query(q): Query<RelationshipQuery>, Params(q): Params<RelationshipQuery>,
) -> Result<Json<FollowRelationResponse>, ApiError> { ) -> Result<Json<FollowRelationResponse>, ApiError> {
let target = state.app_ctx.instance.identify(&q.actor_url); let target = state.app_ctx.instance.identify(&q.actor_url);
let rel = application::social::get_relation::execute( 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 crate::{extractors::AuthenticatedUser, state::AppState};
use api_types::mappers::goal_with_progress_to_dto; use api_types::mappers::goal_with_progress_to_dto;
use api_types::{ use api_types::{
@@ -8,12 +9,7 @@ use application::users::{
get_federated_profile, get_federated_profile_stats, get_local_profile, get_federated_profile, get_federated_profile_stats, get_local_profile,
queries::GetUserProfileQuery, queries::GetUserProfileQuery,
}; };
use axum::{ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use domain::errors::DomainError; use domain::errors::DomainError;
use std::str::FromStr; use std::str::FromStr;
use uuid::Uuid; use uuid::Uuid;
@@ -26,19 +22,25 @@ use uuid::Uuid;
), ),
responses( responses(
(status = 200, body = UserProfileResponse), (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( pub async fn get_user_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(viewer_id): AuthenticatedUser, AuthenticatedUser(viewer_id): AuthenticatedUser,
Path(user_id): Path<Uuid>, PathId(user_id): PathId<Uuid>,
Query(params): Query<UserProfileQueryParams>, Params(params): Params<UserProfileQueryParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let view_str = params.view.as_deref().unwrap_or("recent"); let view_str = params.view.as_deref().unwrap_or("recent");
let profile_view = match application::users::queries::ProfileView::from_str(view_str) { let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
Ok(v) => v, 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( let profile = match get_local_profile::execute(
@@ -73,9 +75,13 @@ pub async fn get_user_profile(
) )
.await; .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 { let view_data = if let Some(p) = profile.entries {
@@ -174,7 +180,7 @@ async fn build_federated_profile_response(
.await .await
{ {
Ok(p) => p, 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 { 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 crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; use api_types::{ProfileResponse, UserSummaryDto, UsersResponse};
use application::users::{ use application::users::{
@@ -14,8 +15,8 @@ use axum::{
get, path = "/api/v1/profile", get, path = "/api/v1/profile",
responses( responses(
(status = 200, body = ProfileResponse), (status = 200, body = ProfileResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "User not found"), (status = 404, description = "User not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -56,9 +57,9 @@ pub async fn get_profile(
put, path = "/api/v1/profile", put, path = "/api/v1/profile",
responses( responses(
(status = 204, description = "Profile updated"), (status = 204, description = "Profile updated"),
(status = 400, description = "Invalid input"), (status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 500, description = "Internal server error"), (status = 500, description = "Internal server error", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) 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 { match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(), 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, request_body = api_types::UpdateProfileFieldsRequest,
responses( responses(
(status = 204, description = "Profile fields updated"), (status = 204, description = "Profile fields updated"),
(status = 400, description = "Invalid input"), (status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 500, description = "Internal server error"), (status = 500, description = "Internal server error", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn update_profile_fields_handler( pub async fn update_profile_fields_handler(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<serde_json::Value>, Body(body): Body<serde_json::Value>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
Some(arr) => arr.clone(), 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 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 match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
{ {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::domain_error_response(e), Err(e) => crate::errors::ApiError(e).into_response(),
} }
} }
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/users", 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> { pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let result = let result =

View File

@@ -1,3 +1,4 @@
use crate::extractors::{Body, Params, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{ use api_types::{
AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse,
@@ -13,12 +14,7 @@ use application::{
remove as remove_from_watchlist, remove as remove_from_watchlist,
}, },
}; };
use axum::{ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use uuid::Uuid; use uuid::Uuid;
#[utoipa::path( #[utoipa::path(
@@ -29,14 +25,14 @@ use uuid::Uuid;
), ),
responses( responses(
(status = 200, body = WatchlistResponse), (status = 200, body = WatchlistResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_watchlist_handler( pub async fn get_watchlist_handler(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Query(params): Query<PaginationQueryParams>, Params(params): Params<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, ApiError> { ) -> Result<Json<WatchlistResponse>, ApiError> {
let page = get_watchlist::execute( let page = get_watchlist::execute(
&state.app_ctx.deps.watchlist.get_watchlist, &state.app_ctx.deps.watchlist.get_watchlist,
@@ -69,15 +65,15 @@ pub async fn get_watchlist_handler(
request_body = AddToWatchlistRequest, request_body = AddToWatchlistRequest,
responses( responses(
(status = 201, description = "Added to watchlist"), (status = 201, description = "Added to watchlist"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Movie not found"), (status = 404, description = "Movie not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_watchlist_add( pub async fn post_watchlist_add(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>, Body(req): Body<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
add_to_watchlist::execute( add_to_watchlist::execute(
&state.app_ctx.deps.watchlist.add, &state.app_ctx.deps.watchlist.add,
@@ -101,15 +97,15 @@ pub async fn post_watchlist_add(
params(("movie_id" = Uuid, Path, description = "Movie ID")), params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses( responses(
(status = 204, description = "Removed from watchlist"), (status = 204, description = "Removed from watchlist"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not on watchlist"), (status = 404, description = "Not on watchlist", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_watchlist_entry( pub async fn delete_watchlist_entry(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Path(movie_id): Path<Uuid>, PathId(movie_id): PathId<Uuid>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute( remove_from_watchlist::execute(
&state.app_ctx.deps.watchlist.remove_from_watchlist, &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")), params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses( responses(
(status = 200, body = WatchlistStatusResponse), (status = 200, body = WatchlistStatusResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_watchlist_status( pub async fn get_watchlist_status(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Path(movie_id): Path<Uuid>, PathId(movie_id): PathId<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> { ) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute( let on_watchlist = is_on_watchlist::execute(
&state.app_ctx.deps.watchlist.is_on_watchlist, &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 crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{ use api_types::{
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
@@ -16,7 +17,7 @@ use application::integrations::{
}; };
use axum::{ use axum::{
Json, Json,
extract::{Multipart, Path, State}, extract::{Multipart, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
}; };
@@ -36,6 +37,14 @@ pub struct WebhookTokenQuery {
pub token: Option<String>, 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> { fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option<String> {
extract_bearer_token(headers).or_else(|| query.token.clone()) 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"), request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"),
responses( responses(
(status = 200, description = "Event accepted or ignored"), (status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"), (status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
(status = 401, description = "Invalid or missing webhook token"), (status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_jellyfin_webhook( pub async fn post_jellyfin_webhook(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>, Params(query): Params<WebhookTokenQuery>,
body: axum::body::Bytes, body: axum::body::Bytes,
) -> impl IntoResponse { ) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) { let token = match extract_webhook_token(&headers, &query) {
Some(t) => t, Some(t) => t,
None => return StatusCode::UNAUTHORIZED, None => return missing_token(),
}; };
let cmd = IngestWatchEventCommand { 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"), request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"),
responses( responses(
(status = 200, description = "Event accepted or ignored"), (status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"), (status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
(status = 401, description = "Invalid or missing webhook token"), (status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_plex_webhook( pub async fn post_plex_webhook(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>, Params(query): Params<WebhookTokenQuery>,
mut multipart: Multipart, mut multipart: Multipart,
) -> impl IntoResponse { ) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) { let token = match extract_webhook_token(&headers, &query) {
Some(t) => t, Some(t) => t,
None => return StatusCode::UNAUTHORIZED, None => return missing_token(),
}; };
let mut payload_bytes: Option<Vec<u8>> = None; let mut payload_bytes: Option<Vec<u8>> = None;
@@ -108,7 +117,13 @@ pub async fn post_plex_webhook(
let raw_payload = match payload_bytes { let raw_payload = match payload_bytes {
Some(b) => b, 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 { let cmd = IngestWatchEventCommand {
@@ -124,7 +139,7 @@ async fn run_ingest(
state: &AppState, state: &AppState,
cmd: IngestWatchEventCommand, cmd: IngestWatchEventCommand,
parser: &dyn domain::ports::MediaServerParser, parser: &dyn domain::ports::MediaServerParser,
) -> StatusCode { ) -> axum::response::Response {
match ingest_watch_event::execute( match ingest_watch_event::execute(
&state.app_ctx.deps.integrations.ingest_watch_event, &state.app_ctx.deps.integrations.ingest_watch_event,
cmd, cmd,
@@ -132,8 +147,8 @@ async fn run_ingest(
) )
.await .await
{ {
Ok(()) => StatusCode::OK, Ok(()) => StatusCode::OK.into_response(),
Err(e) => crate::errors::domain_error_status(&e), Err(e) => crate::errors::ApiError(e).into_response(),
} }
} }
@@ -142,14 +157,14 @@ async fn run_ingest(
request_body = GenerateTokenRequest, request_body = GenerateTokenRequest,
responses( responses(
(status = 200, body = GenerateTokenResponse), (status = 200, body = GenerateTokenResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_generate_webhook_token( pub async fn post_generate_webhook_token(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<GenerateTokenRequest>, Body(req): Body<GenerateTokenRequest>,
) -> Result<Json<GenerateTokenResponse>, ApiError> { ) -> Result<Json<GenerateTokenResponse>, ApiError> {
let provider: WatchEventSource = req let provider: WatchEventSource = req
.provider .provider
@@ -182,7 +197,7 @@ pub async fn post_generate_webhook_token(
get, path = "/api/v1/settings/webhook-tokens", get, path = "/api/v1/settings/webhook-tokens",
responses( responses(
(status = 200, body = Vec<WebhookTokenDto>), (status = 200, body = Vec<WebhookTokenDto>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -216,15 +231,15 @@ pub async fn get_webhook_tokens(
params(("id" = Uuid, Path, description = "Webhook token ID")), params(("id" = Uuid, Path, description = "Webhook token ID")),
responses( responses(
(status = 204, description = "Token revoked"), (status = 204, description = "Token revoked"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Token not found"), (status = 404, description = "Token not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_webhook_token( pub async fn delete_webhook_token(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Path(id): Path<Uuid>, PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let cmd = RevokeWebhookTokenCommand { let cmd = RevokeWebhookTokenCommand {
user_id: user.0.value(), user_id: user.0.value(),
@@ -239,7 +254,7 @@ pub async fn delete_webhook_token(
get, path = "/api/v1/watch-queue", get, path = "/api/v1/watch-queue",
responses( responses(
(status = 200, body = Vec<WatchQueueEntryDto>), (status = 200, body = Vec<WatchQueueEntryDto>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -273,15 +288,15 @@ pub async fn get_watch_queue(
request_body = ConfirmWatchRequest, request_body = ConfirmWatchRequest,
responses( responses(
(status = 200, body = ConfirmWatchResponse), (status = 200, body = ConfirmWatchResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — not your watch event"), (status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_confirm_watch_events( pub async fn post_confirm_watch_events(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<ConfirmWatchRequest>, Body(req): Body<ConfirmWatchRequest>,
) -> Result<Json<ConfirmWatchResponse>, ApiError> { ) -> Result<Json<ConfirmWatchResponse>, ApiError> {
let cmd = ConfirmWatchEventsCommand { let cmd = ConfirmWatchEventsCommand {
user_id: user.0.value(), user_id: user.0.value(),
@@ -307,15 +322,15 @@ pub async fn post_confirm_watch_events(
request_body = DismissWatchRequest, request_body = DismissWatchRequest,
responses( responses(
(status = 200, body = DismissWatchResponse), (status = 200, body = DismissWatchResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — not your watch event"), (status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_dismiss_watch_events( pub async fn post_dismiss_watch_events(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<DismissWatchRequest>, Body(req): Body<DismissWatchRequest>,
) -> Result<Json<DismissWatchResponse>, ApiError> { ) -> Result<Json<DismissWatchResponse>, ApiError> {
let cmd = DismissWatchEventsCommand { let cmd = DismissWatchEventsCommand {
user_id: user.0.value(), user_id: user.0.value(),

View File

@@ -1,3 +1,4 @@
use crate::extractors::{Body, PathId};
use crate::{ use crate::{
errors::ApiError, errors::ApiError,
extractors::{AdminApiUser, AuthenticatedUser}, extractors::{AdminApiUser, AuthenticatedUser},
@@ -11,12 +12,7 @@ use application::wrapup::{
delete as delete_wrapup, generate, get_wrapup, delete as delete_wrapup, generate, get_wrapup,
list_wrapups::{self, ListWrapUpsQuery}, list_wrapups::{self, ListWrapUpsQuery},
}; };
use axum::{ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::NaiveDate; use chrono::NaiveDate;
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; use domain::models::wrapup::{WrapUpRecord, WrapUpStatus};
@@ -41,16 +37,16 @@ fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse {
request_body = GenerateWrapUpRequest, request_body = GenerateWrapUpRequest,
responses( responses(
(status = 200, body = WrapUpGeneratedResponse), (status = 200, body = WrapUpGeneratedResponse),
(status = 400, description = "Invalid date format"), (status = 400, description = "Invalid date format", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — admin only"), (status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn post_generate( pub async fn post_generate(
State(state): State<AppState>, State(state): State<AppState>,
_admin: AdminApiUser, _admin: AdminApiUser,
Json(req): Json<GenerateWrapUpRequest>, Body(req): Body<GenerateWrapUpRequest>,
) -> Result<Json<WrapUpGeneratedResponse>, ApiError> { ) -> Result<Json<WrapUpGeneratedResponse>, ApiError> {
let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d") let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d")
.map_err(|_| DomainError::ValidationError("invalid start_date".into()))?; .map_err(|_| DomainError::ValidationError("invalid start_date".into()))?;
@@ -72,7 +68,7 @@ pub async fn post_generate(
get, path = "/api/v1/wrapups", get, path = "/api/v1/wrapups",
responses( responses(
(status = 200, body = WrapUpListResponse), (status = 200, body = WrapUpListResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -97,15 +93,15 @@ pub async fn get_list(
params(("id" = Uuid, Path, description = "Wrap-up ID")), params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses( responses(
(status = 200, body = WrapUpStatusResponse), (status = 200, body = WrapUpStatusResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not found"), (status = 404, description = "Not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_status( pub async fn get_status(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthenticatedUser, _user: AuthenticatedUser,
Path(id): Path<Uuid>, PathId(id): PathId<Uuid>,
) -> Result<Json<WrapUpStatusResponse>, ApiError> { ) -> Result<Json<WrapUpStatusResponse>, ApiError> {
let record = get_wrapup::execute( let record = get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup, &state.app_ctx.deps.wrapup.get_wrapup,
@@ -122,15 +118,15 @@ pub async fn get_status(
responses( responses(
(status = 200, description = "Report JSON", content_type = "application/json"), (status = 200, description = "Report JSON", content_type = "application/json"),
(status = 202, description = "Still generating"), (status = 202, description = "Still generating"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not found"), (status = 404, description = "Not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_report( pub async fn get_report(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthenticatedUser, _user: AuthenticatedUser,
Path(id): Path<Uuid>, PathId(id): PathId<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match get_wrapup::execute( match get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup, &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(); let json = serde_json::to_string(report).unwrap_or_default();
(StatusCode::OK, [("content-type", "application/json")], json).into_response() (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(Some(_)) => StatusCode::ACCEPTED.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(), Ok(None) => {
Err(e) => crate::errors::domain_error_response(e), 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")), params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses( responses(
(status = 204, description = "Deleted"), (status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — admin only"), (status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
(status = 404, description = "Not found"), (status = 404, description = "Not found", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_wrapup_handler( pub async fn delete_wrapup_handler(
State(state): State<AppState>, State(state): State<AppState>,
_admin: AdminApiUser, _admin: AdminApiUser,
Path(id): Path<Uuid>, PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
delete_wrapup::execute( delete_wrapup::execute(
&state.app_ctx.deps.wrapup.delete_wrapup, &state.app_ctx.deps.wrapup.delete_wrapup,

View File

@@ -1,9 +1,30 @@
use api_types::ErrorResponse;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use domain::errors::DomainError; 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 { pub fn domain_error_status(e: &DomainError) -> StatusCode {
match e { match e {
DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => { DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => {
@@ -38,19 +59,16 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = domain_error_status(&self.0); let status = domain_error_status(&self.0);
match &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(_) => { DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {:?}", self.0); tracing::error!("Internal error: {:?}", self.0);
( refuse(status, INTERNAL, "internal server error")
status, }
axum::Json(serde_json::json!({"error": "internal server error"})),
)
.into_response()
}
_ => (
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::{ use axum::{
extract::{FromRef, FromRequestParts}, extract::{FromRef, FromRequestParts},
http::{StatusCode, header, header::AUTHORIZATION, request::Parts}, http::{StatusCode, header, header::AUTHORIZATION, request::Parts},

View File

@@ -1,4 +1,5 @@
use crate::extractors::AdminApiUser; use crate::extractors::AdminApiUser;
use crate::extractors::{Body, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::ActorUrlRequest; use api_types::ActorUrlRequest;
use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse}; use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse};
@@ -21,8 +22,8 @@ fn social_actor_to_blocked_dto(
get, path = "/api/v1/admin/blocked-domains", get, path = "/api/v1/admin/blocked-domains",
responses( responses(
(status = 200, body = Vec<BlockedDomainResponse>), (status = 200, body = Vec<BlockedDomainResponse>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden - admin only"), (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -48,15 +49,15 @@ pub async fn get_blocked_domains_admin(
request_body = AddBlockedDomainRequest, request_body = AddBlockedDomainRequest,
responses( responses(
(status = 201, description = "Domain blocked"), (status = 201, description = "Domain blocked"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden - admin only"), (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn add_blocked_domain_admin( pub async fn add_blocked_domain_admin(
State(state): State<AppState>, State(state): State<AppState>,
_admin: AdminApiUser, _admin: AdminApiUser,
axum::Json(body): axum::Json<AddBlockedDomainRequest>, Body(body): Body<AddBlockedDomainRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
state state
.app_ctx .app_ctx
@@ -71,15 +72,15 @@ pub async fn add_blocked_domain_admin(
params(("domain" = String, Path, description = "Domain to unblock")), params(("domain" = String, Path, description = "Domain to unblock")),
responses( responses(
(status = 204, description = "Domain unblocked"), (status = 204, description = "Domain unblocked"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden - admin only"), (status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn remove_blocked_domain_admin( pub async fn remove_blocked_domain_admin(
State(state): State<AppState>, State(state): State<AppState>,
_admin: AdminApiUser, _admin: AdminApiUser,
axum::extract::Path(domain): axum::extract::Path<String>, PathId(domain): PathId<String>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
state state
.app_ctx .app_ctx
@@ -94,14 +95,14 @@ pub async fn remove_blocked_domain_admin(
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
responses( responses(
(status = 204, description = "Actor blocked"), (status = 204, description = "Actor blocked"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn block_actor_api( pub async fn block_actor_api(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>, Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command( application::social::execute::execute_command(
@@ -120,14 +121,14 @@ pub async fn block_actor_api(
request_body = ActorUrlRequest, request_body = ActorUrlRequest,
responses( responses(
(status = 204, description = "Actor unblocked"), (status = 204, description = "Actor unblocked"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn unblock_actor_api( pub async fn unblock_actor_api(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>, Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone(); let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command( application::social::execute::execute_command(
@@ -145,7 +146,7 @@ pub async fn unblock_actor_api(
get, path = "/api/v1/social/blocked", get, path = "/api/v1/social/blocked",
responses( responses(
(status = 200, body = Vec<BlockedActorResponse>), (status = 200, body = Vec<BlockedActorResponse>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
), ),
security(("bearer_auth" = [])) 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(); 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() api.info = utoipa::openapi::InfoBuilder::new()
.title("Movies Diary API") .title("Movies Diary API")
.version("1.0.0") .version("1.0.0")
@@ -56,7 +63,7 @@ fn build() -> utoipa::openapi::OpenApi {
pub fn serve(router: Router) -> Router { pub fn serve(router: Router) -> Router {
tracing::info!("API docs at /docs (Swagger) and /scalar"); tracing::info!("API docs at /docs (Swagger) and /scalar");
let spec = build(); let spec = spec();
router router
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
.merge(Scalar::with_url("/scalar", spec)) .merge(Scalar::with_url("/scalar", spec))

View File

@@ -1,14 +1,21 @@
use std::num::NonZeroU32; use std::num::NonZeroU32;
use axum::{Router, routing}; use axum::{Router, http::StatusCode, routing};
use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; use axum_governor::{
GovernorConfigBuilder, GovernorLayer, Quota, RejectionReason, extractor::PeerIp,
};
use tower_http::{ use tower_http::{
cors::CorsLayer, cors::CorsLayer,
services::{ServeDir, ServeFile}, services::{ServeDir, ServeFile},
trace::TraceLayer, 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 { pub fn build_router(state: AppState, ap_router: Router) -> Router {
let rate_limit = state.app_ctx.config.rate_limit; 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" })) 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 { fn per_minute(n: u64) -> Quota {
let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap();
Quota::requests_per_minute(n) Quota::requests_per_minute(n)
@@ -111,6 +134,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router<AppState> {
.with_extractor(PeerIp::default()) .with_extractor(PeerIp::default())
.expect_connect_info() .expect_connect_info()
.quota_default(per_minute(rate_limit)) .quota_default(per_minute(rate_limit))
.error_handler(slow_down)
.finish() .finish()
.unwrap(); .unwrap();
@@ -120,6 +144,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router<AppState> {
.with_extractor(PeerIp::default()) .with_extractor(PeerIp::default())
.expect_connect_info() .expect_connect_info()
.quota_default(per_minute(rate_limit / 4)) .quota_default(per_minute(rate_limit / 4))
.error_handler(slow_down)
.finish() .finish()
.unwrap(); .unwrap();
let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg)); 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); 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:?}");
}

View File

@@ -21,3 +21,12 @@ pub type ActivityFeedResponse = PaginatedResponse<crate::diary::FeedEntryDto>;
pub type WatchlistResponse = PaginatedResponse<crate::watchlist::WatchlistEntryDto>; pub type WatchlistResponse = PaginatedResponse<crate::watchlist::WatchlistEntryDto>;
pub type PaginatedMovieHits = PaginatedResponse<crate::search::MovieSearchHitDto>; pub type PaginatedMovieHits = PaginatedResponse<crate::search::MovieSearchHitDto>;
pub type PaginatedPersonHits = PaginatedResponse<crate::search::PersonSearchHitDto>; pub type PaginatedPersonHits = PaginatedResponse<crate::search::PersonSearchHitDto>;
/// 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,
}

View File

@@ -414,3 +414,23 @@ async fn openapi_paths_match_the_recorded_list() {
let expected = std::fs::read_to_string(&path).unwrap(); let expected = std::fs::read_to_string(&path).unwrap();
assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}"); 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");
}

View File

@@ -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<T>`, `Params<T>` and `PathId<T>` 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.