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)]
87#[non_exhaustive]
88pub enum HttpError {
89    /// The request body bytes are not valid UTF-8.
90    ///
91    /// → HTTP 400 Bad Request.
92    BodyNotUtf8,
93    /// The request body was empty (`Content-Length: 0` or no body).
94    ///
95    /// → HTTP 400 Bad Request.
96    EmptyBody,
97    /// An unexpected internal error occurred during conversion.
98    ///
99    /// → HTTP 500 Internal Server Error.
100    Internal,
101    /// The HTTP method is not supported on this endpoint.
102    ///
103    /// → HTTP 405 Method Not Allowed (response includes an `Allow` header).
104    MethodNotAllowed {
105        /// Comma-separated list of allowed methods for this endpoint.
106        allowed: &'static str,
107    },
108    /// The `Accept` header excludes all formats this server produces.
109    ///
110    /// → HTTP 406 Not Acceptable.
111    NotAcceptable,
112    /// No route matches the requested method + path.
113    ///
114    /// → HTTP 404 Not Found.
115    NotFound {
116        /// The HTTP method of the unmatched request (e.g. `"GET"`).
117        method: String,
118        /// The path of the unmatched request (e.g. `"/unknown"`).
119        path: String,
120    },
121    /// Document conversion failed due to invalid or malformed input.
122    ///
123    /// → HTTP 422 Unprocessable Entity.
124    Unprocessable {
125        /// Explanation of what made the input invalid.
126        detail: String,
127    },
128    /// The `Content-Type` header is not one of the supported types: `text/markdown`, `text/html`, or `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
129    ///
130    /// → HTTP 415 Unsupported Media Type.
131    UnsupportedMediaType {
132        /// The content-type that was received, if any.
133        received: Option<String>,
134    },
135}
136
137impl IntoResponse for HttpError {
138    /// Convert this error into an HTTP response with an RFC 7807 Problem JSON body.
139    ///
140    /// Sets `Content-Type: application/problem+json; charset=utf-8` and a JSON body
141    /// with exactly four fields: `type`, `title`, `status`, `detail`.
142    /// [`HttpError::MethodNotAllowed`] additionally sets the `Allow` response header.
143    #[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    /// Returns a stable, low-cardinality string identifying the error class.
244    /// Safe to use as a Prometheus label value — never contains per-request data.
245    #[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    /// Returns the result class for Prometheus labels: `"client_error"` for 4xx, `"server_error"` for 5xx.
261    #[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    // Reason: test code legitimately panics on assertion failures; unwrap, expect,
281    // and slice indexing are standard testing patterns that express expected outcomes.
282    #![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}