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