minna-caos/src/http_api/api_error.rs
2025-04-18 21:29:03 +02:00

188 lines
8 KiB
Rust

use crate::http_api::upload::headers::UploadOffsetResponseHeader;
use crate::upload_manager::UploadFailureReason;
use axum::http::{HeaderName, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use color_eyre::Report;
use serde::Serialize;
use serde_json::json;
use std::borrow::Cow;
use tokio_util::bytes::{BufMut, BytesMut};
pub struct ProblemJson<T>(pub T);
impl<T: Serialize> IntoResponse for ProblemJson<T> {
fn into_response(self) -> Response {
// Same as IntoResponse::into_response for Json, but the content type header is changed.
let mut buf = BytesMut::with_capacity(128).writer();
match serde_json::to_writer(&mut buf, &self.0) {
Ok(()) => (
[(header::CONTENT_TYPE, HeaderValue::from_static("application/problem+json"))],
buf.into_inner().freeze(),
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
err.to_string(),
)
.into_response(),
}
}
}
#[derive(Debug)]
pub enum ApiError {
Internal { report: Report },
Forbidden,
InvalidRequestHeader { name: HeaderName, message: Cow<'static, str> },
InvalidRequestContent { path: Cow<'static, str>, message: Cow<'static, str> },
UnknownResource { resource_type: Cow<'static, str>, id: Cow<'static, str> },
RequestBodyTooLong,
RequestBodyTooShort,
IanaUploadAlreadyComplete,
CaosUploadRequestSuperseded,
CaosUploadFailed { reason: UploadFailureReason },
CaosUploadOffsetMismatch { expected: u64, provided: u64 },
CaosInconsistentUploadLength { expected: u64, detail: Cow<'static, str> },
CaosUploadNotFinished,
CaosUnknownBucket { bucket_id: Cow<'static, str> },
}
impl From<Report> for ApiError {
fn from(report: Report) -> Self {
ApiError::Internal { report }
}
}
impl From<std::io::Error> for ApiError {
fn from(error: std::io::Error) -> Self {
ApiError::Internal { report: Report::new(error) }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::Internal { report } => {
log::error!("Internal error in request handler: {:#}", report);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
ApiError::Forbidden => StatusCode::FORBIDDEN.into_response(),
ApiError::InvalidRequestHeader { name, message } => (
StatusCode::UNPROCESSABLE_ENTITY,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/invalid-request-header",
"title": "A specific request header value is invalid.",
"detail": format!("The value of `{}` is invalid: {}", name.as_str(), message)
})),
)
.into_response(),
ApiError::InvalidRequestContent { path, message } => (
StatusCode::UNPROCESSABLE_ENTITY,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/invalid-request-content",
"title": "The request content is semantically invalid.",
"detail": format!("`{path}`: {message}"),
"path": path
})),
)
.into_response(),
ApiError::UnknownResource { resource_type, id } => (
StatusCode::NOT_FOUND,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/unknown-resource",
"title": "The requested resource is unknown.",
"detail": format!("There is no {resource_type} resource with this ID: {id}"),
"resource_type": resource_type,
"resource_id": id
})),
)
.into_response(),
ApiError::RequestBodyTooLong => (
StatusCode::BAD_REQUEST,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/request-body-too-long",
"title": "The received request body is longer than expected.",
})),
)
.into_response(),
ApiError::RequestBodyTooShort => (
StatusCode::BAD_REQUEST,
ProblemJson(json!({
"type": "https://minna.media/api-problems/general/request-body-too-short",
"title": "The received request body is shorter than expected.",
})),
)
.into_response(),
ApiError::IanaUploadAlreadyComplete => (
StatusCode::CONFLICT,
// According to https://www.ietf.org/archive/id/draft-ietf-httpbis-resumable-upload-08.html#section-4.4.2-7,
// if the request did not (itself) complete the upload, the response must contain the `Upload-Complete: ?0` header.
// When the request is *already* complete, the request cannot ever possibly complete the upload.
ProblemJson(json!({
"type": "https://iana.org/assignments/http-problem-types#completed-upload",
"title": "The upload is already complete.",
})),
)
.into_response(),
ApiError::CaosUploadRequestSuperseded => (
StatusCode::CONFLICT,
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/request-superseded",
"title": "Another request superseded the current request.",
})),
)
.into_response(),
ApiError::CaosUploadFailed { reason } => (
StatusCode::GONE,
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/request-superseded",
"title": "The upload was cancelled or failed.",
"reason": reason.to_string()
})),
)
.into_response(),
ApiError::CaosUploadOffsetMismatch { expected, provided } => (
StatusCode::CONFLICT,
UploadOffsetResponseHeader(expected),
ProblemJson(json!({
"type": "https://iana.org/assignments/http-problem-types#mismatching-upload-offset",
"title": "The upload offset provided in the request does not match the actual offset of the resource.",
"expected-offset": expected,
"provided-offset": provided,
})),
)
.into_response(),
ApiError::CaosInconsistentUploadLength { expected, detail } => (
StatusCode::CONFLICT,
UploadOffsetResponseHeader(expected),
ProblemJson(json!({
"type": "https://iana.org/assignments/http-problem-types#inconsistent-upload-length",
"title": "The provided upload lengths are inconsistent with one another or a previously established total length.",
"detail": detail,
})),
)
.into_response(),
ApiError::CaosUploadNotFinished => (
StatusCode::CONFLICT,
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/upload-not-finished",
"title": "The upload is not finished yet."
})),
)
.into_response(),
ApiError::CaosUnknownBucket { bucket_id } => (
StatusCode::CONFLICT,
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/unknown-bucket",
"title": "There is no bucket with the specified ID.",
"bucketId": bucket_id
})),
)
.into_response(),
}
}
}