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