refactor: single refusal shape with openapi contract test
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::Body;
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
use api_types::{
|
||||
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
|
||||
@@ -12,12 +13,12 @@ use axum::{Json, extract::State, http::StatusCode};
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, body = LoginResponse),
|
||||
(status = 401, description = "Invalid credentials"),
|
||||
(status = 401, description = "Invalid credentials", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
Body(req): Body<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let result = login_uc::execute(
|
||||
&state.app_ctx.deps.auth.login,
|
||||
@@ -42,12 +43,12 @@ pub async fn login(
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, description = "User registered"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
Body(req): Body<RegisterRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
register_uc::execute(
|
||||
&state.app_ctx.deps.auth.register,
|
||||
@@ -67,12 +68,12 @@ pub async fn register(
|
||||
request_body = RefreshRequest,
|
||||
responses(
|
||||
(status = 200, body = RefreshResponse),
|
||||
(status = 401, description = "Invalid or expired refresh token"),
|
||||
(status = 401, description = "Invalid or expired refresh token", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
Body(req): Body<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, ApiError> {
|
||||
let result =
|
||||
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token)
|
||||
@@ -93,7 +94,7 @@ pub async fn refresh(
|
||||
)]
|
||||
pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LogoutRequest>,
|
||||
Body(req): Body<LogoutRequest>,
|
||||
) -> StatusCode {
|
||||
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
|
||||
.await;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Body, Params, PathId};
|
||||
use crate::support::build_export_response;
|
||||
use crate::{
|
||||
errors::ApiError,
|
||||
@@ -14,12 +15,7 @@ use application::diary::{
|
||||
delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
|
||||
queries::GetActivityFeedQuery,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -27,13 +23,13 @@ use uuid::Uuid;
|
||||
params(DiaryQueryParams),
|
||||
responses(
|
||||
(status = 200, body = DiaryResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_diary(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DiaryQueryParams>,
|
||||
Params(params): Params<DiaryQueryParams>,
|
||||
) -> Result<Json<DiaryResponse>, ApiError> {
|
||||
let page =
|
||||
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?;
|
||||
@@ -55,15 +51,15 @@ pub async fn get_diary(
|
||||
request_body = LogReviewRequest,
|
||||
responses(
|
||||
(status = 201, description = "Review created"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_review(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<LogReviewRequest>,
|
||||
Body(req): Body<LogReviewRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let data = LogReviewData::try_from(req).map_err(ApiError)?;
|
||||
log_review::execute(
|
||||
@@ -79,16 +75,16 @@ pub async fn post_review(
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 204, description = "Review deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Review not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Review not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_review(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
PathId(review_id): PathId<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
@@ -104,18 +100,18 @@ pub async fn delete_review(
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 200, description = "Review updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Review not found"),
|
||||
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Review not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn patch_review(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Json(req): Json<EditReviewRequest>,
|
||||
PathId(review_id): PathId<Uuid>,
|
||||
Body(req): Body<EditReviewRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let watched_at = req
|
||||
.watched_at
|
||||
@@ -140,27 +136,37 @@ pub async fn patch_review(
|
||||
params(ExportQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Diary file download", content_type = "text/csv"),
|
||||
(status = 400, description = "Invalid format parameter"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 400, description = "Invalid format parameter", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn export_diary(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<ExportQueryParams>,
|
||||
Params(params): Params<ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
if !matches!(params.format.as_str(), "csv" | "json") {
|
||||
return crate::errors::refuse(
|
||||
StatusCode::BAD_REQUEST,
|
||||
crate::errors::INVALID_INPUT,
|
||||
"format must be csv or json",
|
||||
);
|
||||
}
|
||||
build_export_response(¶ms.format, user.0.value(), &state)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/activity-feed",
|
||||
params(ActivityFeedQueryParams),
|
||||
responses((status = 200, body = ActivityFeedResponse)),
|
||||
responses(
|
||||
(status = 200, body = ActivityFeedResponse),
|
||||
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
|
||||
),
|
||||
)]
|
||||
pub async fn get_activity_feed(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ActivityFeedQueryParams>,
|
||||
Params(params): Params<ActivityFeedQueryParams>,
|
||||
) -> Result<Json<ActivityFeedResponse>, ApiError> {
|
||||
let page = get_feed_uc::execute(
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
use crate::extractors::{Body, PathId};
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::mappers::goal_with_progress_to_dto;
|
||||
use api_types::{
|
||||
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
|
||||
UserSettingsDto,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/goals",
|
||||
responses(
|
||||
(status = 200, body = GoalsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -40,14 +37,14 @@ pub async fn list_goals(
|
||||
request_body = CreateGoalRequest,
|
||||
responses(
|
||||
(status = 200, body = GoalDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_goal(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<CreateGoalRequest>,
|
||||
Body(req): Body<CreateGoalRequest>,
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let g = application::goals::create::execute(
|
||||
&state.app_ctx.deps.goals.command,
|
||||
@@ -66,16 +63,16 @@ pub async fn create_goal(
|
||||
request_body = UpdateGoalRequest,
|
||||
responses(
|
||||
(status = 200, body = GoalDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Goal not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Goal not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_goal(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(year): Path<u16>,
|
||||
Json(req): Json<UpdateGoalRequest>,
|
||||
PathId(year): PathId<u16>,
|
||||
Body(req): Body<UpdateGoalRequest>,
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let g = application::goals::update::execute(
|
||||
&state.app_ctx.deps.goals.command,
|
||||
@@ -93,15 +90,15 @@ pub async fn update_goal(
|
||||
delete, path = "/api/v1/goals/{year}",
|
||||
responses(
|
||||
(status = 204, description = "Goal deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Goal not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Goal not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_goal(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(year): Path<u16>,
|
||||
PathId(year): PathId<u16>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
application::goals::delete::execute(
|
||||
&state.app_ctx.deps.goals.command,
|
||||
@@ -118,14 +115,14 @@ pub async fn delete_goal(
|
||||
get, path = "/api/v1/users/{id}/goals",
|
||||
responses(
|
||||
(status = 200, body = GoalsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_user_goals(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_viewer): AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
PathId(user_id): PathId<Uuid>,
|
||||
) -> Result<Json<GoalsResponse>, ApiError> {
|
||||
let goals = application::goals::list::execute(
|
||||
&state.app_ctx.deps.goals.query,
|
||||
@@ -141,7 +138,7 @@ pub async fn get_user_goals(
|
||||
get, path = "/api/v1/settings",
|
||||
responses(
|
||||
(status = 200, body = UserSettingsDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -166,14 +163,14 @@ pub async fn get_settings(
|
||||
request_body = UpdateUserSettingsRequest,
|
||||
responses(
|
||||
(status = 204, description = "Settings updated"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_settings(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<UpdateUserSettingsRequest>,
|
||||
Body(req): Body<UpdateUserSettingsRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
application::users::update_settings::execute(
|
||||
&state.app_ctx.deps.users.update_settings,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Body, PathId};
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::SaveProfileRequest;
|
||||
use application::import::{
|
||||
@@ -5,11 +6,7 @@ use application::import::{
|
||||
delete_profile as delete_import_profile, list_profiles as list_import_profiles,
|
||||
save_profile as save_import_profile,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse};
|
||||
use domain::errors::DomainError;
|
||||
use domain::value_objects::ImportSessionId;
|
||||
|
||||
@@ -17,7 +14,7 @@ use domain::value_objects::ImportSessionId;
|
||||
get, path = "/api/v1/import/profiles",
|
||||
responses(
|
||||
(status = 200, description = "List of saved import profiles", body = inline(Vec<serde_json::Value>)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -46,15 +43,15 @@ pub async fn api_get_profiles(
|
||||
request_body = SaveProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile saved", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Session has no mapping yet"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 422, description = "Session has no mapping yet", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<SaveProfileRequest>,
|
||||
Body(body): Body<SaveProfileRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = body
|
||||
.session_id
|
||||
@@ -80,15 +77,15 @@ pub async fn api_post_profile(
|
||||
params(("id" = String, Path, description = "Import profile UUID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Profile not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
PathId(profile_id_str): PathId<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Body, PathId};
|
||||
use crate::support::str_to_domain_field;
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
@@ -14,7 +15,7 @@ use application::import::{
|
||||
get_session_state,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
extract::{Multipart, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
@@ -29,9 +30,9 @@ use domain::value_objects::ImportSessionId;
|
||||
request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"),
|
||||
responses(
|
||||
(status = 200, body = SessionCreatedResponse),
|
||||
(status = 400, description = "No file provided"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Parse error"),
|
||||
(status = 400, description = "No file provided", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 422, description = "Parse error", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -87,15 +88,15 @@ pub async fn api_post_session(
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
responses(
|
||||
(status = 200, body = SessionStateResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Session not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_get_session(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
PathId(session_id_str): PathId<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
@@ -121,16 +122,16 @@ pub async fn api_get_session(
|
||||
request_body = ApplyMappingRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mapping applied", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 422, description = "Mapping error", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_put_mapping(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ApplyMappingRequest>,
|
||||
PathId(session_id_str): PathId<String>,
|
||||
Body(body): Body<ApplyMappingRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
@@ -173,7 +174,7 @@ pub async fn api_put_mapping(
|
||||
pub async fn api_get_preview(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
PathId(session_id_str): PathId<String>,
|
||||
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
@@ -238,16 +239,16 @@ pub async fn api_get_preview(
|
||||
request_body = ConfirmRequest,
|
||||
responses(
|
||||
(status = 200, description = "Import summary", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Session not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_confirm(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ConfirmRequest>,
|
||||
PathId(session_id_str): PathId<String>,
|
||||
Body(body): Body<ConfirmRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
@@ -277,17 +278,17 @@ pub async fn api_post_confirm(
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
|
||||
(status = 400, description = "Invalid ID"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session or profile not found"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
(status = 400, description = "Invalid ID", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Session or profile not found", body = api_types::ErrorResponse),
|
||||
(status = 422, description = "Mapping error", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_apply_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path((session_id_str, profile_id_str)): Path<(String, String)>,
|
||||
PathId((session_id_str, profile_id_str)): PathId<(String, String)>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::extractors::Params;
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
|
||||
use application::insights::get as get_insights_uc;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use domain::{errors::DomainError, models::Insight};
|
||||
|
||||
fn to_dto(insight: Insight) -> InsightDto {
|
||||
@@ -68,15 +66,15 @@ fn to_dto(insight: Insight) -> InsightDto {
|
||||
params(InsightsQueryParams),
|
||||
responses(
|
||||
(status = 200, body = InsightsResponse),
|
||||
(status = 400, description = "Unknown time zone"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 400, description = "Unknown time zone", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_insights(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<InsightsQueryParams>,
|
||||
Params(params): Params<InsightsQueryParams>,
|
||||
) -> Result<Json<InsightsResponse>, ApiError> {
|
||||
let tz: chrono_tz::Tz = match params.tz.as_deref() {
|
||||
None => chrono_tz::UTC,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Params, PathId};
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse,
|
||||
@@ -12,12 +13,7 @@ use application::{
|
||||
},
|
||||
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use domain::services::review_history::Trend;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -26,11 +22,12 @@ use uuid::Uuid;
|
||||
params(MoviesQueryParams),
|
||||
responses(
|
||||
(status = 200, body = MoviesResponse),
|
||||
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn list_movies(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<MoviesQueryParams>,
|
||||
Params(params): Params<MoviesQueryParams>,
|
||||
) -> Result<Json<MoviesResponse>, ApiError> {
|
||||
let page = get_movies::execute(
|
||||
&state.app_ctx.deps.movies.get_movies,
|
||||
@@ -61,12 +58,12 @@ pub async fn list_movies(
|
||||
params(("id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = ReviewHistoryResponse),
|
||||
(status = 404, description = "Movie not found"),
|
||||
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_review_history(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
PathId(movie_id): PathId<Uuid>,
|
||||
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
|
||||
let (history, trend) = get_review_history::execute(
|
||||
&state.app_ctx.deps.diary.get_review_history,
|
||||
@@ -95,15 +92,15 @@ pub async fn get_review_history(
|
||||
params(("id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 204, description = "Poster synced"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Movie not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn sync_poster(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
PathId(movie_id): PathId<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
sync_poster::execute(
|
||||
&state.app_ctx.deps.movies.sync_poster,
|
||||
@@ -118,13 +115,13 @@ pub async fn sync_poster(
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = MovieDetailResponse),
|
||||
(status = 404, description = "Movie not found"),
|
||||
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_movie_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
PathId(movie_id): PathId<Uuid>,
|
||||
Params(params): Params<PaginationQueryParams>,
|
||||
) -> Result<Json<MovieDetailResponse>, ApiError> {
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
@@ -175,12 +172,12 @@ pub async fn get_movie_detail(
|
||||
params(("id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = MovieProfileResponse),
|
||||
(status = 404, description = "No profile found for this movie"),
|
||||
(status = 404, description = "No profile found for this movie", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_movie_profile(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
PathId(movie_id): PathId<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
use application::movies::get_movie_profile;
|
||||
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
|
||||
@@ -243,7 +240,9 @@ pub async fn get_movie_profile(
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Ok(None) => {
|
||||
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
|
||||
}
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Params, PathId};
|
||||
use crate::state::AppState;
|
||||
use api_types::search::{
|
||||
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
|
||||
@@ -7,11 +8,7 @@ use application::{
|
||||
person::{get as get_person, get_credits as get_person_credits},
|
||||
search::execute as search_uc,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse};
|
||||
use domain::models::{PersonId, collections::PageParams};
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -19,12 +16,13 @@ use domain::models::{PersonId, collections::PageParams};
|
||||
params(api_types::search::SearchQueryParams),
|
||||
responses(
|
||||
(status = 200, body = api_types::search::SearchResponse),
|
||||
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
|
||||
),
|
||||
tag = "search",
|
||||
)]
|
||||
pub async fn get_search(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchQueryParams>,
|
||||
Params(params): Params<SearchQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let query = domain::models::SearchQuery {
|
||||
text: params.q,
|
||||
@@ -80,7 +78,7 @@ pub async fn get_search(
|
||||
},
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,13 +87,13 @@ pub async fn get_search(
|
||||
params(("id" = Uuid, Path, description = "Person ID")),
|
||||
responses(
|
||||
(status = 200, body = api_types::search::PersonDto),
|
||||
(status = 404, description = "Person not found"),
|
||||
(status = 404, description = "Person not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
tag = "search",
|
||||
)]
|
||||
pub async fn get_person_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
PathId(id): PathId<uuid::Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match get_person::execute(
|
||||
&state.app_ctx.deps.person.get_person,
|
||||
@@ -104,8 +102,10 @@ pub async fn get_person_handler(
|
||||
.await
|
||||
{
|
||||
Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Ok(None) => {
|
||||
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
|
||||
}
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,13 +114,13 @@ pub async fn get_person_handler(
|
||||
params(("id" = Uuid, Path, description = "Person ID")),
|
||||
responses(
|
||||
(status = 200, body = api_types::search::PersonCreditsDto),
|
||||
(status = 404, description = "Person not found"),
|
||||
(status = 404, description = "Person not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
tag = "search",
|
||||
)]
|
||||
pub async fn get_person_credits_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
PathId(id): PathId<uuid::Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match get_person_credits::execute(
|
||||
&state.app_ctx.deps.person.get_person,
|
||||
@@ -155,7 +155,7 @@ pub async fn get_person_credits_handler(
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,10 +165,14 @@ pub async fn post_reindex_search(
|
||||
) -> impl IntoResponse {
|
||||
let event = domain::events::DomainEvent::SearchReindexRequested;
|
||||
match state.app_ctx.services.event_publisher.publish(&event).await {
|
||||
Ok(()) => StatusCode::ACCEPTED,
|
||||
Ok(()) => StatusCode::ACCEPTED.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("failed to publish reindex event: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
crate::errors::refuse(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
crate::errors::INTERNAL,
|
||||
"internal server error",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
use crate::extractors::{Body, Params, PathId};
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest,
|
||||
PendingCountResponse, RemoteActorDto,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -29,7 +25,7 @@ fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> Remot
|
||||
get, path = "/api/v1/social/following",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -55,7 +51,7 @@ pub async fn get_following(
|
||||
get, path = "/api/v1/social/followers",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -80,7 +76,7 @@ pub async fn get_followers(
|
||||
pub async fn get_user_following(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
PathId(user_id): PathId<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
@@ -97,7 +93,7 @@ pub async fn get_user_following(
|
||||
pub async fn get_user_followers(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
PathId(user_id): PathId<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
@@ -116,14 +112,14 @@ pub async fn get_user_followers(
|
||||
request_body = FollowRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follow request sent"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn follow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<FollowRequest>,
|
||||
Body(body): Body<FollowRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
@@ -141,14 +137,14 @@ pub async fn follow(
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Unfollowed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn unfollow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
Body(body): Body<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
@@ -167,14 +163,14 @@ pub async fn unfollow(
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower accepted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn accept_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
Body(body): Body<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
@@ -193,14 +189,14 @@ pub async fn accept_follower(
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower rejected"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn reject_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
Body(body): Body<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
@@ -219,14 +215,14 @@ pub async fn reject_follower(
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower removed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn remove_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
Body(body): Body<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
@@ -244,7 +240,7 @@ pub async fn remove_follower(
|
||||
get, path = "/api/v1/social/followers/pending",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -270,7 +266,7 @@ pub async fn get_pending_followers(
|
||||
get, path = "/api/v1/social/followers/pending/count",
|
||||
responses(
|
||||
(status = 200, body = PendingCountResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -290,7 +286,7 @@ pub async fn get_pending_follower_count(
|
||||
get, path = "/api/v1/social/following/pending",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -322,14 +318,14 @@ pub struct RelationshipQuery {
|
||||
params(("actor_url" = String, Query, description = "Canonical actor URL of the target")),
|
||||
responses(
|
||||
(status = 200, body = FollowRelationResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_relationship(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(q): Query<RelationshipQuery>,
|
||||
Params(q): Params<RelationshipQuery>,
|
||||
) -> Result<Json<FollowRelationResponse>, ApiError> {
|
||||
let target = state.app_ctx.instance.identify(&q.actor_url);
|
||||
let rel = application::social::get_relation::execute(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Params, PathId};
|
||||
use crate::{extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::mappers::goal_with_progress_to_dto;
|
||||
use api_types::{
|
||||
@@ -8,12 +9,7 @@ use application::users::{
|
||||
get_federated_profile, get_federated_profile_stats, get_local_profile,
|
||||
queries::GetUserProfileQuery,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use domain::errors::DomainError;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
@@ -26,19 +22,25 @@ use uuid::Uuid;
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = UserProfileResponse),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 404, description = "User not found", body = api_types::ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_user_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(viewer_id): AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Query(params): Query<UserProfileQueryParams>,
|
||||
PathId(user_id): PathId<Uuid>,
|
||||
Params(params): Params<UserProfileQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(_) => {
|
||||
return crate::errors::refuse(
|
||||
StatusCode::BAD_REQUEST,
|
||||
crate::errors::INVALID_INPUT,
|
||||
"invalid profile view",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let profile = match get_local_profile::execute(
|
||||
@@ -73,9 +75,13 @@ pub async fn get_user_profile(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
return crate::errors::refuse(
|
||||
StatusCode::NOT_FOUND,
|
||||
crate::errors::NOT_FOUND,
|
||||
"not found",
|
||||
);
|
||||
}
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
Err(e) => return crate::errors::ApiError(e).into_response(),
|
||||
};
|
||||
|
||||
let view_data = if let Some(p) = profile.entries {
|
||||
@@ -174,7 +180,7 @@ async fn build_federated_profile_response(
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
Err(e) => return crate::errors::ApiError(e).into_response(),
|
||||
};
|
||||
|
||||
let view_data = if let Some(p) = profile.entries {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::Body;
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{ProfileResponse, UserSummaryDto, UsersResponse};
|
||||
use application::users::{
|
||||
@@ -14,8 +15,8 @@ use axum::{
|
||||
get, path = "/api/v1/profile",
|
||||
responses(
|
||||
(status = 200, body = ProfileResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "User not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -56,9 +57,9 @@ pub async fn get_profile(
|
||||
put, path = "/api/v1/profile",
|
||||
responses(
|
||||
(status = 204, description = "Profile updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -82,7 +83,7 @@ pub async fn update_profile_handler(
|
||||
|
||||
match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,20 +92,26 @@ pub async fn update_profile_handler(
|
||||
request_body = api_types::UpdateProfileFieldsRequest,
|
||||
responses(
|
||||
(status = 204, description = "Profile fields updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_profile_fields_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<serde_json::Value>,
|
||||
Body(body): Body<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
None => {
|
||||
return crate::errors::refuse(
|
||||
StatusCode::BAD_REQUEST,
|
||||
crate::errors::INVALID_INPUT,
|
||||
"body must contain a fields array",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let fields: Vec<domain::models::ProfileField> = raw_fields
|
||||
@@ -124,13 +131,16 @@ pub async fn update_profile_fields_handler(
|
||||
match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/users",
|
||||
responses((status = 200, body = UsersResponse)),
|
||||
responses(
|
||||
(status = 200, body = UsersResponse),
|
||||
(status = 429, description = "Rate limited", body = api_types::ErrorResponse),
|
||||
),
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
||||
let result =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Body, Params, PathId};
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse,
|
||||
@@ -13,12 +14,7 @@ use application::{
|
||||
remove as remove_from_watchlist,
|
||||
},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -29,14 +25,14 @@ use uuid::Uuid;
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = WatchlistResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_handler(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
Params(params): Params<PaginationQueryParams>,
|
||||
) -> Result<Json<WatchlistResponse>, ApiError> {
|
||||
let page = get_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.get_watchlist,
|
||||
@@ -69,15 +65,15 @@ pub async fn get_watchlist_handler(
|
||||
request_body = AddToWatchlistRequest,
|
||||
responses(
|
||||
(status = 201, description = "Added to watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Movie not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_watchlist_add(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<AddToWatchlistRequest>,
|
||||
Body(req): Body<AddToWatchlistRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
add_to_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.add,
|
||||
@@ -101,15 +97,15 @@ pub async fn post_watchlist_add(
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 204, description = "Removed from watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not on watchlist"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Not on watchlist", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_watchlist_entry(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
PathId(movie_id): PathId<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
remove_from_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.remove_from_watchlist,
|
||||
@@ -127,14 +123,14 @@ pub async fn delete_watchlist_entry(
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = WatchlistStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_status(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
PathId(movie_id): PathId<Uuid>,
|
||||
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
|
||||
let on_watchlist = is_on_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Body, Params, PathId};
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
|
||||
@@ -16,7 +17,7 @@ use application::integrations::{
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Multipart, Path, State},
|
||||
extract::{Multipart, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
@@ -36,6 +37,14 @@ pub struct WebhookTokenQuery {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
fn missing_token() -> axum::response::Response {
|
||||
crate::errors::refuse(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
crate::errors::UNAUTHORIZED,
|
||||
"missing webhook token",
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option<String> {
|
||||
extract_bearer_token(headers).or_else(|| query.token.clone())
|
||||
}
|
||||
@@ -45,20 +54,20 @@ fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Opti
|
||||
request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"),
|
||||
responses(
|
||||
(status = 200, description = "Event accepted or ignored"),
|
||||
(status = 400, description = "Invalid payload"),
|
||||
(status = 401, description = "Invalid or missing webhook token"),
|
||||
(status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_jellyfin_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
|
||||
Params(query): Params<WebhookTokenQuery>,
|
||||
body: axum::body::Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let token = match extract_webhook_token(&headers, &query) {
|
||||
Some(t) => t,
|
||||
None => return StatusCode::UNAUTHORIZED,
|
||||
None => return missing_token(),
|
||||
};
|
||||
|
||||
let cmd = IngestWatchEventCommand {
|
||||
@@ -80,20 +89,20 @@ pub async fn post_jellyfin_webhook(
|
||||
request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "Event accepted or ignored"),
|
||||
(status = 400, description = "Invalid payload"),
|
||||
(status = 401, description = "Invalid or missing webhook token"),
|
||||
(status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_plex_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
|
||||
Params(query): Params<WebhookTokenQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let token = match extract_webhook_token(&headers, &query) {
|
||||
Some(t) => t,
|
||||
None => return StatusCode::UNAUTHORIZED,
|
||||
None => return missing_token(),
|
||||
};
|
||||
|
||||
let mut payload_bytes: Option<Vec<u8>> = None;
|
||||
@@ -108,7 +117,13 @@ pub async fn post_plex_webhook(
|
||||
|
||||
let raw_payload = match payload_bytes {
|
||||
Some(b) => b,
|
||||
None => return StatusCode::BAD_REQUEST,
|
||||
None => {
|
||||
return crate::errors::refuse(
|
||||
StatusCode::BAD_REQUEST,
|
||||
crate::errors::INVALID_INPUT,
|
||||
"multipart form has no payload field",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let cmd = IngestWatchEventCommand {
|
||||
@@ -124,7 +139,7 @@ async fn run_ingest(
|
||||
state: &AppState,
|
||||
cmd: IngestWatchEventCommand,
|
||||
parser: &dyn domain::ports::MediaServerParser,
|
||||
) -> StatusCode {
|
||||
) -> axum::response::Response {
|
||||
match ingest_watch_event::execute(
|
||||
&state.app_ctx.deps.integrations.ingest_watch_event,
|
||||
cmd,
|
||||
@@ -132,8 +147,8 @@ async fn run_ingest(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK,
|
||||
Err(e) => crate::errors::domain_error_status(&e),
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,14 +157,14 @@ async fn run_ingest(
|
||||
request_body = GenerateTokenRequest,
|
||||
responses(
|
||||
(status = 200, body = GenerateTokenResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_generate_webhook_token(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<GenerateTokenRequest>,
|
||||
Body(req): Body<GenerateTokenRequest>,
|
||||
) -> Result<Json<GenerateTokenResponse>, ApiError> {
|
||||
let provider: WatchEventSource = req
|
||||
.provider
|
||||
@@ -182,7 +197,7 @@ pub async fn post_generate_webhook_token(
|
||||
get, path = "/api/v1/settings/webhook-tokens",
|
||||
responses(
|
||||
(status = 200, body = Vec<WebhookTokenDto>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -216,15 +231,15 @@ pub async fn get_webhook_tokens(
|
||||
params(("id" = Uuid, Path, description = "Webhook token ID")),
|
||||
responses(
|
||||
(status = 204, description = "Token revoked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Token not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Token not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_webhook_token(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(id): Path<Uuid>,
|
||||
PathId(id): PathId<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = RevokeWebhookTokenCommand {
|
||||
user_id: user.0.value(),
|
||||
@@ -239,7 +254,7 @@ pub async fn delete_webhook_token(
|
||||
get, path = "/api/v1/watch-queue",
|
||||
responses(
|
||||
(status = 200, body = Vec<WatchQueueEntryDto>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -273,15 +288,15 @@ pub async fn get_watch_queue(
|
||||
request_body = ConfirmWatchRequest,
|
||||
responses(
|
||||
(status = 200, body = ConfirmWatchResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — not your watch event"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_confirm_watch_events(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<ConfirmWatchRequest>,
|
||||
Body(req): Body<ConfirmWatchRequest>,
|
||||
) -> Result<Json<ConfirmWatchResponse>, ApiError> {
|
||||
let cmd = ConfirmWatchEventsCommand {
|
||||
user_id: user.0.value(),
|
||||
@@ -307,15 +322,15 @@ pub async fn post_confirm_watch_events(
|
||||
request_body = DismissWatchRequest,
|
||||
responses(
|
||||
(status = 200, body = DismissWatchResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — not your watch event"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_dismiss_watch_events(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<DismissWatchRequest>,
|
||||
Body(req): Body<DismissWatchRequest>,
|
||||
) -> Result<Json<DismissWatchResponse>, ApiError> {
|
||||
let cmd = DismissWatchEventsCommand {
|
||||
user_id: user.0.value(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::extractors::{Body, PathId};
|
||||
use crate::{
|
||||
errors::ApiError,
|
||||
extractors::{AdminApiUser, AuthenticatedUser},
|
||||
@@ -11,12 +12,7 @@ use application::wrapup::{
|
||||
delete as delete_wrapup, generate, get_wrapup,
|
||||
list_wrapups::{self, ListWrapUpsQuery},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use chrono::NaiveDate;
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::wrapup::{WrapUpRecord, WrapUpStatus};
|
||||
@@ -41,16 +37,16 @@ fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse {
|
||||
request_body = GenerateWrapUpRequest,
|
||||
responses(
|
||||
(status = 200, body = WrapUpGeneratedResponse),
|
||||
(status = 400, description = "Invalid date format"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — admin only"),
|
||||
(status = 400, description = "Invalid date format", body = api_types::ErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_generate(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
Json(req): Json<GenerateWrapUpRequest>,
|
||||
Body(req): Body<GenerateWrapUpRequest>,
|
||||
) -> Result<Json<WrapUpGeneratedResponse>, ApiError> {
|
||||
let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d")
|
||||
.map_err(|_| DomainError::ValidationError("invalid start_date".into()))?;
|
||||
@@ -72,7 +68,7 @@ pub async fn post_generate(
|
||||
get, path = "/api/v1/wrapups",
|
||||
responses(
|
||||
(status = 200, body = WrapUpListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -97,15 +93,15 @@ pub async fn get_list(
|
||||
params(("id" = Uuid, Path, description = "Wrap-up ID")),
|
||||
responses(
|
||||
(status = 200, body = WrapUpStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_status(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(id): Path<Uuid>,
|
||||
PathId(id): PathId<Uuid>,
|
||||
) -> Result<Json<WrapUpStatusResponse>, ApiError> {
|
||||
let record = get_wrapup::execute(
|
||||
&state.app_ctx.deps.wrapup.get_wrapup,
|
||||
@@ -122,15 +118,15 @@ pub async fn get_status(
|
||||
responses(
|
||||
(status = 200, description = "Report JSON", content_type = "application/json"),
|
||||
(status = 202, description = "Still generating"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_report(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(id): Path<Uuid>,
|
||||
PathId(id): PathId<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match get_wrapup::execute(
|
||||
&state.app_ctx.deps.wrapup.get_wrapup,
|
||||
@@ -143,11 +139,15 @@ pub async fn get_report(
|
||||
let json = serde_json::to_string(report).unwrap_or_default();
|
||||
(StatusCode::OK, [("content-type", "application/json")], json).into_response()
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
None => {
|
||||
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
|
||||
}
|
||||
},
|
||||
Ok(Some(_)) => StatusCode::ACCEPTED.into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Ok(None) => {
|
||||
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
|
||||
}
|
||||
Err(e) => crate::errors::ApiError(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,16 +156,16 @@ pub async fn get_report(
|
||||
params(("id" = Uuid, Path, description = "Wrap-up ID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — admin only"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
|
||||
(status = 404, description = "Not found", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_wrapup_handler(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
Path(id): Path<Uuid>,
|
||||
PathId(id): PathId<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
delete_wrapup::execute(
|
||||
&state.app_ctx.deps.wrapup.delete_wrapup,
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
use api_types::ErrorResponse;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub const NOT_FOUND: &str = "NOT_FOUND";
|
||||
pub const INVALID_INPUT: &str = "INVALID_INPUT";
|
||||
pub const CONFLICT: &str = "CONFLICT";
|
||||
pub const UNAUTHORIZED: &str = "UNAUTHORIZED";
|
||||
pub const FORBIDDEN: &str = "FORBIDDEN";
|
||||
pub const RATE_LIMITED: &str = "RATE_LIMITED";
|
||||
pub const INTERNAL: &str = "INTERNAL";
|
||||
|
||||
/// The only constructor of JSON error bodies in this crate.
|
||||
pub fn refuse(status: StatusCode, code: &'static str, message: impl Into<String>) -> Response {
|
||||
(
|
||||
status,
|
||||
axum::Json(ErrorResponse {
|
||||
error: message.into(),
|
||||
code: code.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn domain_error_status(e: &DomainError) -> StatusCode {
|
||||
match e {
|
||||
DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => {
|
||||
@@ -38,19 +59,16 @@ impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = domain_error_status(&self.0);
|
||||
match &self.0 {
|
||||
DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => {
|
||||
refuse(status, INVALID_INPUT, self.0.to_string())
|
||||
}
|
||||
DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()),
|
||||
DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()),
|
||||
DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()),
|
||||
DomainError::InfrastructureError(_) => {
|
||||
tracing::error!("Internal error: {:?}", self.0);
|
||||
(
|
||||
status,
|
||||
axum::Json(serde_json::json!({"error": "internal server error"})),
|
||||
)
|
||||
.into_response()
|
||||
refuse(status, INTERNAL, "internal server error")
|
||||
}
|
||||
_ => (
|
||||
status,
|
||||
axum::Json(serde_json::json!({"error": self.0.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
crates/adapters/http-axum/src/extractors/json.rs
Normal file
101
crates/adapters/http-axum/src/extractors/json.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
mod json;
|
||||
pub use json::{Body, MalformedRequest, Params, PathId};
|
||||
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{StatusCode, header, header::AUTHORIZATION, request::Parts},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::extractors::AdminApiUser;
|
||||
use crate::extractors::{Body, PathId};
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::ActorUrlRequest;
|
||||
use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse};
|
||||
@@ -21,8 +22,8 @@ fn social_actor_to_blocked_dto(
|
||||
get, path = "/api/v1/admin/blocked-domains",
|
||||
responses(
|
||||
(status = 200, body = Vec<BlockedDomainResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
@@ -48,15 +49,15 @@ pub async fn get_blocked_domains_admin(
|
||||
request_body = AddBlockedDomainRequest,
|
||||
responses(
|
||||
(status = 201, description = "Domain blocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn add_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
|
||||
Body(body): Body<AddBlockedDomainRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
@@ -71,15 +72,15 @@ pub async fn add_blocked_domain_admin(
|
||||
params(("domain" = String, Path, description = "Domain to unblock")),
|
||||
responses(
|
||||
(status = 204, description = "Domain unblocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
(status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn remove_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
axum::extract::Path(domain): axum::extract::Path<String>,
|
||||
PathId(domain): PathId<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
@@ -94,14 +95,14 @@ pub async fn remove_blocked_domain_admin(
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 204, description = "Actor blocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn block_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
Body(body): Body<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
@@ -120,14 +121,14 @@ pub async fn block_actor_api(
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 204, description = "Actor unblocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn unblock_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
Body(body): Body<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
@@ -145,7 +146,7 @@ pub async fn unblock_actor_api(
|
||||
get, path = "/api/v1/social/blocked",
|
||||
responses(
|
||||
(status = 200, body = Vec<BlockedActorResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
|
||||
@@ -31,8 +31,15 @@ impl Modify for SecurityAddon {
|
||||
}
|
||||
}
|
||||
|
||||
fn build() -> utoipa::openapi::OpenApi {
|
||||
pub fn spec() -> utoipa::openapi::OpenApi {
|
||||
let mut api = auth::AuthDoc::openapi();
|
||||
api.components
|
||||
.get_or_insert_with(Default::default)
|
||||
.schemas
|
||||
.insert(
|
||||
"ErrorResponse".into(),
|
||||
<api_types::ErrorResponse as utoipa::PartialSchema>::schema(),
|
||||
);
|
||||
api.info = utoipa::openapi::InfoBuilder::new()
|
||||
.title("Movies Diary API")
|
||||
.version("1.0.0")
|
||||
@@ -56,7 +63,7 @@ fn build() -> utoipa::openapi::OpenApi {
|
||||
|
||||
pub fn serve(router: Router) -> Router {
|
||||
tracing::info!("API docs at /docs (Swagger) and /scalar");
|
||||
let spec = build();
|
||||
let spec = spec();
|
||||
router
|
||||
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
|
||||
.merge(Scalar::with_url("/scalar", spec))
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use axum::{Router, routing};
|
||||
use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp};
|
||||
use axum::{Router, http::StatusCode, routing};
|
||||
use axum_governor::{
|
||||
GovernorConfigBuilder, GovernorLayer, Quota, RejectionReason, extractor::PeerIp,
|
||||
};
|
||||
use tower_http::{
|
||||
cors::CorsLayer,
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
use crate::{api, federation, html, state::AppState};
|
||||
use crate::{
|
||||
api,
|
||||
errors::{INTERNAL, RATE_LIMITED, refuse},
|
||||
federation, html,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn build_router(state: AppState, ap_router: Router) -> Router {
|
||||
let rate_limit = state.app_ctx.config.rate_limit;
|
||||
@@ -41,6 +48,22 @@ async fn health_handler() -> axum::Json<serde_json::Value> {
|
||||
axum::Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
/// JSON API rate-limit refusals use the one error shape.
|
||||
fn slow_down(reason: RejectionReason) -> axum::response::Response {
|
||||
match reason {
|
||||
RejectionReason::QuotaExceeded { .. } => refuse(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
RATE_LIMITED,
|
||||
"too many requests: slow down and try again shortly",
|
||||
),
|
||||
RejectionReason::KeyExtractionFailed(_) => refuse(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
INTERNAL,
|
||||
"the caller could not be identified for rate limiting",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn per_minute(n: u64) -> Quota {
|
||||
let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap();
|
||||
Quota::requests_per_minute(n)
|
||||
@@ -111,6 +134,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router<AppState> {
|
||||
.with_extractor(PeerIp::default())
|
||||
.expect_connect_info()
|
||||
.quota_default(per_minute(rate_limit))
|
||||
.error_handler(slow_down)
|
||||
.finish()
|
||||
.unwrap();
|
||||
|
||||
@@ -120,6 +144,7 @@ fn api_routes(rate_limit: u64, cors_origins: &str) -> Router<AppState> {
|
||||
.with_extractor(PeerIp::default())
|
||||
.expect_connect_info()
|
||||
.quota_default(per_minute(rate_limit / 4))
|
||||
.error_handler(slow_down)
|
||||
.finish()
|
||||
.unwrap();
|
||||
let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg));
|
||||
|
||||
@@ -393,3 +393,139 @@ async fn following_collection_returns_500_on_federation_error() {
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// --- One refusal shape ---
|
||||
|
||||
async fn refusal_body(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).expect("refusal body is JSON")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_resource_is_refused_with_not_found_code() {
|
||||
let state =
|
||||
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}",
|
||||
get(crate::api::search::get_person_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/people/{}", Uuid::new_v4()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let body = refusal_body(resp).await;
|
||||
assert_eq!(body["code"], "NOT_FOUND");
|
||||
assert!(body["error"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_credential_is_refused_with_unauthorized_code() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/watchlist",
|
||||
get(crate::api::watchlist::get_watchlist_handler),
|
||||
)
|
||||
.with_state(test_app_state());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/watchlist")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(refusal_body(resp).await["code"], "UNAUTHORIZED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() {
|
||||
let state =
|
||||
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}",
|
||||
get(crate::api::search::get_person_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/people/not-a-uuid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_malformed_json_body_is_refused_with_invalid_input_code() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/auth/login",
|
||||
axum::routing::post(crate::api::auth::login),
|
||||
)
|
||||
.with_state(test_app_state());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{not json"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let body = refusal_body(resp).await;
|
||||
assert_eq!(body["code"], "INVALID_INPUT");
|
||||
assert!(body["error"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/auth/login",
|
||||
axum::routing::post(crate::api::auth::login),
|
||||
)
|
||||
.with_state(test_app_state());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
|
||||
}
|
||||
|
||||
93
crates/adapters/http-axum/tests/openapi_contract_test.rs
Normal file
93
crates/adapters/http-axum/tests/openapi_contract_test.rs
Normal 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:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user