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 const 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 _ => Self::new(OciErrorCode::Unsupported, msg),
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::{OciError, OciErrorCode};
230 use http::StatusCode;
231
232 #[test]
233 fn code_wire_strings_match_spec() {
234 assert_eq!(OciErrorCode::BlobUnknown.as_str(), "BLOB_UNKNOWN");
235 assert_eq!(
236 OciErrorCode::BlobUploadInvalid.as_str(),
237 "BLOB_UPLOAD_INVALID"
238 );
239 assert_eq!(
240 OciErrorCode::ManifestBlobUnknown.as_str(),
241 "MANIFEST_BLOB_UNKNOWN"
242 );
243 assert_eq!(OciErrorCode::NameInvalid.as_str(), "NAME_INVALID");
244 assert_eq!(OciErrorCode::TooManyRequests.as_str(), "TOOMANYREQUESTS");
245 }
246
247 #[test]
248 fn default_statuses_align_with_spec() {
249 assert_eq!(OciErrorCode::BlobUnknown.status(), StatusCode::NOT_FOUND);
250 assert_eq!(
251 OciErrorCode::DigestInvalid.status(),
252 StatusCode::BAD_REQUEST
253 );
254 assert_eq!(
255 OciErrorCode::Unauthorized.status(),
256 StatusCode::UNAUTHORIZED
257 );
258 assert_eq!(OciErrorCode::Denied.status(), StatusCode::FORBIDDEN);
259 }
260
261 #[test]
262 fn body_contains_single_error_entry() {
263 let err = OciError::new(OciErrorCode::NameInvalid, "bad name");
264 let body = err.body();
265 assert_eq!(body.errors.len(), 1);
266 assert_eq!(body.errors[0].code, "NAME_INVALID");
267 assert_eq!(body.errors[0].message, "bad name");
268 }
269
270 #[test]
271 fn status_override_wins_over_code_default() {
272 let err = OciError::new(OciErrorCode::Unsupported, "no delete by tag")
273 .with_status(StatusCode::METHOD_NOT_ALLOWED);
274 assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED);
275 }
276
277 #[test]
278 fn every_code_has_wire_string_and_status() {
279 use OciErrorCode::{
280 BlobUnknown, BlobUploadInvalid, BlobUploadUnknown, Denied, DigestInvalid,
281 ManifestBlobUnknown, ManifestInvalid, ManifestUnknown, NameInvalid, NameUnknown,
282 SizeInvalid, TooManyRequests, Unauthorized, Unsupported,
283 };
284 let cases = [
287 (BlobUnknown, "BLOB_UNKNOWN", StatusCode::NOT_FOUND),
288 (
289 BlobUploadInvalid,
290 "BLOB_UPLOAD_INVALID",
291 StatusCode::BAD_REQUEST,
292 ),
293 (
294 BlobUploadUnknown,
295 "BLOB_UPLOAD_UNKNOWN",
296 StatusCode::NOT_FOUND,
297 ),
298 (DigestInvalid, "DIGEST_INVALID", StatusCode::BAD_REQUEST),
299 (
300 ManifestBlobUnknown,
301 "MANIFEST_BLOB_UNKNOWN",
302 StatusCode::NOT_FOUND,
303 ),
304 (ManifestInvalid, "MANIFEST_INVALID", StatusCode::BAD_REQUEST),
305 (ManifestUnknown, "MANIFEST_UNKNOWN", StatusCode::NOT_FOUND),
306 (NameInvalid, "NAME_INVALID", StatusCode::BAD_REQUEST),
307 (NameUnknown, "NAME_UNKNOWN", StatusCode::NOT_FOUND),
308 (SizeInvalid, "SIZE_INVALID", StatusCode::BAD_REQUEST),
309 (Unauthorized, "UNAUTHORIZED", StatusCode::UNAUTHORIZED),
310 (Denied, "DENIED", StatusCode::FORBIDDEN),
311 (
312 Unsupported,
313 "UNSUPPORTED",
314 StatusCode::METHOD_NOT_ALLOWED,
315 ),
316 (
317 TooManyRequests,
318 "TOOMANYREQUESTS",
319 StatusCode::TOO_MANY_REQUESTS,
320 ),
321 ];
322 for (code, wire, status) in cases {
323 assert_eq!(code.as_str(), wire, "{code:?} wire string");
324 assert_eq!(code.status(), status, "{code:?} status");
325 assert_eq!(code.to_string(), wire, "{code:?} display");
327 }
328 }
329
330 #[test]
331 fn with_detail_is_surfaced_in_body() {
332 let err = OciError::new(OciErrorCode::ManifestInvalid, "bad")
333 .with_detail(serde_json::json!({ "field": "config" }));
334 let body = err.body();
335 assert_eq!(
336 body.errors[0].detail.as_ref().expect("detail")["field"],
337 "config"
338 );
339 }
340
341 #[test]
342 fn blob_store_error_maps_to_oci_codes() {
343 use ferro_blob_store::BlobStoreError;
344
345 let not_found: OciError = BlobStoreError::NotFound("x".into()).into();
346 assert_eq!(not_found.code, OciErrorCode::BlobUnknown);
347
348 let parse_err = "no-colon".parse::<ferro_blob_store::Digest>().unwrap_err();
350 let bad_digest: OciError = BlobStoreError::InvalidDigest(parse_err).into();
351 assert_eq!(bad_digest.code, OciErrorCode::DigestInvalid);
352
353 let mismatch: OciError = BlobStoreError::DigestMismatch {
354 expected: "a".into(),
355 computed: "b".into(),
356 }
357 .into();
358 assert_eq!(mismatch.code, OciErrorCode::DigestInvalid);
359
360 let io: OciError = BlobStoreError::Io(std::io::Error::other("disk gone")).into();
361 assert_eq!(io.code, OciErrorCode::Unsupported);
362 }
363}