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, 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(),