Skip to main content

docspec_http/
error.rs

1//! Error types for the HTTP server.
2
3// Reason: docspec-http is an HTTP server unconditionally requiring std;
4// alloc is not in the extern prelude for std crates without `extern crate alloc`.
5#![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/// RFC 7807 Problem Details for HTTP APIs.
19///
20/// Contains exactly four fields per [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807):
21/// `type`, `title`, `status`, and `detail`. Serialized without `serde`
22/// via [`ProblemJson::to_json_bytes`].
23#[derive(Debug)]
24pub struct ProblemJson {
25    /// Human-readable explanation specific to this occurrence.
26    ///
27    /// May contain user-supplied data such as path names or MIME type strings.
28    /// Dynamic values are stored as [`Cow::Owned`]; static strings as [`Cow::Borrowed`].
29    pub detail: Cow<'static, str>,
30    /// HTTP status code generated by this occurrence.
31    pub status: u16,
32    /// Short, human-readable summary of the problem type.
33    ///
34    /// Matches the standard HTTP reason phrase for the status code.
35    pub title: &'static str,
36    /// URI reference identifying the problem type.
37    ///
38    /// This server always uses `"about:blank"`.
39    pub type_uri: &'static str,
40}
41
42impl ProblemJson {
43    /// Serialize this problem detail as a JSON-encoded byte vector.
44    ///
45    /// Emits exactly four fields in document order:
46    /// `"type"`, `"title"`, `"status"`, `"detail"`. String fields are
47    /// JSON-escaped (RFC 8259 §7) by the underlying writer; the status is
48    /// an unquoted integer.
49    ///
50    /// Uses [`JsonEmitter`] backed by `struson` for serialization.
51    ///
52    /// # Panics
53    ///
54    /// Does not panic for any well-formed `ProblemJson` instance. The
55    /// internal `.expect()` calls would only trigger on a bug in
56    /// `docspec-json` (the key/value sequence is statically valid JSON and
57    /// the `Vec<u8>` writer is infallible).
58    #[inline]
59    #[must_use]
60    pub fn to_json_bytes(&self) -> Vec<u8> {
61        // Reason: emission of a fixed 4-field object into Vec<u8> cannot fail
62        // in practice — Vec writes are infallible and the key/value sequence
63        // is statically valid JSON. Any error here would indicate a bug in
64        // docspec-json itself, not runtime input.
65        #[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/// HTTP-layer errors returned by the conversion API.
83///
84/// Each variant maps to a specific HTTP status code and is serialized as
85/// an RFC 7807 Problem JSON body via [`IntoResponse`].
86#[derive(Debug)]
87pub enum HttpError {
88    /// The request body bytes are not valid UTF-8.
89    ///
90    /// → HTTP 400 Bad Request.
91    BodyNotUtf8,
92    /// The request body was empty (`Content-Length: 0` or no body).
93    ///
94    /// → HTTP 400 Bad Request.
95    EmptyBody,
96    /// An unexpected internal error occurred during conversion.
97    ///
98    /// → HTTP 500 Internal Server Error.
99    Internal,
100    /// The HTTP method is not supported on this endpoint.
101    ///
102    /// → HTTP 405 Method Not Allowed (response includes an `Allow` header).
103    MethodNotAllowed {
104        /// Comma-separated list of allowed methods for this endpoint.
105        allowed: &'static str,
106    },
107    /// The `Accept` header excludes all formats this server produces.
108    ///
109    /// → HTTP 406 Not Acceptable.
110    NotAcceptable,
111    /// No route matches the requested method + path.
112    ///
113    /// → HTTP 404 Not Found.
114    NotFound {
115        /// The HTTP method of the unmatched request (e.g. `"GET"`).
116        method: String,
117        /// The path of the unmatched request (e.g. `"/unknown"`).
118        path: String,
119    },
120    /// Document conversion failed due to invalid or malformed input.
121    ///
122    /// → HTTP 422 Unprocessable Entity.
123    Unprocessable {
124        /// Explanation of what made the input invalid.
125        detail: String,
126    },
127    /// The `Content-Type` header is not `text/markdown`.
128    ///
129    /// → HTTP 415 Unsupported Media Type.
130    UnsupportedMediaType {
131        /// The content-type that was received, if any.
132        received: Option<String>,
133    },
134}
135
136impl IntoResponse for HttpError {
137    /// Convert this error into an HTTP response with an RFC 7807 Problem JSON body.
138    ///
139    /// Sets `Content-Type: application/problem+json; charset=utf-8` and a JSON body
140    /// with exactly four fields: `type`, `title`, `status`, `detail`.
141    /// [`HttpError::MethodNotAllowed`] additionally sets the `Allow` response header.
142    #[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    /// Returns a stable, low-cardinality string identifying the error class.
242    /// Safe to use as a Prometheus label value — never contains per-request data.
243    #[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    /// Returns the result class for Prometheus labels: `"client_error"` for 4xx, `"server_error"` for 5xx.
259    #[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    // Reason: test code legitimately panics on assertion failures; unwrap, expect,
279    // and slice indexing are standard testing patterns that express expected outcomes.
280    #![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}