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 neither `text/markdown` nor `text/html`.
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/vnd.oxa+json, text/html, \
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 or text/html"),
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 or text/html, 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    /// Returns a stable, low-cardinality string identifying the error class.
243    /// Safe to use as a Prometheus label value — never contains per-request data.
244    #[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    /// Returns the result class for Prometheus labels: `"client_error"` for 4xx, `"server_error"` for 5xx.
260    #[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    // Reason: test code legitimately panics on assertion failures; unwrap, expect,
280    // and slice indexing are standard testing patterns that express expected outcomes.
281    #![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 or text/html, 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}