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