1#![allow(clippy::std_instead_of_alloc)]
6
7use std::borrow::Cow;
8
9use axum::{
10 http::{
11 header::{ALLOW, CONTENT_TYPE},
12 HeaderValue, StatusCode,
13 },
14 response::{IntoResponse, Response},
15};
16use docspec_json::{JsonEmitter, StrusonBackend};
17
18#[derive(Debug)]
24pub struct ProblemJson {
25 pub detail: Cow<'static, str>,
30 pub status: u16,
32 pub title: &'static str,
36 pub type_uri: &'static str,
40}
41
42impl ProblemJson {
43 #[inline]
59 #[must_use]
60 pub fn to_json_bytes(&self) -> Vec<u8> {
61 #[allow(clippy::expect_used)]
66 {
67 let mut emitter = JsonEmitter::new(StrusonBackend::new(Vec::new()));
68 emitter
69 .object(|builder| {
70 builder.key("type").value(self.type_uri)?;
71 builder.key("title").value(self.title)?;
72 builder.key("status").value(u32::from(self.status))?;
73 builder.key("detail").value(self.detail.as_ref())?;
74 Ok(())
75 })
76 .expect("ProblemJson object emission is infallible");
77 emitter.finish().expect("ProblemJson finish is infallible")
78 }
79 }
80}
81
82#[derive(Debug)]
87pub enum HttpError {
88 BodyNotUtf8,
92 EmptyBody,
96 Internal,
100 MethodNotAllowed {
104 allowed: &'static str,
106 },
107 NotAcceptable,
111 NotFound {
115 method: String,
117 path: String,
119 },
120 Unprocessable {
124 detail: String,
126 },
127 UnsupportedMediaType {
131 received: Option<String>,
133 },
134}
135
136impl IntoResponse for HttpError {
137 #[inline]
143 fn into_response(self) -> Response {
144 let (status, title, detail, allow): (
145 StatusCode,
146 &'static str,
147 Cow<'static, str>,
148 Option<&'static str>,
149 ) = match self {
150 Self::EmptyBody => (
151 StatusCode::BAD_REQUEST,
152 "Bad Request",
153 Cow::Borrowed("Request body is empty"),
154 None,
155 ),
156 Self::BodyNotUtf8 => (
157 StatusCode::BAD_REQUEST,
158 "Bad Request",
159 Cow::Borrowed("Request body is not valid UTF-8"),
160 None,
161 ),
162 Self::NotFound { method, path } => (
163 StatusCode::NOT_FOUND,
164 "Not Found",
165 Cow::Owned(format!("No route matches {method} {path}")),
166 None,
167 ),
168 Self::MethodNotAllowed { allowed } => (
169 StatusCode::METHOD_NOT_ALLOWED,
170 "Method Not Allowed",
171 Cow::Owned(format!("Method not allowed. Allowed methods: {allowed}.")),
172 Some(allowed),
173 ),
174 Self::NotAcceptable => (
175 StatusCode::NOT_ACCEPTABLE,
176 "Not Acceptable",
177 Cow::Borrowed(
178 "Accept header must include application/vnd.docspec.blocknote+json, \
179 application/vnd.blocknote+json, application/vnd.oxa+json, \
180 application/*, or */*",
181 ),
182 None,
183 ),
184 Self::UnsupportedMediaType { received: None } => (
185 StatusCode::UNSUPPORTED_MEDIA_TYPE,
186 "Unsupported Media Type",
187 Cow::Borrowed("Content-Type must be text/markdown"),
188 None,
189 ),
190 Self::UnsupportedMediaType {
191 received: Some(content_type),
192 } => (
193 StatusCode::UNSUPPORTED_MEDIA_TYPE,
194 "Unsupported Media Type",
195 Cow::Owned(format!(
196 "Content-Type must be text/markdown, got {content_type}"
197 )),
198 None,
199 ),
200 Self::Unprocessable { detail } => (
201 StatusCode::UNPROCESSABLE_ENTITY,
202 "Unprocessable Entity",
203 Cow::Owned(detail),
204 None,
205 ),
206 Self::Internal => (
207 StatusCode::INTERNAL_SERVER_ERROR,
208 "Internal Server Error",
209 Cow::Borrowed("An unexpected error occurred during conversion"),
210 None,
211 ),
212 };
213
214 if status == StatusCode::INTERNAL_SERVER_ERROR || status == StatusCode::UNPROCESSABLE_ENTITY
215 {
216 sentry::capture_message(detail.as_ref(), sentry::Level::Error);
217 }
218
219 let body = ProblemJson {
220 detail,
221 status: status.as_u16(),
222 title,
223 type_uri: "about:blank",
224 }
225 .to_json_bytes();
226
227 let mut response = (status, body).into_response();
228 response.headers_mut().insert(
229 CONTENT_TYPE,
230 HeaderValue::from_static("application/problem+json; charset=utf-8"),
231 );
232 if let Some(allowed) = allow {
233 response
234 .headers_mut()
235 .insert(ALLOW, HeaderValue::from_static(allowed));
236 }
237 response
238 }
239}
240
241impl HttpError {
242 #[inline]
245 #[must_use]
246 pub fn error_class(&self) -> &'static str {
247 match self {
248 Self::BodyNotUtf8 => "body_not_utf8",
249 Self::EmptyBody => "empty_body",
250 Self::Internal => "internal",
251 Self::MethodNotAllowed { .. } => "method_not_allowed",
252 Self::NotAcceptable => "not_acceptable",
253 Self::NotFound { .. } => "not_found",
254 Self::Unprocessable { .. } => "unprocessable",
255 Self::UnsupportedMediaType { .. } => "unsupported_media_type",
256 }
257 }
258
259 #[inline]
261 #[must_use]
262 pub fn result_class(&self) -> &'static str {
263 use crate::metrics::{RESULT_CLIENT_ERROR, RESULT_SERVER_ERROR};
264 match self {
265 Self::BodyNotUtf8
266 | Self::EmptyBody
267 | Self::MethodNotAllowed { .. }
268 | Self::NotAcceptable
269 | Self::NotFound { .. }
270 | Self::Unprocessable { .. }
271 | Self::UnsupportedMediaType { .. } => RESULT_CLIENT_ERROR,
272 Self::Internal => RESULT_SERVER_ERROR,
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
282
283 use axum::{
284 http::{
285 header::{ALLOW, CONTENT_TYPE},
286 StatusCode,
287 },
288 response::IntoResponse as _,
289 };
290
291 use super::*;
292
293 async fn body_bytes(error: HttpError) -> Vec<u8> {
294 axum::body::to_bytes(error.into_response().into_body(), usize::MAX)
295 .await
296 .unwrap()
297 .to_vec()
298 }
299
300 #[test]
301 fn all_variants_have_correct_status_codes() {
302 assert_eq!(
303 HttpError::EmptyBody.into_response().status(),
304 StatusCode::BAD_REQUEST
305 );
306 assert_eq!(
307 HttpError::BodyNotUtf8.into_response().status(),
308 StatusCode::BAD_REQUEST
309 );
310 assert_eq!(
311 HttpError::NotFound {
312 method: "GET".to_owned(),
313 path: "/foo".to_owned()
314 }
315 .into_response()
316 .status(),
317 StatusCode::NOT_FOUND
318 );
319 assert_eq!(
320 HttpError::MethodNotAllowed { allowed: "GET" }
321 .into_response()
322 .status(),
323 StatusCode::METHOD_NOT_ALLOWED
324 );
325 assert_eq!(
326 HttpError::NotAcceptable.into_response().status(),
327 StatusCode::NOT_ACCEPTABLE
328 );
329 assert_eq!(
330 HttpError::UnsupportedMediaType { received: None }
331 .into_response()
332 .status(),
333 StatusCode::UNSUPPORTED_MEDIA_TYPE
334 );
335 assert_eq!(
336 HttpError::Unprocessable {
337 detail: "bad".to_owned()
338 }
339 .into_response()
340 .status(),
341 StatusCode::UNPROCESSABLE_ENTITY
342 );
343 assert_eq!(
344 HttpError::Internal.into_response().status(),
345 StatusCode::INTERNAL_SERVER_ERROR
346 );
347 }
348
349 #[test]
350 fn method_not_allowed_has_allow_header() {
351 let response = HttpError::MethodNotAllowed { allowed: "GET" }.into_response();
352 let allow_val = response.headers().get(ALLOW).unwrap();
353 assert_eq!(allow_val, "GET");
354 }
355
356 #[test]
357 fn content_type_is_problem_json() {
358 let response = HttpError::Internal.into_response();
359 let content_type = response.headers().get(CONTENT_TYPE).unwrap();
360 assert_eq!(content_type, "application/problem+json; charset=utf-8");
361 }
362
363 #[test]
364 fn no_allow_header_on_non_405_variants() {
365 let response = HttpError::Internal.into_response();
366 assert!(response.headers().get(ALLOW).is_none());
367 }
368
369 #[test]
370 fn internal_error_is_captured_by_sentry() {
371 let events = sentry::test::with_captured_events(|| {
372 let _response = HttpError::Internal.into_response();
373 });
374 assert_eq!(events.len(), 1);
375 assert_eq!(events[0].level, sentry::Level::Error);
376 assert_eq!(
377 events[0].message.as_deref(),
378 Some("An unexpected error occurred during conversion")
379 );
380 }
381
382 #[test]
383 fn unprocessable_error_is_captured_by_sentry() {
384 let events = sentry::test::with_captured_events(|| {
385 let _response = HttpError::Unprocessable {
386 detail: "bad input".to_owned(),
387 }
388 .into_response();
389 });
390 assert_eq!(events.len(), 1);
391 assert_eq!(events[0].level, sentry::Level::Error);
392 assert_eq!(events[0].message.as_deref(), Some("bad input"));
393 }
394
395 #[test]
396 fn client_errors_are_not_captured_by_sentry() {
397 let events = sentry::test::with_captured_events(|| {
398 drop(HttpError::EmptyBody.into_response());
399 drop(HttpError::BodyNotUtf8.into_response());
400 drop(
401 HttpError::NotFound {
402 method: "GET".to_owned(),
403 path: "/x".to_owned(),
404 }
405 .into_response(),
406 );
407 drop(HttpError::MethodNotAllowed { allowed: "GET" }.into_response());
408 drop(HttpError::NotAcceptable.into_response());
409 drop(HttpError::UnsupportedMediaType { received: None }.into_response());
410 });
411 assert_eq!(events.len(), 0, "4xx errors must not be captured");
412 }
413
414 #[tokio::test]
415 async fn serializes_with_four_fields() {
416 let bytes = body_bytes(HttpError::Internal).await;
417 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
418 assert_eq!(
419 json,
420 serde_json::json!({
421 "type": "about:blank",
422 "title": "Internal Server Error",
423 "status": 500,
424 "detail": "An unexpected error occurred during conversion",
425 })
426 );
427 }
428
429 #[tokio::test]
430 async fn no_instance_key_in_output() {
431 let bytes = body_bytes(HttpError::EmptyBody).await;
432 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
433 assert!(
434 json.get("instance").is_none(),
435 "unexpected 'instance' key in output"
436 );
437 }
438
439 #[tokio::test]
440 async fn not_found_problem_body_is_exact() {
441 let bytes = body_bytes(HttpError::NotFound {
442 method: "GET".to_owned(),
443 path: "/api/v99".to_owned(),
444 })
445 .await;
446 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
447 assert_eq!(
448 json,
449 serde_json::json!({
450 "type": "about:blank",
451 "title": "Not Found",
452 "status": 404,
453 "detail": "No route matches GET /api/v99",
454 })
455 );
456 }
457
458 #[tokio::test]
459 async fn internal_detail_is_fixed() {
460 let bytes = body_bytes(HttpError::Internal).await;
461 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
462 assert_eq!(
463 json["detail"].as_str().unwrap(),
464 "An unexpected error occurred during conversion"
465 );
466 }
467
468 #[tokio::test]
469 async fn unsupported_media_type_with_received_problem_body_is_exact() {
470 let bytes = body_bytes(HttpError::UnsupportedMediaType {
471 received: Some("application/json".to_owned()),
472 })
473 .await;
474 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
475 assert_eq!(
476 json,
477 serde_json::json!({
478 "type": "about:blank",
479 "title": "Unsupported Media Type",
480 "status": 415,
481 "detail": "Content-Type must be text/markdown, got application/json",
482 })
483 );
484 }
485
486 #[tokio::test]
487 async fn unprocessable_problem_body_is_exact() {
488 let message = "heading level jumped from 1 to 3".to_owned();
489 let bytes = body_bytes(HttpError::Unprocessable {
490 detail: message.clone(),
491 })
492 .await;
493 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
494 assert_eq!(
495 json,
496 serde_json::json!({
497 "type": "about:blank",
498 "title": "Unprocessable Entity",
499 "status": 422,
500 "detail": message,
501 })
502 );
503 }
504
505 #[tokio::test]
506 async fn control_char_in_detail_is_escaped() {
507 let bytes = body_bytes(HttpError::Unprocessable {
508 detail: "bad\x01input".to_owned(),
509 })
510 .await;
511 assert_eq!(
512 bytes.as_slice(),
513 br#"{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"bad\u0001input"}"#
514 );
515 }
516
517 #[test]
518 fn body_not_utf8_error_class_returns_body_not_utf8() {
519 assert_eq!(HttpError::BodyNotUtf8.error_class(), "body_not_utf8");
520 }
521
522 #[test]
523 fn empty_body_error_class_returns_empty_body() {
524 assert_eq!(HttpError::EmptyBody.error_class(), "empty_body");
525 }
526
527 #[test]
528 fn internal_error_class_returns_internal() {
529 assert_eq!(HttpError::Internal.error_class(), "internal");
530 }
531
532 #[test]
533 fn method_not_allowed_error_class_returns_method_not_allowed() {
534 assert_eq!(
535 HttpError::MethodNotAllowed { allowed: "GET" }.error_class(),
536 "method_not_allowed"
537 );
538 }
539
540 #[test]
541 fn not_acceptable_error_class_returns_not_acceptable() {
542 assert_eq!(HttpError::NotAcceptable.error_class(), "not_acceptable");
543 }
544
545 #[test]
546 fn not_found_error_class_returns_not_found() {
547 assert_eq!(
548 HttpError::NotFound {
549 method: "GET".to_owned(),
550 path: "/foo".to_owned()
551 }
552 .error_class(),
553 "not_found"
554 );
555 }
556
557 #[test]
558 fn unprocessable_error_class_returns_unprocessable() {
559 assert_eq!(
560 HttpError::Unprocessable {
561 detail: "bad".to_owned()
562 }
563 .error_class(),
564 "unprocessable"
565 );
566 }
567
568 #[test]
569 fn unsupported_media_type_error_class_returns_unsupported_media_type() {
570 assert_eq!(
571 HttpError::UnsupportedMediaType { received: None }.error_class(),
572 "unsupported_media_type"
573 );
574 }
575
576 #[test]
577 fn body_not_utf8_result_class_returns_client_error() {
578 assert_eq!(HttpError::BodyNotUtf8.result_class(), "client_error");
579 }
580
581 #[test]
582 fn empty_body_result_class_returns_client_error() {
583 assert_eq!(HttpError::EmptyBody.result_class(), "client_error");
584 }
585
586 #[test]
587 fn internal_result_class_returns_server_error() {
588 assert_eq!(HttpError::Internal.result_class(), "server_error");
589 }
590
591 #[test]
592 fn method_not_allowed_result_class_returns_client_error() {
593 assert_eq!(
594 HttpError::MethodNotAllowed { allowed: "GET" }.result_class(),
595 "client_error"
596 );
597 }
598
599 #[test]
600 fn not_acceptable_result_class_returns_client_error() {
601 assert_eq!(HttpError::NotAcceptable.result_class(), "client_error");
602 }
603
604 #[test]
605 fn not_found_result_class_returns_client_error() {
606 assert_eq!(
607 HttpError::NotFound {
608 method: "GET".to_owned(),
609 path: "/foo".to_owned()
610 }
611 .result_class(),
612 "client_error"
613 );
614 }
615
616 #[test]
617 fn unprocessable_result_class_returns_client_error() {
618 assert_eq!(
619 HttpError::Unprocessable {
620 detail: "bad".to_owned()
621 }
622 .result_class(),
623 "client_error"
624 );
625 }
626
627 #[test]
628 fn unsupported_media_type_result_class_returns_client_error() {
629 assert_eq!(
630 HttpError::UnsupportedMediaType { received: None }.result_class(),
631 "client_error"
632 );
633 }
634}