1use axum::Json;
18use axum::response::{IntoResponse, Response};
19use http::StatusCode;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum OciErrorCode {
29 BlobUnknown,
31 BlobUploadInvalid,
33 BlobUploadUnknown,
35 DigestInvalid,
37 ManifestBlobUnknown,
39 ManifestInvalid,
41 ManifestUnknown,
43 NameInvalid,
45 NameUnknown,
47 SizeInvalid,
49 Unauthorized,
51 Denied,
53 Unsupported,
55 TooManyRequests,
57}
58
59impl OciErrorCode {
60 #[must_use]
62 pub const fn as_str(self) -> &'static str {
63 match self {
64 Self::BlobUnknown => "BLOB_UNKNOWN",
65 Self::BlobUploadInvalid => "BLOB_UPLOAD_INVALID",
66 Self::BlobUploadUnknown => "BLOB_UPLOAD_UNKNOWN",
67 Self::DigestInvalid => "DIGEST_INVALID",
68 Self::ManifestBlobUnknown => "MANIFEST_BLOB_UNKNOWN",
69 Self::ManifestInvalid => "MANIFEST_INVALID",
70 Self::ManifestUnknown => "MANIFEST_UNKNOWN",
71 Self::NameInvalid => "NAME_INVALID",
72 Self::NameUnknown => "NAME_UNKNOWN",
73 Self::SizeInvalid => "SIZE_INVALID",
74 Self::Unauthorized => "UNAUTHORIZED",
75 Self::Denied => "DENIED",
76 Self::Unsupported => "UNSUPPORTED",
77 Self::TooManyRequests => "TOOMANYREQUESTS",
78 }
79 }
80
81 #[must_use]
83 pub const fn status(self) -> StatusCode {
84 match self {
85 Self::BlobUnknown
86 | Self::BlobUploadUnknown
87 | Self::ManifestBlobUnknown
88 | Self::ManifestUnknown
89 | Self::NameUnknown => StatusCode::NOT_FOUND,
90 Self::BlobUploadInvalid
91 | Self::DigestInvalid
92 | Self::ManifestInvalid
93 | Self::NameInvalid
94 | Self::SizeInvalid => StatusCode::BAD_REQUEST,
95 Self::Unauthorized => StatusCode::UNAUTHORIZED,
96 Self::Denied => StatusCode::FORBIDDEN,
97 Self::Unsupported => StatusCode::METHOD_NOT_ALLOWED,
98 Self::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
99 }
100 }
101}
102
103impl std::fmt::Display for OciErrorCode {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.write_str(self.as_str())
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct OciErrorInfo {
115 pub code: String,
117 pub message: String,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub detail: Option<Value>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct OciErrorBody {
129 pub errors: Vec<OciErrorInfo>,
131}
132
133#[derive(Debug, Clone, thiserror::Error)]
141#[error("{code}: {message}")]
142pub struct OciError {
143 pub code: OciErrorCode,
145 pub message: String,
147 pub detail: Option<Value>,
149 pub status_override: Option<StatusCode>,
151}
152
153impl OciError {
154 pub fn new(code: OciErrorCode, message: impl Into<String>) -> Self {
156 Self {
157 code,
158 message: message.into(),
159 detail: None,
160 status_override: None,
161 }
162 }
163
164 #[must_use]
166 pub fn with_detail(mut self, detail: Value) -> Self {
167 self.detail = Some(detail);
168 self
169 }
170
171 #[must_use]
173 pub fn with_status(mut self, status: StatusCode) -> Self {
174 self.status_override = Some(status);
175 self
176 }
177
178 #[must_use]
180 pub fn status(&self) -> StatusCode {
181 self.status_override.unwrap_or_else(|| self.code.status())
182 }
183
184 #[must_use]
186 pub fn body(&self) -> OciErrorBody {
187 OciErrorBody {
188 errors: vec![OciErrorInfo {
189 code: self.code.to_string(),
190 message: self.message.clone(),
191 detail: self.detail.clone(),
192 }],
193 }
194 }
195}
196
197impl IntoResponse for OciError {
198 fn into_response(self) -> Response {
199 let status = self.status();
200 let body = self.body();
201 (status, Json(body)).into_response()
202 }
203}
204
205pub type OciResult<T> = Result<T, OciError>;
207
208impl From<ferro_blob_store::BlobStoreError> for OciError {
214 fn from(err: ferro_blob_store::BlobStoreError) -> Self {
215 use ferro_blob_store::BlobStoreError as B;
216 let msg = err.to_string();
217 match err {
218 B::NotFound(_) => Self::new(OciErrorCode::BlobUnknown, msg),
219 B::DigestMismatch { .. } | B::InvalidDigest(_) => {
220 Self::new(OciErrorCode::DigestInvalid, msg)
221 }
222 B::Io(_) => Self::new(OciErrorCode::Unsupported, msg),
223 _ => Self::new(OciErrorCode::Unsupported, msg),
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::{OciError, OciErrorCode};
231 use http::StatusCode;
232
233 #[test]
234 fn code_wire_strings_match_spec() {
235 assert_eq!(OciErrorCode::BlobUnknown.as_str(), "BLOB_UNKNOWN");
236 assert_eq!(
237 OciErrorCode::BlobUploadInvalid.as_str(),
238 "BLOB_UPLOAD_INVALID"
239 );
240 assert_eq!(
241 OciErrorCode::ManifestBlobUnknown.as_str(),
242 "MANIFEST_BLOB_UNKNOWN"
243 );
244 assert_eq!(OciErrorCode::NameInvalid.as_str(), "NAME_INVALID");
245 assert_eq!(OciErrorCode::TooManyRequests.as_str(), "TOOMANYREQUESTS");
246 }
247
248 #[test]
249 fn default_statuses_align_with_spec() {
250 assert_eq!(OciErrorCode::BlobUnknown.status(), StatusCode::NOT_FOUND);
251 assert_eq!(
252 OciErrorCode::DigestInvalid.status(),
253 StatusCode::BAD_REQUEST
254 );
255 assert_eq!(
256 OciErrorCode::Unauthorized.status(),
257 StatusCode::UNAUTHORIZED
258 );
259 assert_eq!(OciErrorCode::Denied.status(), StatusCode::FORBIDDEN);
260 }
261
262 #[test]
263 fn body_contains_single_error_entry() {
264 let err = OciError::new(OciErrorCode::NameInvalid, "bad name");
265 let body = err.body();
266 assert_eq!(body.errors.len(), 1);
267 assert_eq!(body.errors[0].code, "NAME_INVALID");
268 assert_eq!(body.errors[0].message, "bad name");
269 }
270
271 #[test]
272 fn status_override_wins_over_code_default() {
273 let err = OciError::new(OciErrorCode::Unsupported, "no delete by tag")
274 .with_status(StatusCode::METHOD_NOT_ALLOWED);
275 assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED);
276 }
277}