use axum::Json;
use axum::response::{IntoResponse, Response};
use http::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OciErrorCode {
BlobUnknown,
BlobUploadInvalid,
BlobUploadUnknown,
DigestInvalid,
ManifestBlobUnknown,
ManifestInvalid,
ManifestUnknown,
NameInvalid,
NameUnknown,
SizeInvalid,
Unauthorized,
Denied,
Unsupported,
TooManyRequests,
}
impl OciErrorCode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::BlobUnknown => "BLOB_UNKNOWN",
Self::BlobUploadInvalid => "BLOB_UPLOAD_INVALID",
Self::BlobUploadUnknown => "BLOB_UPLOAD_UNKNOWN",
Self::DigestInvalid => "DIGEST_INVALID",
Self::ManifestBlobUnknown => "MANIFEST_BLOB_UNKNOWN",
Self::ManifestInvalid => "MANIFEST_INVALID",
Self::ManifestUnknown => "MANIFEST_UNKNOWN",
Self::NameInvalid => "NAME_INVALID",
Self::NameUnknown => "NAME_UNKNOWN",
Self::SizeInvalid => "SIZE_INVALID",
Self::Unauthorized => "UNAUTHORIZED",
Self::Denied => "DENIED",
Self::Unsupported => "UNSUPPORTED",
Self::TooManyRequests => "TOOMANYREQUESTS",
}
}
#[must_use]
pub const fn status(self) -> StatusCode {
match self {
Self::BlobUnknown
| Self::BlobUploadUnknown
| Self::ManifestBlobUnknown
| Self::ManifestUnknown
| Self::NameUnknown => StatusCode::NOT_FOUND,
Self::BlobUploadInvalid
| Self::DigestInvalid
| Self::ManifestInvalid
| Self::NameInvalid
| Self::SizeInvalid => StatusCode::BAD_REQUEST,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Denied => StatusCode::FORBIDDEN,
Self::Unsupported => StatusCode::METHOD_NOT_ALLOWED,
Self::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
}
}
}
impl std::fmt::Display for OciErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OciErrorInfo {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OciErrorBody {
pub errors: Vec<OciErrorInfo>,
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct OciError {
pub code: OciErrorCode,
pub message: String,
pub detail: Option<Value>,
pub status_override: Option<StatusCode>,
}
impl OciError {
pub fn new(code: OciErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
detail: None,
status_override: None,
}
}
#[must_use]
pub fn with_detail(mut self, detail: Value) -> Self {
self.detail = Some(detail);
self
}
#[must_use]
pub const fn with_status(mut self, status: StatusCode) -> Self {
self.status_override = Some(status);
self
}
#[must_use]
pub fn status(&self) -> StatusCode {
self.status_override.unwrap_or_else(|| self.code.status())
}
#[must_use]
pub fn body(&self) -> OciErrorBody {
OciErrorBody {
errors: vec![OciErrorInfo {
code: self.code.to_string(),
message: self.message.clone(),
detail: self.detail.clone(),
}],
}
}
}
impl IntoResponse for OciError {
fn into_response(self) -> Response {
let status = self.status();
let body = self.body();
(status, Json(body)).into_response()
}
}
pub type OciResult<T> = Result<T, OciError>;
impl From<ferro_blob_store::BlobStoreError> for OciError {
fn from(err: ferro_blob_store::BlobStoreError) -> Self {
use ferro_blob_store::BlobStoreError as B;
let msg = err.to_string();
match err {
B::NotFound(_) => Self::new(OciErrorCode::BlobUnknown, msg),
B::DigestMismatch { .. } | B::InvalidDigest(_) => {
Self::new(OciErrorCode::DigestInvalid, msg)
}
_ => Self::new(OciErrorCode::Unsupported, msg),
}
}
}
#[cfg(test)]
mod tests {
use super::{OciError, OciErrorCode};
use http::StatusCode;
#[test]
fn code_wire_strings_match_spec() {
assert_eq!(OciErrorCode::BlobUnknown.as_str(), "BLOB_UNKNOWN");
assert_eq!(
OciErrorCode::BlobUploadInvalid.as_str(),
"BLOB_UPLOAD_INVALID"
);
assert_eq!(
OciErrorCode::ManifestBlobUnknown.as_str(),
"MANIFEST_BLOB_UNKNOWN"
);
assert_eq!(OciErrorCode::NameInvalid.as_str(), "NAME_INVALID");
assert_eq!(OciErrorCode::TooManyRequests.as_str(), "TOOMANYREQUESTS");
}
#[test]
fn default_statuses_align_with_spec() {
assert_eq!(OciErrorCode::BlobUnknown.status(), StatusCode::NOT_FOUND);
assert_eq!(
OciErrorCode::DigestInvalid.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
OciErrorCode::Unauthorized.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(OciErrorCode::Denied.status(), StatusCode::FORBIDDEN);
}
#[test]
fn body_contains_single_error_entry() {
let err = OciError::new(OciErrorCode::NameInvalid, "bad name");
let body = err.body();
assert_eq!(body.errors.len(), 1);
assert_eq!(body.errors[0].code, "NAME_INVALID");
assert_eq!(body.errors[0].message, "bad name");
}
#[test]
fn status_override_wins_over_code_default() {
let err = OciError::new(OciErrorCode::Unsupported, "no delete by tag")
.with_status(StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[test]
fn every_code_has_wire_string_and_status() {
use OciErrorCode::{
BlobUnknown, BlobUploadInvalid, BlobUploadUnknown, Denied, DigestInvalid,
ManifestBlobUnknown, ManifestInvalid, ManifestUnknown, NameInvalid, NameUnknown,
SizeInvalid, TooManyRequests, Unauthorized, Unsupported,
};
let cases = [
(BlobUnknown, "BLOB_UNKNOWN", StatusCode::NOT_FOUND),
(
BlobUploadInvalid,
"BLOB_UPLOAD_INVALID",
StatusCode::BAD_REQUEST,
),
(
BlobUploadUnknown,
"BLOB_UPLOAD_UNKNOWN",
StatusCode::NOT_FOUND,
),
(DigestInvalid, "DIGEST_INVALID", StatusCode::BAD_REQUEST),
(
ManifestBlobUnknown,
"MANIFEST_BLOB_UNKNOWN",
StatusCode::NOT_FOUND,
),
(ManifestInvalid, "MANIFEST_INVALID", StatusCode::BAD_REQUEST),
(ManifestUnknown, "MANIFEST_UNKNOWN", StatusCode::NOT_FOUND),
(NameInvalid, "NAME_INVALID", StatusCode::BAD_REQUEST),
(NameUnknown, "NAME_UNKNOWN", StatusCode::NOT_FOUND),
(SizeInvalid, "SIZE_INVALID", StatusCode::BAD_REQUEST),
(Unauthorized, "UNAUTHORIZED", StatusCode::UNAUTHORIZED),
(Denied, "DENIED", StatusCode::FORBIDDEN),
(
Unsupported,
"UNSUPPORTED",
StatusCode::METHOD_NOT_ALLOWED,
),
(
TooManyRequests,
"TOOMANYREQUESTS",
StatusCode::TOO_MANY_REQUESTS,
),
];
for (code, wire, status) in cases {
assert_eq!(code.as_str(), wire, "{code:?} wire string");
assert_eq!(code.status(), status, "{code:?} status");
assert_eq!(code.to_string(), wire, "{code:?} display");
}
}
#[test]
fn with_detail_is_surfaced_in_body() {
let err = OciError::new(OciErrorCode::ManifestInvalid, "bad")
.with_detail(serde_json::json!({ "field": "config" }));
let body = err.body();
assert_eq!(
body.errors[0].detail.as_ref().expect("detail")["field"],
"config"
);
}
#[test]
fn blob_store_error_maps_to_oci_codes() {
use ferro_blob_store::BlobStoreError;
let not_found: OciError = BlobStoreError::NotFound("x".into()).into();
assert_eq!(not_found.code, OciErrorCode::BlobUnknown);
let parse_err = "no-colon".parse::<ferro_blob_store::Digest>().unwrap_err();
let bad_digest: OciError = BlobStoreError::InvalidDigest(parse_err).into();
assert_eq!(bad_digest.code, OciErrorCode::DigestInvalid);
let mismatch: OciError = BlobStoreError::DigestMismatch {
expected: "a".into(),
computed: "b".into(),
}
.into();
assert_eq!(mismatch.code, OciErrorCode::DigestInvalid);
let io: OciError = BlobStoreError::Io(std::io::Error::other("disk gone")).into();
assert_eq!(io.code, OciErrorCode::Unsupported);
}
}