jmap_server/lib.rs
1//! Backend-agnostic JMAP server framework (RFC 8620).
2//!
3//! Provides request parsing, ResultReference resolution, HTTP response helpers,
4//! the [`Dispatcher`] machinery, shared backend infrastructure, and generic
5//! JMAP method handlers.
6//!
7//! # Version coupling with `jmap-types` (bd:JMAP-wlip.18)
8//!
9//! This crate re-exports the wire-format types from `jmap-types`
10//! (`Id`, `JmapError`, `JmapRequest`, `JmapResponse`, `Invocation`,
11//! `ResultReference`, `Argument`, `State`, `UTCDate`) plus the marker
12//! traits (`GetObject`, `JmapObject`, `QueryObject`, `SetObject`).
13//! The public API of this crate is therefore *coupled* to
14//! `jmap-types`' public API: any breaking change to a re-exported
15//! type in `jmap-types` is a breaking change here, even when this
16//! crate's own surface is otherwise additive.
17//!
18//! **SemVer pin discipline**: consumers that depend on both
19//! `jmap-server` and `jmap-types` directly MUST pin the `jmap-types`
20//! version to the exact version `jmap-server` resolves to. The
21//! simplest path is to NOT depend on `jmap-types` directly and use
22//! `jmap_server::{Id, JmapError, ...}` instead — re-exports
23//! guarantee consistency. Depending on both with mismatched versions
24//! produces cargo's "expected `jmap_types::Id`, found `jmap_types::Id`"
25//! error (same type name, two different version hashes).
26
27#![forbid(unsafe_code)]
28
29pub use jmap_types::{
30 Argument, Id, Invocation, JmapError, JmapRequest, JmapResponse, ResultReference, State, UTCDate,
31};
32
33pub mod backend;
34pub mod handlers;
35mod helpers;
36
37pub use backend::{
38 AddedItem, BackendChangesError, BackendSetError, ChangesResult, GetObject, JmapBackend,
39 JmapObject, QueryChangesResult, QueryObject, QueryResult, ReservedExtrasKey, SetError,
40 SetErrorType, SetObject, RESERVED_SET_ERROR_WIRE_NAMES,
41};
42pub use handlers::{
43 handle_changes, handle_get, handle_query, handle_query_changes, server_fail_from_backend,
44 server_fail_value_from_backend, SERVER_FAIL_INTERNAL_DESC,
45};
46#[allow(deprecated)]
47pub use helpers::ser;
48pub use helpers::{
49 bool_arg, enforce_max_objects_in_set, extract_account_id, json_merge_patch, not_found_json,
50 now_utc_string, now_utc_string_checked, optional_arg, resolve_query_offset, serialize_value,
51 take_bool_arg, MergePatchError,
52};
53
54mod parse;
55mod response;
56
57pub use parse::{check_known_capabilities, parse_request, resolve_args};
58pub use response::{error_invocation, error_status, request_error, RequestError};
59
60use std::{collections::HashMap, fmt, future::Future, pin::Pin, sync::Arc};
61
62use serde_json::Value;
63use tokio::task;
64
65/// The return type for all [`JmapHandler`] implementations.
66///
67/// Handlers must return a `Send` future. The concrete type is a heap-allocated
68/// trait object so the trait itself remains object-safe.
69///
70/// The `Vec<Invocation>` holds zero or more additional entries to append to
71/// `methodResponses` immediately after the primary response (in order). Most
72/// handlers return an empty `Vec`. RFC 8621 §7.5 `EmailSubmission/set` uses
73/// this to append the implicit `Email/set` invocation for `onSuccessUpdateEmail`.
74pub type HandlerFuture =
75 Pin<Box<dyn Future<Output = Result<(Value, Vec<Invocation>), JmapError>> + Send>>;
76
77/// Implement this for each JMAP method handler.
78///
79/// `CallerCtx` is whatever your auth layer produces — an `Identity`, a session
80/// token, `()`, etc. The dispatcher passes it through unchanged.
81///
82/// # /set response contract
83///
84/// Handlers for `/set` methods (RFC 8620 §5.3) that create objects MUST include
85/// an `"id"` field (type string) in each entry of the `"created"` map. The
86/// dispatcher reads this field to accumulate `createdIds` in the response.
87/// Entries without an `"id"` field are silently skipped — the dispatcher cannot
88/// retroactively error a method call that already returned success.
89pub trait JmapHandler<CallerCtx>: Send + Sync {
90 /// `method` is the registered method name for this call. A single handler
91 /// instance may be registered under multiple names (e.g. both `"Foo/get"` and
92 /// `"Bar/get"`); this parameter lets the handler distinguish between them.
93 ///
94 /// `call_id` is the client-supplied identifier for this invocation (RFC 8620 §3.3).
95 /// Handlers may use it for logging or correlation but need not echo it —
96 /// the dispatcher echoes it in the response automatically.
97 ///
98 /// Both parameters are `String` (not `&str`) because the returned future is
99 /// `'static` — it must own all data it captures. Handlers that do not need
100 /// `method`/`call_id` can ignore them; handlers that do (e.g. echo) simply
101 /// capture the owned value.
102 fn call(
103 &self,
104 method: String,
105 call_id: String,
106 args: Value,
107 caller: CallerCtx,
108 ) -> HandlerFuture;
109}
110
111/// Walk a `/set` handler's primary response and accumulate every
112/// `created[client_id].id` pair into `sink` (RFC 8620 §3.4
113/// `createdIds`) (bd:JMAP-wlip.10).
114///
115/// Lives next to the [`JmapHandler`] doc contract that requires
116/// every entry of `created` to contain a string `"id"` field. Entries
117/// that violate the contract — no `"id"` key, or an `"id"` of a
118/// non-string type — are silently skipped, because the dispatcher
119/// cannot produce a method-level error for a method call that already
120/// succeeded. The shared helper makes the silent-skip behaviour
121/// auditable in one place rather than inlined in the dispatcher loop.
122///
123/// Non-`/set` primary responses (no `"created"` key at the top level,
124/// or `"created"` of a non-Object type) leave `sink` unchanged.
125///
126/// Collision semantics (bd:JMAP-jfia.3): when a creationId in the
127/// `/set` response collides with an entry already in `sink` — whether
128/// from an earlier `/set` call in the same batch OR from the client's
129/// pre-populated `createdIds` map — `HashMap::insert` silently
130/// overwrites: last write wins. The pre-populated collision case is
131/// exercised by
132/// [`tests::created_ids_pre_populated_collision_last_write_wins`].
133/// See the dispatcher call site for the full rationale.
134fn extract_created_ids_into(primary: &Value, sink: &mut HashMap<Id, Id>) {
135 let Some(map) = primary.get("created").and_then(|v| v.as_object()) else {
136 return;
137 };
138 for (client_id, created_obj) in map {
139 if let Some(id_val) = created_obj.get("id").and_then(|v| v.as_str()) {
140 sink.insert(Id::from(client_id.as_str()), Id::from(id_val));
141 }
142 }
143}
144
145/// Dispatches a [`JmapRequest`] to registered method handlers.
146///
147/// Register handlers with [`Dispatcher::register`], then call
148/// [`Dispatcher::dispatch`] per request. `CallerCtx` is cloned for each
149/// method call in the batch, so it must be `Clone`.
150///
151/// `CallerCtx` must also be `'static` because each handler call is spawned as
152/// a [`tokio::task`]. To share non-static data (e.g. a database connection),
153/// wrap it in `Arc<T>` — `Arc` is `Clone + Send + 'static` when `T: Send + Sync`.
154///
155/// # Thread safety
156///
157/// `Dispatcher` is both `Send` and `Sync`. Register handlers on one thread,
158/// then wrap in `Arc` and share across tasks — `dispatch` takes `&self` and is
159/// safe to call concurrently.
160pub struct Dispatcher<CallerCtx> {
161 handlers: HashMap<String, Arc<dyn JmapHandler<CallerCtx>>>,
162}
163
164/// Returned by [`Dispatcher::try_register`] when a handler is already
165/// registered under the requested method name.
166///
167/// Added in bd:JMAP-jfia.4 alongside `try_register` to make the
168/// duplicate-registration foot-gun explicit at the call site rather
169/// than silently dropping a binding.
170#[non_exhaustive]
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct DuplicateMethodError {
173 /// The method name that was already registered.
174 pub method: String,
175}
176
177impl std::fmt::Display for DuplicateMethodError {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 write!(f, "handler already registered for method {:?}", self.method)
180 }
181}
182
183impl std::error::Error for DuplicateMethodError {}
184
185impl<CallerCtx: Clone + Send + 'static> Dispatcher<CallerCtx> {
186 /// Create an empty dispatcher with no registered handlers.
187 pub fn new() -> Self {
188 Self {
189 handlers: HashMap::new(),
190 }
191 }
192
193 /// Register a handler for the given method name.
194 ///
195 /// **Registering the same name twice silently replaces the earlier
196 /// handler.** This is a real foot-gun in the workspace pattern where
197 /// each extension crate ships a `register_*_handlers` macro that
198 /// registers ~10 method names against a single `Dispatcher`: a typo
199 /// or accidental double-registration drops one binding with no
200 /// diagnostic, and the handler that "never fires" is hard to debug
201 /// (bd:JMAP-jfia.4). Prefer [`Dispatcher::try_register`] in new
202 /// code; `register` is kept for ergonomic call sites where the
203 /// silent-overwrite is the deliberate choice (e.g. a test fixture
204 /// that overrides a handler for a specific scenario).
205 ///
206 /// Using `Arc` rather than `Box` allows the same handler instance to be
207 /// shared across multiple method name registrations (via `Arc::clone`).
208 pub fn register(
209 &mut self,
210 method: impl Into<String>,
211 handler: Arc<dyn JmapHandler<CallerCtx>>,
212 ) {
213 self.handlers.insert(method.into(), handler);
214 }
215
216 /// Register a handler for the given method name, returning an error
217 /// if the method name is already registered.
218 ///
219 /// This is the recommended registration entry point for production
220 /// code paths (bd:JMAP-jfia.4). Unlike [`Dispatcher::register`], a
221 /// duplicate method name produces
222 /// [`DuplicateMethodError`] rather than silently replacing the
223 /// existing handler, surfacing the collision at the call site that
224 /// caused it.
225 ///
226 /// On `Err(DuplicateMethodError)` the dispatcher's handler map is
227 /// left unchanged — the existing handler is preserved and the
228 /// would-be registration is dropped.
229 ///
230 /// Using `Arc` rather than `Box` allows the same handler instance to be
231 /// shared across multiple method name registrations (via `Arc::clone`).
232 ///
233 /// # Errors
234 ///
235 /// Returns [`DuplicateMethodError`] when `method` is already
236 /// registered.
237 pub fn try_register(
238 &mut self,
239 method: impl Into<String>,
240 handler: Arc<dyn JmapHandler<CallerCtx>>,
241 ) -> Result<(), DuplicateMethodError> {
242 let method = method.into();
243 if self.handlers.contains_key(&method) {
244 return Err(DuplicateMethodError { method });
245 }
246 self.handlers.insert(method, handler);
247 Ok(())
248 }
249
250 /// Process a validated [`JmapRequest`] and return a [`JmapResponse`].
251 ///
252 /// Method calls are processed sequentially per RFC 8620 §3.3. Each
253 /// handler runs in a `tokio::task::spawn` for panic isolation: a panicking
254 /// handler returns a `serverFail` invocation rather than crashing the
255 /// connection task.
256 ///
257 /// `CallerCtx` must be `Clone + Send + 'static`; see the struct-level doc.
258 ///
259 /// # Runtime requirement (bd:JMAP-jfia.24)
260 ///
261 /// `dispatch` invokes [`tokio::task::spawn`] internally and
262 /// therefore **requires a Tokio runtime in scope at the call
263 /// site**. The async-fn signature itself does not advertise this
264 /// requirement — there is no `?Send` async-trait bound and no
265 /// `Spawner` parameter — but calling `dispatch` without a Tokio
266 /// runtime will panic at the first method call with a
267 /// `there is no reactor running` error.
268 ///
269 /// This is a structural coupling to the Tokio ecosystem.
270 /// Production consumers running under `tokio::main` /
271 /// `Runtime::block_on` already satisfy this; consumers
272 /// experimenting with alternative runtimes (`async-std`,
273 /// `smol`, `embassy`, etc.) cannot use `Dispatcher` without
274 /// either a Tokio compat shim or a custom dispatcher
275 /// re-implementation. Decoupling the spawn mechanism is a
276 /// major-bump API change tracked separately.
277 ///
278 /// # Cancellation
279 ///
280 /// **Per-request cancellation is not supported in the current API**
281 /// (bd:JMAP-wlip.23). If this future is dropped while a handler
282 /// task is running (e.g., the HTTP connection closes), the spawned
283 /// [`tokio::task`] runs to completion — tokio does not cancel
284 /// tasks when their `JoinHandle` is dropped. The handler result
285 /// is discarded.
286 ///
287 /// Production consequence: a JMAP server with a long-running
288 /// backend operation (e.g., `Email/query` over a 10M-mailbox
289 /// account, a slow full-text search, a slow downstream-service
290 /// lookup) cannot react to client disconnect. Every disconnect
291 /// leaks resource consumption equal to the full backend cost.
292 /// Adversarial clients can amplify this into a DoS by opening
293 /// many requests and dropping them.
294 ///
295 /// Recommended mitigations until per-request cancellation is wired:
296 ///
297 /// - **Bound each backend operation's runtime at the backend
298 /// layer**, e.g. by passing a deadline / timeout from the
299 /// backend impl's storage client (`tokio::time::timeout` around
300 /// each database call, RPC deadline, etc.). The handler does
301 /// not need to know about deadlines; the backend impl does.
302 /// - **Server-wide shutdown** via `tokio::select!` with a
303 /// broadcast channel from `main` works for the dispatcher loop
304 /// itself but does NOT propagate into spawned handler tasks.
305 /// To shut down cleanly, drain the dispatcher first and then
306 /// let in-flight handler tasks finish.
307 ///
308 /// The "implement cancellation at the handler level" advice
309 /// previously given here was unworkable: the spawned task has
310 /// no access to the outer dispatch-future's context (no token,
311 /// no shared liveness flag), and the [`JmapHandler::call`]
312 /// signature carries no cancellation token.
313 ///
314 /// A future revision may add an opt-in cancellation-token shape
315 /// (CallerCtx-carried token, or a dispatch-time
316 /// `cancel: CancellationToken` parameter that gets signalled on
317 /// future-drop). That is a workspace-architectural decision —
318 /// adding `tokio_util` to the dep allowlist plus threading the
319 /// token through every backend trait method — and is tracked
320 /// separately.
321 pub async fn dispatch(
322 &self,
323 request: JmapRequest,
324 caller: CallerCtx,
325 session_state: State,
326 ) -> JmapResponse {
327 let mut method_responses: Vec<Invocation> = Vec::with_capacity(request.method_calls.len());
328 let client_sent_created_ids = request.created_ids.is_some();
329 let mut created_ids: HashMap<Id, Id> = request.created_ids.unwrap_or_default();
330
331 // Invocation layout: (method_name, args, call_id) — RFC 8620 §3.3.
332 for (method, mut args, call_id) in request.method_calls {
333 // Resolve ResultReferences from prior responses.
334 if let Err(e) = resolve_args(&mut args, &method_responses) {
335 method_responses.push(error_invocation(&call_id, e));
336 continue;
337 }
338
339 // Look up the handler.
340 let Some(handler) = self.handlers.get(&method).map(Arc::clone) else {
341 method_responses.push(error_invocation(&call_id, JmapError::unknown_method()));
342 continue;
343 };
344
345 let caller_clone = caller.clone();
346 let method_clone = method.clone();
347 let call_id_clone = call_id.clone();
348
349 // Run in a spawned task for panic isolation.
350 let result: Result<
351 Result<(Value, Vec<Invocation>), JmapError>,
352 tokio::task::JoinError,
353 > = task::spawn(async move {
354 handler
355 .call(method_clone, call_id_clone, args, caller_clone)
356 .await
357 })
358 .await;
359
360 match result {
361 Ok(Ok((primary_value, extra_invocations))) => {
362 // Accumulate createdIds from /set responses (RFC 8620 §3.4).
363 // Only when the client sent createdIds; otherwise the field
364 // is omitted from the response.
365 //
366 // Duplicate-creationId behaviour (bd:JMAP-wlip.7,
367 // bd:JMAP-jfia.3): HashMap::insert silently overwrites
368 // on duplicate key. Two flavours of duplicate:
369 //
370 // (a) Intra-batch: a client reuses the same
371 // creationId across two /set calls in the same
372 // batch (e.g. "c1" in both Mailbox/set and
373 // Email/set). The second mapping wins and the
374 // first is lost.
375 //
376 // (b) Pre-populated collision: a client
377 // pre-populates createdIds with X->A and a /set
378 // call in the same batch returns X->B (B != A).
379 // The /set value wins and the pre-populated A is
380 // lost. This is reachable via long-lived
381 // background tasks replaying a queued request
382 // whose creationIds overlap with the current
383 // session's batch.
384 //
385 // RFC 8620 §3.4 does not explicitly require either
386 // last-write-wins or rejection; the convention here
387 // is last-write-wins because (a) the response order
388 // is deterministic so the behaviour is at least
389 // reproducible, (b) detecting either flavour of
390 // duplicate would require either a per-batch
391 // creationId pre-check (adds a HashSet allocation
392 // per request) or a second pass over
393 // method_responses after dispatch, and (c) this
394 // crate is the canonical foundation for every
395 // *-server extension — a wire-behaviour change
396 // (e.g. reject-on-collision) would ripple to every
397 // downstream consumer and is out of scope for a
398 // foundation crate without an RFC mandate.
399 //
400 // Clients SHOULD generate unique creationIds across
401 // a batch and SHOULD NOT pre-populate creationIds
402 // that any /set call in the same batch will
403 // produce. Both collision flavours are exercised
404 // explicitly by
405 // [`tests::created_ids_intra_batch_collision_last_write_wins`]
406 // and
407 // [`tests::created_ids_pre_populated_collision_last_write_wins`]
408 // so a future refactor that flips the order is
409 // caught.
410 if client_sent_created_ids {
411 extract_created_ids_into(&primary_value, &mut created_ids);
412 }
413 // Push the primary response first, then any extra invocations
414 // appended by the handler (e.g. onSuccessUpdateEmail from
415 // EmailSubmission/set, RFC 8621 §7.5). Order is preserved.
416 method_responses.push((method, primary_value, call_id));
417 method_responses.extend(extra_invocations);
418 }
419 Ok(Err(e)) => {
420 method_responses.push(error_invocation(&call_id, e));
421 }
422 Err(join_err) => {
423 // Panics and cancellations both map to serverFail, but with
424 // distinct descriptions to aid server-side diagnostics.
425 let desc = if join_err.is_cancelled() {
426 "task cancelled"
427 } else {
428 "internal error"
429 };
430 method_responses.push(error_invocation(&call_id, JmapError::server_fail(desc)));
431 }
432 }
433 }
434
435 let created_ids = client_sent_created_ids.then_some(created_ids);
436
437 JmapResponse::new(method_responses, session_state, created_ids)
438 }
439}
440
441impl<CallerCtx: Clone + Send + 'static> Default for Dispatcher<CallerCtx> {
442 fn default() -> Self {
443 Self::new()
444 }
445}
446
447impl<CallerCtx> fmt::Debug for Dispatcher<CallerCtx> {
448 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449 f.debug_struct("Dispatcher")
450 .field("methods", &self.handlers.keys())
451 .finish()
452 }
453}
454
455// ---------------------------------------------------------------------------
456// ClosureHandler — generic backend-wrapping JmapHandler that forwards CallerCtx
457// ---------------------------------------------------------------------------
458
459/// Type alias for the closure stored inside [`ClosureHandler`].
460///
461/// The `String` argument is the `call_id` (the client-supplied correlation
462/// identifier from RFC 8620 §3.3), not the method name. If you need the
463/// method name inside the closure, register the handler with
464/// [`Dispatcher::register`] and use [`JmapHandler`] directly instead.
465///
466/// `C` is the caller context (e.g. an auth identity) forwarded from
467/// [`Dispatcher::dispatch`]. Closures that don't need it can ignore the
468/// argument with `_ctx`.
469pub type BackendCallFn<B, C> =
470 dyn Fn(Arc<B>, String, serde_json::Value, C) -> HandlerFuture + Send + Sync + 'static;
471
472/// A [`JmapHandler`] that wraps an async closure over a shared backend and
473/// forwards `CallerCtx` to it.
474///
475/// Use this when your handler closures need per-request context — for
476/// example, an auth identity that controls which data the handler can
477/// access. Closures that don't need the context can simply ignore the
478/// `ctx` parameter.
479///
480/// # Usage
481///
482/// ```rust,ignore
483/// use jmap_server::{ClosureHandler, Dispatcher};
484/// use std::sync::Arc;
485///
486/// #[derive(Clone)]
487/// struct AuthCtx { user_id: String }
488///
489/// let handler: Arc<ClosureHandler<MyBackend, AuthCtx>> =
490/// Arc::new(ClosureHandler::new(
491/// Arc::new(my_backend),
492/// |b, call_id, args, ctx| {
493/// Box::pin(async move {
494/// // ctx.user_id is available here
495/// handle_something(&*b, args, &ctx.user_id).await
496/// })
497/// },
498/// ));
499///
500/// let mut dispatcher: Dispatcher<AuthCtx> = Dispatcher::new();
501/// dispatcher.register("MyMethod/get", handler);
502/// ```
503/// A [`JmapHandler`] handle wrapping a shared backend + an async
504/// closure. Construct via [`ClosureHandler::new`] — the fields are
505/// crate-private (bd:JMAP-jfia.5) to keep the handle opaque, prevent
506/// post-construction hot-swap of the closure or backend, and let the
507/// constructor remain the sole site that enforces invariants when
508/// future fields (per-handler tracing context, metrics handle, etc.)
509/// are added.
510#[non_exhaustive]
511pub struct ClosureHandler<B: Send + Sync + 'static, C: Clone + Send + 'static> {
512 /// Shared reference to the backend implementation, passed to the
513 /// closure on every method call. Crate-private to keep
514 /// `ClosureHandler` an opaque handle (bd:JMAP-jfia.5).
515 pub(crate) backend: Arc<B>,
516 /// The async closure invoked for each JMAP method call this handler
517 /// receives from the dispatcher. Crate-private to keep
518 /// `ClosureHandler` an opaque handle (bd:JMAP-jfia.5).
519 pub(crate) call_fn: Box<BackendCallFn<B, C>>,
520}
521
522impl<B: Send + Sync + 'static, C: Clone + Send + 'static> ClosureHandler<B, C> {
523 /// Construct a [`ClosureHandler`] wrapping a shared backend and an
524 /// async closure (bd:JMAP-wlip.17).
525 ///
526 /// This is the supported construction path. The struct is
527 /// `#[non_exhaustive]` so future fields (per-handler tracing
528 /// context, metrics handle, timeout, etc.) can be added without a
529 /// major-version bump — external callers MUST go through `new`
530 /// rather than struct-literal syntax.
531 ///
532 /// The `call_fn` parameter is generic over
533 /// `F: Fn(...) + Send + Sync + 'static` (bd:JMAP-jfia.40), so
534 /// callers can pass a closure directly without wrapping it in
535 /// `Box::new`. Existing callers that already wrap in
536 /// `Box::new(...)` continue to compile unchanged:
537 /// `Box<dyn Fn(...) + Send + Sync + 'static>` itself implements
538 /// `Fn(...)` via the blanket
539 /// `impl<F: Fn(...)> Fn(...) for Box<F>`, so the boxed form
540 /// satisfies the generic bound. Internally, the closure is boxed
541 /// once at construction and stored as
542 /// `Box<BackendCallFn<B, C>>`.
543 pub fn new<F>(backend: Arc<B>, call_fn: F) -> Self
544 where
545 F: Fn(Arc<B>, String, serde_json::Value, C) -> HandlerFuture + Send + Sync + 'static,
546 {
547 Self {
548 backend,
549 call_fn: Box::new(call_fn),
550 }
551 }
552}
553
554impl<B: Send + Sync + 'static, C: Clone + Send + 'static> JmapHandler<C> for ClosureHandler<B, C> {
555 fn call(
556 &self,
557 _method: String,
558 call_id: String,
559 args: serde_json::Value,
560 caller: C,
561 ) -> HandlerFuture {
562 (self.call_fn)(Arc::clone(&self.backend), call_id, args, caller)
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use serde_json::{json, Value};
570 use std::sync::{Arc, Mutex};
571
572 // Compile-time: Dispatcher must be Send + Sync so it can be wrapped in Arc
573 // and shared across tokio tasks. This assertion catches future regressions
574 // that would silently break thread-safety (e.g., adding a Cell or Rc field).
575 #[allow(dead_code)]
576 fn assert_dispatcher_send_sync() {
577 fn check<T: Send + Sync>() {}
578 check::<Dispatcher<String>>();
579 check::<Dispatcher<()>>();
580 }
581
582 // -----------------------------------------------------------------------
583 // Test handler implementations
584 // -----------------------------------------------------------------------
585
586 /// Returns a fixed Value regardless of inputs.
587 struct EchoHandler(Value);
588
589 impl<C: Clone + Send + 'static> JmapHandler<C> for EchoHandler {
590 fn call(
591 &self,
592 _method: String,
593 _call_id: String,
594 _args: Value,
595 _caller: C,
596 ) -> HandlerFuture {
597 let v = self.0.clone();
598 Box::pin(async move { Ok((v, vec![])) })
599 }
600 }
601
602 /// Returns a fixed error.
603 struct ErrorHandler(JmapError);
604
605 impl JmapHandler<String> for ErrorHandler {
606 fn call(
607 &self,
608 _method: String,
609 _call_id: String,
610 _args: Value,
611 _caller: String,
612 ) -> HandlerFuture {
613 let e = self.0.clone();
614 Box::pin(async move { Err(e) })
615 }
616 }
617
618 /// Captures the resolved args it was called with.
619 struct CaptureArgsHandler(Arc<Mutex<Option<Value>>>);
620
621 impl JmapHandler<String> for CaptureArgsHandler {
622 fn call(
623 &self,
624 _method: String,
625 _call_id: String,
626 args: Value,
627 _caller: String,
628 ) -> HandlerFuture {
629 let slot = self.0.clone();
630 Box::pin(async move {
631 *slot.lock().expect("test: mutex poisoned") = Some(args);
632 Ok((json!({}), vec![]))
633 })
634 }
635 }
636
637 /// Captures the caller value it was called with.
638 struct CaptureCallerHandler(Arc<Mutex<Option<String>>>);
639
640 impl JmapHandler<String> for CaptureCallerHandler {
641 fn call(
642 &self,
643 _method: String,
644 _call_id: String,
645 _args: Value,
646 caller: String,
647 ) -> HandlerFuture {
648 let slot = self.0.clone();
649 Box::pin(async move {
650 *slot.lock().expect("test: mutex poisoned") = Some(caller);
651 Ok((json!({}), vec![]))
652 })
653 }
654 }
655
656 /// Panics unconditionally.
657 struct PanicHandler;
658
659 impl JmapHandler<String> for PanicHandler {
660 fn call(
661 &self,
662 _method: String,
663 _call_id: String,
664 _args: Value,
665 _caller: String,
666 ) -> HandlerFuture {
667 Box::pin(async move { panic!("deliberate test panic") })
668 }
669 }
670
671 // -----------------------------------------------------------------------
672 // Helper: build a minimal JmapRequest with a single method call.
673 // -----------------------------------------------------------------------
674
675 fn single_call(method: &str, args: Value, call_id: &str) -> JmapRequest {
676 JmapRequest::new(
677 vec!["urn:ietf:params:jmap:core".into()],
678 vec![(method.into(), args, call_id.into())],
679 None,
680 )
681 }
682
683 // -----------------------------------------------------------------------
684 // Basic dispatch
685 // -----------------------------------------------------------------------
686
687 /// Oracle: RFC 8620 §7.1 — unknownMethod when no handler is registered.
688 #[tokio::test]
689 async fn unknown_method_returns_error_invocation() {
690 let d: Dispatcher<String> = Dispatcher::new();
691 let req = single_call("Foo/get", json!({}), "c0");
692 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
693 assert_eq!(resp.method_responses.len(), 1);
694 let (_, args, call_id) = &resp.method_responses[0];
695 assert_eq!(call_id, "c0");
696 assert_eq!(args["type"], "unknownMethod");
697 }
698
699 /// Oracle: RFC 8620 §3.5 — successful call appears in methodResponses.
700 #[tokio::test]
701 async fn known_method_success() {
702 let mut d: Dispatcher<String> = Dispatcher::new();
703 d.register("Foo/get", Arc::new(EchoHandler(json!({"list": []}))));
704 let req = single_call("Foo/get", json!({}), "c1");
705 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
706 assert_eq!(resp.method_responses.len(), 1);
707 let (method, args, call_id) = &resp.method_responses[0];
708 assert_eq!(method, "Foo/get");
709 assert_eq!(call_id, "c1");
710 assert_eq!(args["list"], json!([]));
711 }
712
713 /// Oracle: RFC 8620 §3.6.2 — method-level errors appear in methodResponses.
714 #[tokio::test]
715 async fn handler_returns_error() {
716 let mut d: Dispatcher<String> = Dispatcher::new();
717 d.register("Foo/get", Arc::new(ErrorHandler(JmapError::not_found())));
718 let req = single_call("Foo/get", json!({}), "c2");
719 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
720 assert_eq!(resp.method_responses.len(), 1);
721 let (_, args, _) = &resp.method_responses[0];
722 assert_eq!(args["type"], "notFound");
723 }
724
725 /// Oracle (bd:JMAP-jfia.4): try_register MUST return Ok for the
726 /// first registration of a method name, and Err(DuplicateMethodError)
727 /// for any subsequent registration of the same name. On Err the
728 /// dispatcher's handler map MUST be left unchanged — the
729 /// already-registered handler stays in place.
730 #[tokio::test]
731 async fn try_register_succeeds_then_errors_on_duplicate() {
732 let mut d: Dispatcher<String> = Dispatcher::new();
733 let first = Arc::new(EchoHandler(json!({"v": "first"})));
734 let second = Arc::new(EchoHandler(json!({"v": "second"})));
735
736 d.try_register("Foo/get", first)
737 .expect("first registration must succeed");
738
739 let err = d
740 .try_register("Foo/get", second)
741 .expect_err("second registration must error");
742 assert_eq!(err.method, "Foo/get");
743 assert_eq!(
744 err.to_string(),
745 "handler already registered for method \"Foo/get\""
746 );
747
748 // The first handler MUST still be the one that fires — try_register
749 // must NOT have replaced it as a side-effect of the error path.
750 let req = single_call("Foo/get", json!({}), "c0");
751 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
752 let (_, args, _) = &resp.method_responses[0];
753 assert_eq!(
754 args["v"], "first",
755 "try_register error path must not replace the existing handler"
756 );
757 }
758
759 /// Oracle (bd:JMAP-jfia.4): register (the silent-overwrite variant)
760 /// MUST continue to replace on duplicate, since established consumers
761 /// depend on that ergonomic for test fixtures. Pins the contract so
762 /// a future refactor that "fixes" register's silent overwrite is
763 /// caught.
764 #[tokio::test]
765 async fn register_silently_overwrites_on_duplicate() {
766 let mut d: Dispatcher<String> = Dispatcher::new();
767 d.register("Foo/get", Arc::new(EchoHandler(json!({"v": "first"}))));
768 d.register("Foo/get", Arc::new(EchoHandler(json!({"v": "second"}))));
769
770 let req = single_call("Foo/get", json!({}), "c0");
771 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
772 let (_, args, _) = &resp.method_responses[0];
773 assert_eq!(args["v"], "second", "register must replace on duplicate");
774 }
775
776 /// Oracle: RFC 8620 §3.4 — sessionState in response matches what dispatcher was given.
777 #[tokio::test]
778 async fn session_state_echoed() {
779 let d: Dispatcher<String> = Dispatcher::new();
780 let req = JmapRequest::new(vec!["urn:ietf:params:jmap:core".into()], vec![], None);
781 let resp = d.dispatch(req, "alice".into(), "my-state-123".into()).await;
782 assert_eq!(resp.session_state.as_ref(), "my-state-123");
783 }
784
785 // -----------------------------------------------------------------------
786 // Batch
787 // -----------------------------------------------------------------------
788
789 /// Oracle: RFC 8620 §3.3 — methodCalls processed in order, all responses present.
790 /// Also covers: error in one method does not abort the batch (RFC 8620 §3.6.2).
791 #[tokio::test]
792 async fn mixed_batch_all_responses_in_order() {
793 let mut d: Dispatcher<String> = Dispatcher::new();
794 d.register("M/a", Arc::new(EchoHandler(json!({"ok": true}))));
795 // "M/b" is NOT registered → unknownMethod
796 let req = JmapRequest::new(
797 vec!["urn:ietf:params:jmap:core".into()],
798 vec![
799 ("M/a".into(), json!({}), "c0".into()),
800 ("M/b".into(), json!({}), "c1".into()),
801 ("M/a".into(), json!({}), "c2".into()),
802 ],
803 None,
804 );
805 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
806 assert_eq!(
807 resp.method_responses.len(),
808 3,
809 "all three calls must produce a response"
810 );
811 // responses[0]: M/a success
812 assert_eq!(resp.method_responses[0].2, "c0");
813 assert!(
814 resp.method_responses[0].1.get("type").is_none(),
815 "c0 must not be an error"
816 );
817 // responses[1]: M/b unknownMethod
818 assert_eq!(resp.method_responses[1].2, "c1");
819 assert_eq!(resp.method_responses[1].1["type"], "unknownMethod");
820 // responses[2]: M/a success (error in [1] did not abort the batch)
821 assert_eq!(resp.method_responses[2].2, "c2");
822 assert!(
823 resp.method_responses[2].1.get("type").is_none(),
824 "c2 must not be an error"
825 );
826 }
827
828 /// Oracle: RFC 8620 §3.6.2 — error in one method does not abort subsequent calls.
829 #[tokio::test]
830 async fn error_does_not_abort_subsequent_calls() {
831 let mut d: Dispatcher<String> = Dispatcher::new();
832 d.register("M/ok", Arc::new(EchoHandler(json!({"ok": true}))));
833 d.register("M/err", Arc::new(ErrorHandler(JmapError::forbidden())));
834 let req = JmapRequest::new(
835 vec!["urn:ietf:params:jmap:core".into()],
836 vec![
837 ("M/err".into(), json!({}), "c0".into()),
838 ("M/ok".into(), json!({}), "c1".into()),
839 ],
840 None,
841 );
842 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
843 assert_eq!(resp.method_responses.len(), 2);
844 assert_eq!(resp.method_responses[0].1["type"], "forbidden");
845 assert!(
846 resp.method_responses[1].1.get("type").is_none(),
847 "second call must succeed"
848 );
849 }
850
851 // -----------------------------------------------------------------------
852 // Panic isolation
853 // -----------------------------------------------------------------------
854
855 /// Oracle: RFC 8620 §7.1 serverFail; PLAN.md panic isolation design decision.
856 #[tokio::test]
857 async fn panicking_handler_returns_server_fail() {
858 let mut d: Dispatcher<String> = Dispatcher::new();
859 d.register("Panic/now", Arc::new(PanicHandler));
860 let req = single_call("Panic/now", json!({}), "c0");
861 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
862 assert_eq!(resp.method_responses.len(), 1);
863 let (_, args, _) = &resp.method_responses[0];
864 assert_eq!(
865 args["type"], "serverFail",
866 "panicking handler must produce serverFail"
867 );
868 }
869
870 /// Oracle (bd:JMAP-wlip.12): security invariant — panic payloads
871 /// may contain secrets and MUST NOT leak through ANY field of the
872 /// error invocation, not just `description`. A future refactor that
873 /// surfaces panic-payload text through a typed `context`, an
874 /// `innerError` nested object, or any other field would slip past a
875 /// single-field check.
876 ///
877 /// The assertion walks the entire methodResponse args Value
878 /// recursively and asserts that no string anywhere in the tree
879 /// contains the canary `"deliberate test panic"` from
880 /// [`PanicHandler`].
881 ///
882 /// **Decision record (bd:JMAP-jfia.14)**: a future "simplify" pass
883 /// will reasonably suggest narrowing this to a single-field check
884 /// against `args["description"]` because that is where panic
885 /// payloads land today. That suggestion is **WRONG** and must be
886 /// rejected: a single-field check encodes the current
887 /// implementation rather than the security invariant. The
888 /// recursive walk encodes the actual invariant ("panic-payload
889 /// text does not leak to the wire, ANYWHERE in the response shape")
890 /// and survives refactors of the error shape. Defending this
891 /// shape protects the workspace credential/PII redaction policy
892 /// from drift.
893 #[tokio::test]
894 async fn panic_message_not_in_response() {
895 /// Returns `true` iff any `Value::String` in the tree
896 /// contains `needle`.
897 fn value_contains_recursive(v: &Value, needle: &str) -> bool {
898 match v {
899 Value::String(s) => s.contains(needle),
900 Value::Array(arr) => arr.iter().any(|x| value_contains_recursive(x, needle)),
901 Value::Object(o) => o.values().any(|x| value_contains_recursive(x, needle)),
902 Value::Null | Value::Bool(_) | Value::Number(_) => false,
903 }
904 }
905
906 let mut d: Dispatcher<String> = Dispatcher::new();
907 d.register("Panic/now", Arc::new(PanicHandler));
908 let req = single_call("Panic/now", json!({}), "c0");
909 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
910 let (_, args, _) = &resp.method_responses[0];
911 assert!(
912 !value_contains_recursive(args, "deliberate test panic"),
913 "panic message must not leak into ANY field of the response: {args}"
914 );
915 }
916
917 // -----------------------------------------------------------------------
918 // ResultReference end-to-end
919 // -----------------------------------------------------------------------
920
921 /// Oracle: RFC 8620 §3.7 — #-prefixed args resolved from prior responses before handler call.
922 #[tokio::test]
923 async fn result_reference_resolved_before_dispatch() {
924 let captured = Arc::new(Mutex::new(None::<Value>));
925 let mut d: Dispatcher<String> = Dispatcher::new();
926 d.register(
927 "Foo/get",
928 Arc::new(EchoHandler(json!({"list": [{"id": "item-1"}]}))),
929 );
930 d.register(
931 "Bar/query",
932 Arc::new(CaptureArgsHandler(Arc::clone(&captured))),
933 );
934 let req = JmapRequest::new(
935 vec!["urn:ietf:params:jmap:core".into()],
936 vec![
937 ("Foo/get".into(), json!({}), "c0".into()),
938 (
939 "Bar/query".into(),
940 json!({"#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/list/0/id"}}),
941 "c1".into(),
942 ),
943 ],
944 None,
945 );
946 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
947 assert_eq!(resp.method_responses.len(), 2);
948 // c1 must succeed, not be an error
949 assert!(
950 resp.method_responses[1].1.get("type").is_none(),
951 "Bar/query must succeed after ResultReference resolution"
952 );
953 // Handler must have received the resolved value, not the original #ids object
954 let got = captured
955 .lock()
956 .unwrap()
957 .clone()
958 .expect("CaptureArgsHandler was not called");
959 assert_eq!(
960 got["ids"],
961 json!("item-1"),
962 "resolved value must be the string item-1"
963 );
964 assert!(
965 got.get("#ids").is_none(),
966 "#ids key must have been replaced"
967 );
968 }
969
970 /// Oracle: RFC 8620 §3.7 — resolution failure → error for that call, batch continues.
971 #[tokio::test]
972 async fn result_reference_failure_stops_that_call() {
973 let d: Dispatcher<String> = Dispatcher::new();
974 let req = single_call(
975 "Foo/get",
976 json!({"#ids": {"resultOf": "nonexistent", "name": "Foo/get", "path": "/x"}}),
977 "c0",
978 );
979 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
980 assert_eq!(resp.method_responses.len(), 1);
981 let (_, args, _) = &resp.method_responses[0];
982 assert!(
983 args.get("type").is_some(),
984 "failed ResultReference must produce an error invocation"
985 );
986 }
987
988 // -----------------------------------------------------------------------
989 // createdIds
990 // -----------------------------------------------------------------------
991
992 /// Oracle: RFC 8620 §3.3 createdIds — server-assigned IDs returned from /set
993 /// responses are accumulated into resp.created_ids when client sent createdIds.
994 #[tokio::test]
995 async fn created_ids_accumulated_from_set_response() {
996 let mut d: Dispatcher<String> = Dispatcher::new();
997 d.register(
998 "Foo/set",
999 Arc::new(EchoHandler(
1000 json!({"created": {"client-1": {"id": "server-abc"}}}),
1001 )),
1002 );
1003 // Client sends createdIds (empty map) to signal it wants the response field.
1004 let req = JmapRequest::new(
1005 vec!["urn:ietf:params:jmap:core".into()],
1006 vec![("Foo/set".into(), json!({}), "c0".into())],
1007 Some(std::collections::HashMap::new()),
1008 );
1009 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1010 let ids = resp
1011 .created_ids
1012 .as_ref()
1013 .expect("created_ids must be Some when client sent createdIds");
1014 assert_eq!(
1015 ids.get(&Id::from("client-1")),
1016 Some(&Id::from("server-abc")),
1017 "client-1 must map to server-abc"
1018 );
1019 }
1020
1021 /// Oracle: RFC 8620 §3.4 — createdIds omitted when no objects were created.
1022 #[tokio::test]
1023 async fn created_ids_absent_when_no_set() {
1024 let mut d: Dispatcher<String> = Dispatcher::new();
1025 d.register("Foo/get", Arc::new(EchoHandler(json!({"list": []}))));
1026 let req = single_call("Foo/get", json!({}), "c0");
1027 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1028 assert!(
1029 resp.created_ids.is_none(),
1030 "created_ids must be None when no /set call created objects"
1031 );
1032 }
1033
1034 /// Oracle: RFC 8620 §3.3 — createdIds accumulates across ALL /set calls in the batch.
1035 #[tokio::test]
1036 async fn created_ids_accumulated_across_multiple_set_calls() {
1037 let mut d: Dispatcher<String> = Dispatcher::new();
1038 d.register(
1039 "A/set",
1040 Arc::new(EchoHandler(json!({"created": {"cA": {"id": "sA"}}}))),
1041 );
1042 d.register(
1043 "B/set",
1044 Arc::new(EchoHandler(json!({"created": {"cB": {"id": "sB"}}}))),
1045 );
1046 // Client sends createdIds to signal it wants the response field.
1047 let req = JmapRequest::new(
1048 vec!["urn:ietf:params:jmap:core".into()],
1049 vec![
1050 ("A/set".into(), json!({}), "c0".into()),
1051 ("B/set".into(), json!({}), "c1".into()),
1052 ],
1053 Some(std::collections::HashMap::new()),
1054 );
1055 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1056 let ids = resp
1057 .created_ids
1058 .as_ref()
1059 .expect("created_ids must be Some when client sent createdIds");
1060 assert_eq!(
1061 ids.get(&Id::from("cA")),
1062 Some(&Id::from("sA")),
1063 "cA must be present"
1064 );
1065 assert_eq!(
1066 ids.get(&Id::from("cB")),
1067 Some(&Id::from("sB")),
1068 "cB must be present"
1069 );
1070 }
1071
1072 /// Oracle: RFC 8620 §3.4 — pre-populated client createdIds are preserved and
1073 /// new /set entries are merged in alongside them.
1074 #[tokio::test]
1075 async fn created_ids_merges_with_pre_populated_map() {
1076 let mut d: Dispatcher<String> = Dispatcher::new();
1077 d.register(
1078 "Foo/set",
1079 Arc::new(EchoHandler(
1080 json!({"created": {"client-new": {"id": "server-new"}}}),
1081 )),
1082 );
1083 // Client sends a pre-populated createdIds map.
1084 let mut initial = std::collections::HashMap::new();
1085 initial.insert(Id::from("client-old"), Id::from("server-old"));
1086 let req = JmapRequest::new(
1087 vec!["urn:ietf:params:jmap:core".into()],
1088 vec![("Foo/set".into(), json!({}), "c0".into())],
1089 Some(initial),
1090 );
1091 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1092 let ids = resp
1093 .created_ids
1094 .as_ref()
1095 .expect("created_ids must be Some when client sent createdIds");
1096 assert_eq!(
1097 ids.get(&Id::from("client-old")),
1098 Some(&Id::from("server-old")),
1099 "pre-populated entry must be preserved"
1100 );
1101 assert_eq!(
1102 ids.get(&Id::from("client-new")),
1103 Some(&Id::from("server-new")),
1104 "new /set entry must be merged in"
1105 );
1106 }
1107
1108 /// Oracle (bd:JMAP-jfia.3): when the client pre-populates
1109 /// `createdIds` with `X -> A` and a `/set` call in the same batch
1110 /// returns `X -> B` (`B != A`), the dispatcher applies last-write-
1111 /// wins semantics: the response carries `X -> B`, and the
1112 /// pre-populated `A` is dropped. This is surprising and the spec is
1113 /// ambiguous on which semantics is correct (RFC 8620 §3.4), but
1114 /// matches the intra-batch duplicate convention documented at the
1115 /// dispatch call site and avoids a wire-behaviour change in the
1116 /// canonical foundation crate. The test exists to catch a future
1117 /// refactor that silently flips the order to first-write-wins.
1118 #[tokio::test]
1119 async fn created_ids_pre_populated_collision_last_write_wins() {
1120 let mut d: Dispatcher<String> = Dispatcher::new();
1121 d.register(
1122 "Foo/set",
1123 Arc::new(EchoHandler(
1124 json!({"created": {"client-X": {"id": "server-B"}}}),
1125 )),
1126 );
1127 // Client pre-populates client-X -> server-A.
1128 let mut initial = std::collections::HashMap::new();
1129 initial.insert(Id::from("client-X"), Id::from("server-A"));
1130 let req = JmapRequest::new(
1131 vec!["urn:ietf:params:jmap:core".into()],
1132 vec![("Foo/set".into(), json!({}), "c0".into())],
1133 Some(initial),
1134 );
1135 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1136 let ids = resp
1137 .created_ids
1138 .as_ref()
1139 .expect("created_ids must be Some when client sent createdIds");
1140 assert_eq!(
1141 ids.get(&Id::from("client-X")),
1142 Some(&Id::from("server-B")),
1143 "last-write-wins: /set response overrides pre-populated entry"
1144 );
1145 assert_eq!(
1146 ids.len(),
1147 1,
1148 "no extra entries should appear from the collision"
1149 );
1150 }
1151
1152 /// Oracle (bd:JMAP-jfia.3): when two `/set` calls in the same batch
1153 /// report the same creationId with different values, the
1154 /// dispatcher applies last-write-wins: the second `/set` response's
1155 /// mapping is the one preserved in the final `createdIds` map. The
1156 /// existing dispatch call-site comment documents this convention
1157 /// (bd:JMAP-wlip.7); the test pins it.
1158 #[tokio::test]
1159 async fn created_ids_intra_batch_collision_last_write_wins() {
1160 let mut d: Dispatcher<String> = Dispatcher::new();
1161 d.register(
1162 "A/set",
1163 Arc::new(EchoHandler(json!({"created": {"cX": {"id": "sA"}}}))),
1164 );
1165 d.register(
1166 "B/set",
1167 Arc::new(EchoHandler(json!({"created": {"cX": {"id": "sB"}}}))),
1168 );
1169 let req = JmapRequest::new(
1170 vec!["urn:ietf:params:jmap:core".into()],
1171 vec![
1172 ("A/set".into(), json!({}), "c0".into()),
1173 ("B/set".into(), json!({}), "c1".into()),
1174 ],
1175 Some(std::collections::HashMap::new()),
1176 );
1177 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1178 let ids = resp
1179 .created_ids
1180 .as_ref()
1181 .expect("created_ids must be Some when client sent createdIds");
1182 assert_eq!(
1183 ids.get(&Id::from("cX")),
1184 Some(&Id::from("sB")),
1185 "last-write-wins: second /set call's mapping for cX preserved"
1186 );
1187 assert_eq!(
1188 ids.len(),
1189 1,
1190 "no extra entries should appear from the collision"
1191 );
1192 }
1193
1194 // -----------------------------------------------------------------------
1195 // CallerCtx
1196 // -----------------------------------------------------------------------
1197
1198 /// Oracle: PLAN.md CallerCtx design — caller value passed through to handler unchanged.
1199 #[tokio::test]
1200 async fn caller_ctx_passed_to_handler() {
1201 let captured = Arc::new(Mutex::new(None::<String>));
1202 let mut d: Dispatcher<String> = Dispatcher::new();
1203 d.register(
1204 "Foo/get",
1205 Arc::new(CaptureCallerHandler(Arc::clone(&captured))),
1206 );
1207 let req = single_call("Foo/get", json!({}), "c0");
1208 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1209 assert!(
1210 resp.method_responses[0].1.get("type").is_none(),
1211 "must succeed"
1212 );
1213 let got = captured
1214 .lock()
1215 .unwrap()
1216 .clone()
1217 .expect("handler was not called");
1218 assert_eq!(got, "alice", "caller must be passed through unchanged");
1219 }
1220
1221 /// Oracle: PLAN.md — CallerCtx = () must work (unit type as auth context).
1222 #[tokio::test]
1223 async fn unit_caller_ctx_works() {
1224 let mut d: Dispatcher<()> = Dispatcher::new();
1225 d.register("Foo/get", Arc::new(EchoHandler(json!({"ok": true}))));
1226 let req = single_call("Foo/get", json!({}), "c0");
1227 let resp = d.dispatch(req, (), "s0".into()).await;
1228 assert_eq!(resp.method_responses.len(), 1);
1229 assert!(
1230 resp.method_responses[0].1.get("type").is_none(),
1231 "must succeed with () caller"
1232 );
1233 }
1234
1235 // -----------------------------------------------------------------------
1236 // Extra invocations
1237 // -----------------------------------------------------------------------
1238
1239 /// A handler that returns both a primary response and one extra invocation.
1240 ///
1241 /// Models RFC 8621 §7.5 EmailSubmission/set with onSuccessUpdateEmail: the
1242 /// submission response is primary; the implied Email/set call is extra.
1243 struct ExtraInvocationHandler;
1244
1245 impl JmapHandler<String> for ExtraInvocationHandler {
1246 fn call(
1247 &self,
1248 _method: String,
1249 _call_id: String,
1250 _args: Value,
1251 _caller: String,
1252 ) -> HandlerFuture {
1253 Box::pin(async move {
1254 let primary = json!({"type": "primary"});
1255 let extra: Vec<Invocation> = vec![(
1256 "Extra/call".to_owned(),
1257 json!({"type": "extra"}),
1258 "x0".to_owned(),
1259 )];
1260 Ok((primary, extra))
1261 })
1262 }
1263 }
1264
1265 /// Oracle: handler returning extra invocations → both primary and extra appear in
1266 /// methodResponses in order (primary first, then extra).
1267 #[tokio::test]
1268 async fn extra_invocations_appended_after_primary() {
1269 let mut d: Dispatcher<String> = Dispatcher::new();
1270 d.register("Sub/set", Arc::new(ExtraInvocationHandler));
1271 let req = single_call("Sub/set", json!({}), "c0");
1272 let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1273
1274 assert_eq!(
1275 resp.method_responses.len(),
1276 2,
1277 "primary + 1 extra = 2 total invocations"
1278 );
1279 // First: the primary Sub/set response.
1280 assert_eq!(resp.method_responses[0].0, "Sub/set");
1281 assert_eq!(resp.method_responses[0].2, "c0");
1282 assert_eq!(resp.method_responses[0].1["type"], "primary");
1283 // Second: the appended extra invocation.
1284 assert_eq!(resp.method_responses[1].0, "Extra/call");
1285 assert_eq!(resp.method_responses[1].2, "x0");
1286 assert_eq!(resp.method_responses[1].1["type"], "extra");
1287 }
1288
1289 /// Oracle: ClosureHandler forwards CallerCtx to the closure.
1290 /// The closure receives the exact same value that was passed to dispatch().
1291 #[tokio::test]
1292 async fn closure_handler_forwards_caller() {
1293 #[derive(Clone)]
1294 struct Ctx(String);
1295
1296 struct DummyBackend;
1297
1298 // Use a shared capture to record what ctx the closure received.
1299 let received: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1300 let received_clone = Arc::clone(&received);
1301
1302 let handler: Arc<ClosureHandler<DummyBackend, Ctx>> = Arc::new(ClosureHandler::new(
1303 Arc::new(DummyBackend),
1304 move |_b: Arc<DummyBackend>, _call_id: String, _args: Value, ctx: Ctx| {
1305 let cap = Arc::clone(&received_clone);
1306 Box::pin(async move {
1307 *cap.lock().unwrap() = Some(ctx.0.clone());
1308 Ok((serde_json::json!({}), vec![]))
1309 })
1310 },
1311 ));
1312
1313 let ctx = Ctx("alice".to_owned());
1314 handler
1315 .call("Test/get".into(), "c1".into(), serde_json::json!({}), ctx)
1316 .await
1317 .expect("handler must succeed");
1318
1319 assert_eq!(
1320 received.lock().unwrap().as_deref(),
1321 Some("alice"),
1322 "CallerCtx must be forwarded to the closure"
1323 );
1324 }
1325
1326 /// Oracle: ClosureHandler implements JmapHandler<C> and can be
1327 /// registered with Dispatcher<C>.
1328 #[test]
1329 fn closure_handler_is_jmap_handler() {
1330 // Compile-time check: ClosureHandler<B, C> must satisfy JmapHandler<C>.
1331 fn assert_handler<C: Clone + Send + 'static, H: JmapHandler<C>>(_: &H) {}
1332
1333 struct DummyBackend;
1334 #[derive(Clone)]
1335 struct Ctx;
1336
1337 let h = ClosureHandler::new(Arc::new(DummyBackend), |_b, _ci, _a, _ctx| {
1338 Box::pin(async { Ok((serde_json::json!({}), vec![])) })
1339 });
1340 assert_handler::<Ctx, _>(&h);
1341 }
1342}