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