Skip to main content

jmap_server/
response.rs

1//! HTTP response helpers for JMAP request-level errors (RFC 8620 §3.6.1, RFC 7807).
2
3use http::{header, Response, StatusCode};
4use serde::Serialize;
5
6use crate::{Invocation, JmapError};
7
8/// RFC 7807 Problem Details body for JMAP request-level errors.
9///
10/// `type` and `status` are always present.  `limit` is present for `limit`
11/// errors (RFC 8620 §3.6.1 requires naming the exceeded limit).  `detail` is
12/// present for other errors that carry a description.
13#[derive(Serialize)]
14struct ProblemDetails<'a> {
15    #[serde(rename = "type")]
16    type_urn: String,
17    status: u16,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    limit: Option<&'a str>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    detail: Option<&'a str>,
22}
23
24/// Wrap a method-level error as an error `Invocation` for `methodResponses`.
25///
26/// Per RFC 8620 §3.6.2, error invocations always use `"error"` as the method
27/// name regardless of the original method.  Only `call_id` is echoed.
28/// Method-level errors are returned inside `methodResponses` with HTTP 200 —
29/// they are NOT returned as top-level HTTP errors.
30pub fn error_invocation(call_id: &str, err: JmapError) -> Invocation {
31    // JmapError uses #[derive(Serialize)] with only String, Option<String>,
32    // and Option<Id> fields — all JSON-serializable primitives with string
33    // keys. serde_json::to_value only fails when a Serialize impl produces a
34    // non-string map key; the derived impl for JmapError cannot do this. The
35    // exhaustive contract test `jmap_error_all_constructors_serialize` in
36    // this module exercises every public JmapError constructor and proves
37    // the invariant.
38    //
39    // bd:JMAP-jfia.35 — previously this was an `unwrap_or_else` fallback to
40    // a generic serverFail. That fallback was actively harmful: a future
41    // jmap-types change that broke the Serialize invariant would silently
42    // rewrite the original error to "internal error" with no log signal.
43    // `.expect()` instead lets a panic surface, get caught by the
44    // dispatcher's `task::spawn` isolation, and produce a serverFail
45    // invocation with the failing-variant identity visible in the panic
46    // payload. Louder is safer than silent corruption.
47    let err_value = serde_json::to_value(&err)
48        .expect("JmapError Serialize invariant — see jmap_error_all_constructors_serialize");
49    ("error".to_owned(), err_value, call_id.to_owned())
50}
51
52/// Map a [`JmapError`] type string to the appropriate HTTP status code.
53///
54/// Error type strings are per RFC 8620 §7.1.
55///
56/// # Request-level errors only
57///
58/// Only request-level errors should flow through this function.  Method-level
59/// errors (`accountNotFound`, `notFound`, `unknownMethod`, etc.) belong in
60/// `methodResponses` at HTTP 200 via [`error_invocation`] — they must never
61/// reach `error_status`.  Passing a method-level error here is a caller bug;
62/// the catch-all maps unrecognized types to 500 rather than silently returning
63/// a wrong status code.
64///
65/// Request-level error types (safe to pass here): `notJSON`, `notRequest`,
66/// `limit`, `unknownCapability`, `invalidArguments`, `requestTooLarge`,
67/// `forbidden`, `serverFail`, `serverUnavailable`.
68pub fn error_status(err: &JmapError) -> StatusCode {
69    match err.error_type.as_str() {
70        // RFC 8620 §3.6.1 request-level errors → 400.
71        "notJSON" | "notRequest" | "limit" | "unknownCapability" | "invalidArguments"
72        | "requestTooLarge" => StatusCode::BAD_REQUEST,
73        "forbidden" => StatusCode::FORBIDDEN,
74        "serverFail" => StatusCode::INTERNAL_SERVER_ERROR,
75        "serverUnavailable" => StatusCode::SERVICE_UNAVAILABLE,
76        // Any unrecognized type is an internal bug, not a client error.
77        // The most common mistake is passing a method-level error (e.g. "accountNotFound",
78        // "notFound") to request_error() — those must stay in methodResponses at HTTP 200
79        // via error_invocation() per RFC 8620 §3.6.2.
80        _ => StatusCode::INTERNAL_SERVER_ERROR,
81    }
82}
83
84/// A request-level JMAP error response: HTTP status code + JMAP error body.
85///
86/// Used when an error occurs before method dispatch (e.g., parse failure,
87/// unknown capability).  Derives the HTTP status from the error type via
88/// [`error_status`].  Use [`request_error`] to construct.
89///
90/// Call [`RequestError::into_response`] to produce an `http::Response<String>`
91/// with the RFC 7807 Problem Details body.  Any HTTP framework that works with
92/// the `http` crate (axum, hyper, warp, etc.) accepts this directly.
93#[derive(Debug)]
94// bd:JMAP-wlip.31 — both fields are private today, so #[non_exhaustive]
95// is functionally a no-op for outside-crate construction / matching.
96// Kept deliberately as a forward-compat signal: a future field that
97// becomes `pub` (e.g. an `extras` map paralleling SetError.extra) does
98// NOT then need to add the attribute as a separate change. The
99// attribute also documents intent at the type-definition site without
100// requiring readers to chase down the field visibility.
101#[non_exhaustive]
102pub struct RequestError {
103    status: StatusCode,
104    err: JmapError,
105}
106
107impl std::fmt::Display for RequestError {
108    /// Render as `"<status>: <error_type>[: <description>]"` for log/diagnostic
109    /// output. The HTTP status is included because [`RequestError`] is the
110    /// request-level error type and the status is the most actionable field
111    /// for operators.
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self.err.description.as_deref() {
114            Some(desc) => write!(
115                f,
116                "{}: {}: {}",
117                self.status.as_u16(),
118                self.err.error_type,
119                desc
120            ),
121            None => write!(f, "{}: {}", self.status.as_u16(), self.err.error_type),
122        }
123    }
124}
125
126impl std::error::Error for RequestError {}
127
128impl RequestError {
129    /// Convert into an HTTP response with an RFC 7807 Problem Details body.
130    ///
131    /// The `Content-Type` is `application/problem+json`.  The body is a JSON
132    /// object with at minimum `"type"` (full URN) and `"status"` fields per
133    /// RFC 7807 §3.1, plus `"limit"` for `limit` errors (RFC 8620 §3.6.1).
134    pub fn into_response(self) -> Response<String> {
135        let status = self.status;
136        let err = self.err;
137        // RFC 8620 §3.6.1 requires RFC 7807 Problem Details format with full URN type.
138        // For "limit" errors, RFC 8620 §3.6.1 REQUIRES a "limit" property naming
139        // the exceeded limit.  By convention (see JmapError::limit()), the limit
140        // name is stored in the description field.  Use JmapError::limit(name) —
141        // never set error_type = "limit" manually — to ensure this invariant holds.
142        let (limit, detail) = if err.error_type == "limit" {
143            (Some(err.description.as_deref().unwrap_or("unknown")), None)
144        } else {
145            (None, err.description.as_deref())
146        };
147        let details = ProblemDetails {
148            type_urn: format!("urn:ietf:params:jmap:error:{}", err.error_type),
149            status: status.as_u16(),
150            limit,
151            detail,
152        };
153        // ProblemDetails only contains String, u16, and Option<&str> fields —
154        // all JSON-serializable; to_json() cannot fail here.
155        let body = serde_json::to_string(&details).expect("ProblemDetails is infallible");
156        // Builder only fails for invalid status codes or header values; both are
157        // controlled here and known-valid, so this cannot panic.
158        Response::builder()
159            .status(status)
160            .header(header::CONTENT_TYPE, "application/problem+json")
161            .body(body)
162            .expect("valid status code and Content-Type header")
163    }
164}
165
166/// Convenience constructor: wrap a [`JmapError`] in a [`RequestError`],
167/// deriving the HTTP status code automatically.
168///
169/// # Request-level errors only
170///
171/// Pass only request-level errors (see [`error_status`] for the full list).
172/// Method-level errors must go through [`error_invocation`] instead.
173pub fn request_error(err: JmapError) -> RequestError {
174    let status = error_status(&err);
175    RequestError { status, err }
176}
177
178impl From<JmapError> for RequestError {
179    /// Convert a [`JmapError`] into a [`RequestError`], deriving the HTTP
180    /// status code automatically via [`error_status`].
181    ///
182    /// Enables `?` propagation in functions returning `Result<_, RequestError>`.
183    /// Pass only request-level errors; see [`error_status`] for the safe list.
184    fn from(err: JmapError) -> Self {
185        request_error(err)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::Id;
193    use http::StatusCode;
194
195    // -----------------------------------------------------------------------
196    // error_invocation
197    // -----------------------------------------------------------------------
198
199    /// Oracle: RFC 8620 §3.6.2 — error invocations must use the literal method name "error".
200    /// The call_id must be echoed from the request.
201    #[test]
202    fn error_invocation_structure() {
203        let inv = error_invocation("c0", JmapError::unknown_method());
204        assert_eq!(inv.0, "error");
205        assert_eq!(inv.2, "c0");
206    }
207
208    /// Oracle: RFC 8620 §7.1 — error args object must have a "type" field.
209    #[test]
210    fn error_invocation_args_contains_type() {
211        let inv = error_invocation("c0", JmapError::unknown_method());
212        // inv.1 is already a serde_json::Value — index directly.
213        assert_eq!(inv.1["type"], "unknownMethod");
214    }
215
216    /// Oracle: RFC 8620 §7.1 — serverFail error type string and description field.
217    #[test]
218    fn error_invocation_server_fail() {
219        let inv = error_invocation("y", JmapError::server_fail("boom"));
220        assert_eq!(inv.1["type"], "serverFail");
221        assert_eq!(inv.1["description"], "boom");
222    }
223
224    // -----------------------------------------------------------------------
225    // error_status
226    // -----------------------------------------------------------------------
227
228    /// Oracle: RFC 8620 §3.6.1 — unknownCapability is a request-level error → 400.
229    #[test]
230    fn error_status_unknown_capability_is_400() {
231        let e: JmapError =
232            serde_json::from_value(serde_json::json!({"type": "unknownCapability"})).unwrap();
233        assert_eq!(error_status(&e), StatusCode::BAD_REQUEST);
234    }
235
236    /// Oracle: RFC 8620 §7.1 — invalidArguments → 400.
237    #[test]
238    fn error_status_invalid_arguments_is_400() {
239        assert_eq!(
240            error_status(&JmapError::invalid_arguments("x")),
241            StatusCode::BAD_REQUEST
242        );
243    }
244
245    /// Oracle: RFC 8620 §3.6.1 limit concept — requestTooLarge → 400.
246    #[test]
247    fn error_status_request_too_large_is_400() {
248        assert_eq!(
249            error_status(&JmapError::request_too_large()),
250            StatusCode::BAD_REQUEST
251        );
252    }
253
254    /// Oracle: RFC 8620 §7.1 — forbidden → HTTP 403.
255    #[test]
256    fn error_status_forbidden_is_403() {
257        assert_eq!(error_status(&JmapError::forbidden()), StatusCode::FORBIDDEN);
258    }
259
260    /// Oracle: RFC 8620 §3.6.1 — accountNotFound is method-level (stays HTTP 200 in
261    /// methodResponses).  Passing it to error_status is a caller bug; the catch-all
262    /// maps it to 500 rather than silently returning a wrong HTTP status.
263    #[test]
264    fn error_status_account_not_found_is_500() {
265        assert_eq!(
266            error_status(&JmapError::account_not_found()),
267            StatusCode::INTERNAL_SERVER_ERROR
268        );
269    }
270
271    /// Oracle: RFC 8620 §7.1 — serverFail → HTTP 500.
272    #[test]
273    fn error_status_server_fail_is_500() {
274        assert_eq!(
275            error_status(&JmapError::server_fail("x")),
276            StatusCode::INTERNAL_SERVER_ERROR
277        );
278    }
279
280    /// Oracle: unknown error types are server-side bugs, not client mistakes → 500.
281    #[test]
282    fn error_status_unknown_type_is_500() {
283        let e: JmapError =
284            serde_json::from_value(serde_json::json!({"type": "totallyMadeUp"})).unwrap();
285        assert_eq!(error_status(&e), StatusCode::INTERNAL_SERVER_ERROR);
286    }
287
288    // -----------------------------------------------------------------------
289    // RequestError / request_error
290    // -----------------------------------------------------------------------
291
292    /// Oracle: request_error calls error_status to derive the HTTP status code.
293    #[test]
294    fn request_error_derives_status() {
295        let re = request_error(JmapError::invalid_arguments("bad"));
296        assert_eq!(re.into_response().status(), StatusCode::BAD_REQUEST);
297    }
298
299    /// Oracle: IntoResponse for RequestError must set HTTP status from the contained StatusCode.
300    #[test]
301    fn request_error_into_response_status_code() {
302        let re = request_error(JmapError::invalid_arguments("bad"));
303        let resp = re.into_response();
304        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
305    }
306
307    /// Oracle: RFC 8620 §3.6.1 + RFC 7807 — Content-Type must be application/problem+json.
308    #[test]
309    fn request_error_content_type_is_problem_json() {
310        let re = request_error(JmapError::not_request());
311        let resp = re.into_response();
312        assert_eq!(
313            resp.headers()
314                .get(header::CONTENT_TYPE)
315                .and_then(|v| v.to_str().ok()),
316            Some("application/problem+json"),
317            "Content-Type must be application/problem+json per RFC 7807"
318        );
319    }
320
321    /// Oracle: RFC 8620 §3.6.1 — type field must be a full URN.
322    #[test]
323    fn request_error_type_is_full_urn() {
324        let body: serde_json::Value = serde_json::from_str(
325            &request_error(JmapError::not_request())
326                .into_response()
327                .into_body(),
328        )
329        .unwrap();
330        assert_eq!(
331            body["type"], "urn:ietf:params:jmap:error:notRequest",
332            "type must be full URN"
333        );
334    }
335
336    /// Oracle: RFC 7807 §3.1 — status field must equal the HTTP status code.
337    #[test]
338    fn request_error_status_field_matches_http_status() {
339        let body: serde_json::Value = serde_json::from_str(
340            &request_error(JmapError::not_request())
341                .into_response()
342                .into_body(),
343        )
344        .unwrap();
345        assert_eq!(body["status"], 400, "status field must match HTTP code");
346    }
347
348    /// Oracle: RFC 8620 §3.6.1 — limit errors MUST include "limit" property.
349    #[test]
350    fn request_error_limit_includes_limit_property() {
351        let body: serde_json::Value = serde_json::from_str(
352            &request_error(JmapError::limit("maxCallsInRequest"))
353                .into_response()
354                .into_body(),
355        )
356        .unwrap();
357        assert_eq!(
358            body["limit"], "maxCallsInRequest",
359            "limit property must name the exceeded limit"
360        );
361        assert_eq!(body["type"], "urn:ietf:params:jmap:error:limit");
362    }
363
364    // -----------------------------------------------------------------------
365    // JmapError serialization invariant (guards the .expect() in error_invocation)
366    // -----------------------------------------------------------------------
367
368    /// Oracle: error_invocation depends on JmapError being infallibly serializable.
369    /// This test exercises every JmapError constructor to catch any future regression
370    /// in jmap-types that breaks the invariant.
371    #[allow(deprecated)]
372    #[test]
373    fn jmap_error_all_constructors_serialize() {
374        let errors = vec![
375            JmapError::not_json(),
376            JmapError::not_request(),
377            JmapError::limit("maxCallsInRequest"),
378            JmapError::unknown_capability(),
379            JmapError::forbidden(),
380            JmapError::server_fail("test"),
381            JmapError::server_unavailable(),
382            JmapError::server_partial_fail(),
383            JmapError::unknown_method(),
384            JmapError::invalid_arguments("x"),
385            JmapError::invalid_result_reference(),
386            JmapError::not_found(),
387            JmapError::account_not_found(),
388            JmapError::account_not_supported_by_method(),
389            JmapError::account_read_only(),
390            JmapError::request_too_large(),
391            JmapError::singleton(),
392            JmapError::will_destroy(),
393            JmapError::invalid_patch(),
394            JmapError::invalid_properties(),
395            JmapError::too_large(),
396            JmapError::rate_limit(),
397            JmapError::over_quota(),
398            JmapError::state_mismatch(),
399            JmapError::cannot_calculate_changes(),
400            JmapError::anchor_not_found(),
401            JmapError::unsupported_sort(),
402            JmapError::unsupported_filter(),
403            JmapError::too_many_changes(),
404            JmapError::from_account_not_found(),
405            JmapError::from_account_not_supported_by_method(),
406            JmapError::already_exists(Id::from("existing-1")),
407            JmapError::custom("customErrorType"),
408        ];
409        for err in &errors {
410            let v = serde_json::to_value(err);
411            assert!(v.is_ok(), "JmapError variant failed to serialize: {err:?}");
412        }
413    }
414
415    // -----------------------------------------------------------------------
416    // From<JmapError> for RequestError
417    // -----------------------------------------------------------------------
418
419    /// Oracle: From<JmapError> must produce the same result as request_error().
420    #[test]
421    fn from_jmap_error_matches_request_error() {
422        let via_from: RequestError = JmapError::invalid_arguments("x").into();
423        let via_fn = request_error(JmapError::invalid_arguments("x"));
424        assert_eq!(
425            via_from.into_response().status(),
426            via_fn.into_response().status()
427        );
428    }
429}