jmap_metadata_client/methods/mod.rs
1//! Typed JMAP Metadata method wrappers — response types, SessionClient,
2//! constants, and helpers.
3//!
4//! Response types mirror RFC 8620 standard shapes (§5.1 /get, §5.5 /query,
5//! §5.2 /changes, §5.3 /set, §5.6 /queryChanges). Method implementations
6//! live in sub-modules and operate on `SessionClient`.
7
8pub mod metadata;
9
10// ---------------------------------------------------------------------------
11// Response types (RFC 8620 §5)
12// ---------------------------------------------------------------------------
13//
14// Re-exported from `jmap-types::methods` so all `jmap-*-client` crates share
15// one canonical set of /get, /set, /changes, /query, /queryChanges shapes.
16// The wire format is identical to the previous local definitions.
17
18pub use jmap_types::{
19 AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
20 SetResponse,
21};
22
23// ---------------------------------------------------------------------------
24// Method-level argument structs (draft-ietf-jmap-metadata-01 §3 + workspace
25// extras-preservation policy)
26// ---------------------------------------------------------------------------
27
28/// Extra method-level arguments for `Metadata/get`
29/// (draft-ietf-jmap-metadata-01 §3.2).
30///
31/// Draft-01 defines no method-specific args on `Metadata/get` beyond the
32/// RFC 8620 §5.1 standard set; this struct carries only the vendor /
33/// site / private-extension `extra` flatten field. Future draft revisions
34/// or vendor extensions add typed knobs without a breaking signature change
35/// because `metadata_get` accepts `Option<MetadataGetParams>`.
36#[derive(Debug, Default, Clone, serde::Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct MetadataGetParams {
39 /// Catch-all for vendor / site / private extension fields. Preserves
40 /// unknown fields across deserialize/serialize round-trip per workspace
41 /// extras-preservation policy (see workspace AGENTS.md).
42 ///
43 /// Keys MUST NOT collide with the standard RFC 8620 §5.1 arg names
44 /// (`accountId`, `ids`, `properties`); `metadata_get` will silently
45 /// retain the typed-field value if an extras key collides.
46 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
47 pub extra: serde_json::Map<String, serde_json::Value>,
48}
49
50/// Extra method-level arguments for `Metadata/set`
51/// (draft-ietf-jmap-metadata-01 §3.1).
52///
53/// Draft-01 defines no method-specific args on `Metadata/set` beyond the
54/// RFC 8620 §5.3 standard set; this struct carries only the vendor /
55/// site / private-extension `extra` flatten field. The `if_in_state`
56/// argument is passed as a positional parameter to `metadata_set`,
57/// mirroring the canonical `email_set` shape.
58#[derive(Debug, Default, Clone, serde::Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct MetadataSetParams {
61 /// Catch-all for vendor / site / private extension fields. Preserves
62 /// unknown fields across deserialize/serialize round-trip per workspace
63 /// extras-preservation policy (see workspace AGENTS.md).
64 ///
65 /// Keys MUST NOT collide with the standard RFC 8620 §5.3 arg names
66 /// (`accountId`, `ifInState`, `create`, `update`, `destroy`);
67 /// `metadata_set` will silently retain the typed-field value if an
68 /// extras key collides.
69 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
70 pub extra: serde_json::Map<String, serde_json::Value>,
71}
72
73/// Extra method-level arguments for `Metadata/changes`
74/// (draft-ietf-jmap-metadata-01 §3.3).
75///
76/// Both filter fields are optional. Pass `None` (or `Default::default()`)
77/// to fetch all Metadata changes regardless of `relatedType` or `@type`.
78///
79/// Per §3.3, when both filters are specified the server MUST return only
80/// changes to Metadata objects that satisfy both criteria (logical AND).
81/// The `state` token returned in the response represents the complete
82/// account state and is independent of the filters; clients that use these
83/// filters MUST re-use the same filter values across subsequent
84/// `Metadata/changes` calls to ensure consistent synchronisation.
85#[derive(Debug, Default, Clone, serde::Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct MetadataChangesParams {
88 /// Restrict the `created`/`updated`/`destroyed` arrays in the response
89 /// to Metadata objects whose `relatedType` equals this value
90 /// (draft-ietf-jmap-metadata-01 §3.3).
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub filter_related_type: Option<String>,
93
94 /// Restrict the `created`/`updated`/`destroyed` arrays in the response
95 /// to Metadata objects whose `@type` is in this list
96 /// (draft-ietf-jmap-metadata-01 §3.3).
97 ///
98 /// `None` disables filtering on `@type` (all types are returned).
99 /// `Some(vec![])` is a legal empty array meaning "match no `@type`
100 /// values" per draft §3.3 — the server returns empty `created` /
101 /// `updated` / `destroyed` arrays regardless of actual changes. If
102 /// you mean "return everything", use `None`, not `Some(vec![])`.
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub filter_metadata_type: Option<Vec<String>>,
105
106 /// Catch-all for vendor / site / private extension fields not covered
107 /// by the typed fields above. Preserves unknown fields across
108 /// deserialize/serialize round-trip per workspace extras-preservation
109 /// policy (see workspace AGENTS.md).
110 ///
111 /// **Constraint**: keys in `extra` MUST NOT collide with the
112 /// typed-field wire names above (the camelCase spelling — e.g.
113 /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
114 /// `"fromAccountId"`, etc.). On collision the typed-field value
115 /// wins on the wire and the `extra` value is silently dropped at
116 /// serialization. Place vendor extensions under vendor-prefixed
117 /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
118 ///
119 /// Keys MUST NOT collide with the standard RFC 8620 §5.2 / draft-01
120 /// §3.3 arg names (`accountId`, `sinceState`, `maxChanges`,
121 /// `filterRelatedType`, `filterMetadataType`); `metadata_changes` will
122 /// silently retain the typed-field value if an extras key collides.
123 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
124 pub extra: serde_json::Map<String, serde_json::Value>,
125}
126
127/// Extra method-level arguments for `Metadata/query`
128/// (draft-ietf-jmap-metadata-01 §3.4).
129///
130/// Draft-01 defines no method-specific args on `Metadata/query` beyond the
131/// RFC 8620 §5.5 standard set; this struct carries only the vendor /
132/// site / private-extension `extra` flatten field.
133#[derive(Debug, Default, Clone, serde::Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct MetadataQueryParams {
136 /// Catch-all for vendor / site / private extension fields. Preserves
137 /// unknown fields across deserialize/serialize round-trip per workspace
138 /// extras-preservation policy (see workspace AGENTS.md).
139 ///
140 /// Keys MUST NOT collide with the standard RFC 8620 §5.5 arg names
141 /// (`accountId`, `filter`, `sort`, `position`, `limit`, etc.);
142 /// `metadata_query` will silently retain the typed-field value if an
143 /// extras key collides.
144 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
145 pub extra: serde_json::Map<String, serde_json::Value>,
146}
147
148/// Extra method-level arguments for `Metadata/queryChanges`
149/// (draft-ietf-jmap-metadata-01 §3.5).
150///
151/// Draft-01 defines no method-specific args on `Metadata/queryChanges`
152/// beyond the RFC 8620 §5.6 standard set; this struct carries only the
153/// vendor / site / private-extension `extra` flatten field.
154#[derive(Debug, Default, Clone, serde::Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct MetadataQueryChangesParams {
157 /// Catch-all for vendor / site / private extension fields. Preserves
158 /// unknown fields across deserialize/serialize round-trip per workspace
159 /// extras-preservation policy (see workspace AGENTS.md).
160 ///
161 /// Keys MUST NOT collide with the standard RFC 8620 §5.6 arg names
162 /// (`accountId`, `sinceQueryState`, `maxChanges`, etc.);
163 /// `metadata_query_changes` will silently retain the typed-field value
164 /// if an extras key collides.
165 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
166 pub extra: serde_json::Map<String, serde_json::Value>,
167}
168
169// ---------------------------------------------------------------------------
170// Constants
171// ---------------------------------------------------------------------------
172
173/// The call-id embedded in every single-method JMAP request produced by
174/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
175pub(crate) const CALL_ID: &str = "r1";
176
177/// Capability URIs for JMAP Metadata method calls
178/// (draft-ietf-jmap-metadata-01 §1.2).
179pub(crate) const USING_METADATA: &[&str] = &[
180 "urn:ietf:params:jmap:core",
181 jmap_metadata_types::JMAP_METADATA_URI,
182];
183
184// ---------------------------------------------------------------------------
185// build_request helper
186// ---------------------------------------------------------------------------
187
188/// Build a single-method JMAP request.
189///
190/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
191/// Use the pre-defined constant [`USING_METADATA`] for standard calls.
192///
193/// The embedded call-id is [`CALL_ID`]; pass it directly to
194/// `jmap_base_client::extract_response`.
195pub(crate) fn build_request(
196 method: &str,
197 args: serde_json::Value,
198 using: &[&str],
199) -> jmap_types::JmapRequest {
200 let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
201 let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
202 jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
203}
204
205// ---------------------------------------------------------------------------
206// SessionClient — session-bound client
207// ---------------------------------------------------------------------------
208
209/// A `JmapClient` bound to a JMAP session.
210///
211/// Obtain via [`JmapMetadataExt::with_metadata_session`](crate::JmapMetadataExt::with_metadata_session).
212/// All JMAP Metadata methods are available on this type without needing to
213/// pass `&Session` on every call.
214///
215/// # Session lifecycle
216///
217/// `SessionClient` captures the `Session` at construction time. After
218/// re-fetching the session via `JmapClient::fetch_session`, construct a new
219/// `SessionClient` with the updated session. Reusing a stale `SessionClient`
220/// after session expiry will result in `unknownAccount` or similar errors
221/// from the server.
222///
223/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
224/// already implements `Clone` and `with_metadata_session` clones one
225/// internally), enabling parallel-task fan-out with one bound session.
226///
227/// `Debug` is implemented manually to redact the inner `JmapClient` (which
228/// holds an HTTP client and is intentionally not `Debug` in
229/// `jmap-base-client`); only the `Session` is shown. This lets callers
230/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
231/// impls of their own.
232///
233/// # Thread safety
234///
235/// `SessionClient` is `Send + Sync`. Both
236/// [`jmap_base_client::JmapClient`] (backed by `reqwest::Client`) and
237/// [`jmap_base_client::Session`] (plain serde-derived data) are
238/// `Send + Sync` per jmap-base-client's contract, so this type can be
239/// shared across async tasks via `Arc<SessionClient>` or cloned for
240/// per-task ownership.
241///
242/// A `Send + Sync` regression in a future jmap-base-client release
243/// would be a major-version-breaking change for this crate. A
244/// compile-time assertion in `methods/mod.rs` guards against the
245/// regression landing silently — see
246/// `_assert_session_client_send_sync`.
247#[non_exhaustive]
248#[derive(Clone)]
249pub struct SessionClient {
250 pub(crate) client: jmap_base_client::JmapClient,
251 pub(crate) session: jmap_base_client::Session,
252}
253
254impl std::fmt::Debug for SessionClient {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.debug_struct("SessionClient")
257 // The inner JmapClient is not Debug — show a placeholder so
258 // callers know it is present without leaking HTTP-client
259 // internals.
260 .field("client", &"<JmapClient>")
261 .field("session", &self.session)
262 .finish()
263 }
264}
265
266impl SessionClient {
267 /// Borrow the underlying [`JmapClient`](jmap_base_client::JmapClient).
268 ///
269 /// Useful for ad-hoc operations outside the typed JMAP method surface —
270 /// for example, calling `JmapClient::upload` / `JmapClient::download_blob`,
271 /// or constructing a `JmapClient::event_source` subscription using the
272 /// bound session's `event_source_url`.
273 pub fn client(&self) -> &jmap_base_client::JmapClient {
274 &self.client
275 }
276
277 /// Borrow the captured [`Session`](jmap_base_client::Session).
278 ///
279 /// `SessionClient` captures the `Session` at construction time. After
280 /// re-fetching the session via `JmapClient::fetch_session`, callers
281 /// should construct a new `SessionClient`. This accessor lets a caller
282 /// compare the captured session's `state` field against a freshly
283 /// fetched session to detect staleness, or inspect
284 /// `accountCapabilities` / `primary_accounts` for capability-specific
285 /// metadata not exposed via the typed JMAP method surface.
286 pub fn session(&self) -> &jmap_base_client::Session {
287 &self.session
288 }
289
290 /// Return the primary account id for `urn:ietf:params:jmap:metadata`,
291 /// or `Err(InvalidSession)` if the session has no primary account for
292 /// that capability.
293 pub fn metadata_account_id(&self) -> Result<&str, jmap_base_client::ClientError> {
294 self.session
295 .primary_account_id(jmap_metadata_types::JMAP_METADATA_URI)
296 .ok_or_else(|| {
297 jmap_base_client::ClientError::InvalidSession(
298 "no primary account for urn:ietf:params:jmap:metadata".into(),
299 )
300 })
301 }
302
303 /// Extract `(api_url, metadata_account_id)` from the bound session.
304 ///
305 /// Returns `Err(InvalidSession)` if there is no primary account for
306 /// `urn:ietf:params:jmap:metadata`.
307 pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
308 let api_url = self.session.api_url.as_str();
309 let account_id = self
310 .session
311 .primary_account_id(jmap_metadata_types::JMAP_METADATA_URI)
312 .ok_or_else(|| {
313 jmap_base_client::ClientError::InvalidSession(
314 "no primary account for urn:ietf:params:jmap:metadata".into(),
315 )
316 })?;
317 Ok((api_url, account_id))
318 }
319
320 /// Forward a JMAP request to the underlying HTTP client.
321 pub(crate) async fn call_internal(
322 &self,
323 api_url: &str,
324 req: &jmap_types::JmapRequest,
325 ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
326 self.client.call(api_url, req).await
327 }
328}
329
330/// Compile-time assertion that [`SessionClient`] is `Send + Sync`.
331///
332/// The `# Thread safety` section of [`SessionClient`]'s rustdoc promises
333/// auto-trait inheritance from
334/// [`jmap_base_client::JmapClient`] and
335/// [`jmap_base_client::Session`]. If a future jmap-base-client release
336/// adds a `!Sync` interior-mutability field to either, this assertion
337/// fails at compile time — flagging the regression at the dependency
338/// upgrade rather than at the downstream caller's "cannot send between
339/// threads safely" error.
340#[allow(dead_code)]
341fn _assert_session_client_send_sync() {
342 fn assert_send_sync<T: Send + Sync>() {}
343 assert_send_sync::<SessionClient>();
344}
345
346// ---------------------------------------------------------------------------
347// Tests
348// ---------------------------------------------------------------------------
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use serde_json::json;
354
355 /// Oracle: USING_METADATA contains exactly the two capability URIs from
356 /// draft-ietf-jmap-metadata-01 §1.2.
357 /// Expected values are taken directly from the draft.
358 #[test]
359 fn using_metadata_contains_correct_uris() {
360 let req = build_request("Metadata/get", json!({}), USING_METADATA);
361 let v = serde_json::to_value(&req).expect("serialize");
362 let using = v["using"].as_array().expect("using must be array");
363 assert_eq!(using.len(), 2, "must have exactly 2 capability URIs");
364 assert!(
365 using.contains(&json!("urn:ietf:params:jmap:core")),
366 "must include jmap:core"
367 );
368 assert!(
369 using.contains(&json!("urn:ietf:params:jmap:metadata")),
370 "must include jmap:metadata"
371 );
372 }
373
374 /// Oracle: build_request produces correct method name and CALL_ID.
375 /// Expected: invocation[0] == method, invocation[2] == CALL_ID constant.
376 #[test]
377 fn build_request_method_name_and_call_id() {
378 let req = build_request(
379 "Metadata/get",
380 json!({"accountId": "acc1", "ids": null}),
381 USING_METADATA,
382 );
383 let v = serde_json::to_value(&req).expect("serialize JmapRequest");
384
385 let calls = v["methodCalls"]
386 .as_array()
387 .expect("methodCalls must be array");
388 assert_eq!(calls.len(), 1, "must have exactly 1 method call");
389 assert_eq!(calls[0][0], json!("Metadata/get"), "method name must match");
390 assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
391 }
392
393 /// Oracle: session_parts returns None when metadata capability absent.
394 #[test]
395 fn session_parts_err_no_primary_account() {
396 let session_json = json!({
397 "capabilities": {},
398 "accounts": {},
399 "primaryAccounts": {},
400 "username": "user@example.com",
401 "apiUrl": "https://jmap.example.com/api/",
402 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
403 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
404 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
405 "state": "s1"
406 });
407 let session: jmap_base_client::Session =
408 serde_json::from_value(session_json).expect("session must deserialize");
409 let result = session.primary_account_id("urn:ietf:params:jmap:metadata");
410 assert!(
411 result.is_none(),
412 "must return None when metadata capability is not in primaryAccounts"
413 );
414 }
415
416 /// Oracle: GetResponse<T> deserializes from RFC 8620 §5.1 shape.
417 #[test]
418 fn get_response_deserializes() {
419 let json = json!({
420 "accountId": "acc1",
421 "state": "s42",
422 "list": [],
423 "notFound": ["missing1"]
424 });
425 let resp: GetResponse<serde_json::Value> =
426 serde_json::from_value(json).expect("GetResponse must deserialize");
427 assert_eq!(resp.account_id, "acc1");
428 assert_eq!(resp.state, "s42");
429 assert!(resp.list.is_empty());
430 assert_eq!(
431 resp.not_found.as_deref(),
432 Some(["missing1".into()].as_slice())
433 );
434 }
435
436 /// Oracle: ChangesResponse deserializes from RFC 8620 §5.2 shape.
437 #[test]
438 fn changes_response_deserializes() {
439 let json = json!({
440 "accountId": "acc1",
441 "oldState": "s10",
442 "newState": "s11",
443 "hasMoreChanges": false,
444 "created": ["id1"],
445 "updated": ["id2"],
446 "destroyed": []
447 });
448 let resp: ChangesResponse =
449 serde_json::from_value(json).expect("ChangesResponse must deserialize");
450 assert_eq!(resp.old_state, "s10");
451 assert_eq!(resp.new_state, "s11");
452 assert!(!resp.has_more_changes);
453 assert_eq!(resp.created.len(), 1);
454 assert_eq!(resp.updated.len(), 1);
455 assert!(resp.destroyed.is_empty());
456 }
457
458 /// Oracle: SetResponse deserializes from RFC 8620 §5.3 shape.
459 #[test]
460 fn set_response_deserializes() {
461 let json = json!({
462 "accountId": "acc1",
463 "oldState": "s10",
464 "newState": "s11",
465 "created": null,
466 "updated": null,
467 "destroyed": ["id1"],
468 "notCreated": null,
469 "notUpdated": null,
470 "notDestroyed": null
471 });
472 let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
473 assert_eq!(resp.new_state, "s11");
474 assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
475 }
476
477 /// Oracle: QueryChangesResponse deserializes from RFC 8620 §5.6 shape.
478 #[test]
479 fn query_changes_response_deserializes() {
480 let json = json!({
481 "accountId": "acc1",
482 "oldQueryState": "qs1",
483 "newQueryState": "qs2",
484 "total": 5,
485 "removed": ["id3"],
486 "added": [{"id": "id4", "index": 0}]
487 });
488 let resp: QueryChangesResponse =
489 serde_json::from_value(json).expect("QueryChangesResponse must deserialize");
490 assert_eq!(resp.old_query_state, "qs1");
491 assert_eq!(resp.new_query_state, "qs2");
492 assert_eq!(resp.total, Some(5));
493 assert_eq!(resp.removed.len(), 1);
494 assert_eq!(resp.added.len(), 1);
495 assert_eq!(resp.added[0].index, 0);
496 }
497
498 /// Oracle: MetadataChangesParams default (all None) serializes to `{}`.
499 /// Expected: skip_serializing_if omits both None fields.
500 #[test]
501 fn metadata_changes_params_default_is_empty_object() {
502 let params = MetadataChangesParams::default();
503 let v = serde_json::to_value(¶ms).expect("serialize");
504 assert_eq!(
505 v,
506 json!({}),
507 "default params must serialize to empty object"
508 );
509 }
510
511 /// Oracle: MetadataChangesParams serializes filterRelatedType in camelCase.
512 /// Expected field name from draft-ietf-jmap-metadata-01 §3.3.
513 #[test]
514 fn metadata_changes_params_serializes_filter_related_type() {
515 let params = MetadataChangesParams {
516 filter_related_type: Some("Email".into()),
517 filter_metadata_type: None,
518 extra: serde_json::Map::new(),
519 };
520 let v = serde_json::to_value(¶ms).expect("serialize");
521 assert_eq!(
522 v["filterRelatedType"],
523 json!("Email"),
524 "filterRelatedType must serialize in camelCase"
525 );
526 assert!(
527 v.get("filterMetadataType").is_none(),
528 "filterMetadataType must be absent when None"
529 );
530 }
531
532 /// Oracle: MetadataChangesParams serializes filterMetadataType array.
533 /// Expected field name from draft-ietf-jmap-metadata-01 §3.3.
534 #[test]
535 fn metadata_changes_params_serializes_filter_metadata_type() {
536 let params = MetadataChangesParams {
537 filter_related_type: None,
538 filter_metadata_type: Some(vec!["Annotation".into(), "ImapMetadata".into()]),
539 extra: serde_json::Map::new(),
540 };
541 let v = serde_json::to_value(¶ms).expect("serialize");
542 assert!(
543 v.get("filterRelatedType").is_none(),
544 "filterRelatedType must be absent when None"
545 );
546 assert_eq!(
547 v["filterMetadataType"],
548 json!(["Annotation", "ImapMetadata"]),
549 "filterMetadataType must serialize as array"
550 );
551 }
552
553 // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
554 //
555 // For Serialize-only method-argument structs, the test constructs a
556 // struct with a vendor field in `extra` and asserts that the field
557 // flattens into the serialized JSON. Uses synthetic `acmeCorp*` keys
558 // that are guaranteed not to appear in any draft-ietf-jmap-metadata
559 // typed field — so the tests are independent of the crate under test.
560
561 /// `MetadataChangesParams.extra` flattens into serialized JSON.
562 #[test]
563 fn metadata_changes_params_propagates_vendor_extras() {
564 let mut params = MetadataChangesParams::default();
565 params
566 .extra
567 .insert("acmeCorpCursor".into(), json!("opaque-token"));
568 let v = serde_json::to_value(¶ms).expect("serialize MetadataChangesParams");
569 assert_eq!(v["acmeCorpCursor"], json!("opaque-token"));
570 }
571
572 /// `MetadataGetParams::default()` serializes to an empty object.
573 /// Oracle: `skip_serializing_if = serde_json::Map::is_empty` on the
574 /// `extra` flatten field.
575 #[test]
576 fn metadata_get_params_default_is_empty_object() {
577 let v = serde_json::to_value(MetadataGetParams::default())
578 .expect("serialize MetadataGetParams");
579 assert_eq!(v, json!({}));
580 }
581
582 /// `MetadataGetParams.extra` flattens into serialized JSON.
583 #[test]
584 fn metadata_get_params_propagates_vendor_extras() {
585 let mut params = MetadataGetParams::default();
586 params.extra.insert("acmeCorpAuditFlag".into(), json!(true));
587 let v = serde_json::to_value(¶ms).expect("serialize MetadataGetParams");
588 assert_eq!(v["acmeCorpAuditFlag"], json!(true));
589 }
590
591 /// `MetadataSetParams::default()` serializes to an empty object.
592 #[test]
593 fn metadata_set_params_default_is_empty_object() {
594 let v = serde_json::to_value(MetadataSetParams::default())
595 .expect("serialize MetadataSetParams");
596 assert_eq!(v, json!({}));
597 }
598
599 /// `MetadataSetParams.extra` flattens into serialized JSON.
600 #[test]
601 fn metadata_set_params_propagates_vendor_extras() {
602 let mut params = MetadataSetParams::default();
603 params.extra.insert("acmeCorpAuditFlag".into(), json!(true));
604 let v = serde_json::to_value(¶ms).expect("serialize MetadataSetParams");
605 assert_eq!(v["acmeCorpAuditFlag"], json!(true));
606 }
607
608 /// `MetadataQueryParams::default()` serializes to an empty object.
609 #[test]
610 fn metadata_query_params_default_is_empty_object() {
611 let v = serde_json::to_value(MetadataQueryParams::default())
612 .expect("serialize MetadataQueryParams");
613 assert_eq!(v, json!({}));
614 }
615
616 /// `MetadataQueryParams.extra` flattens into serialized JSON.
617 #[test]
618 fn metadata_query_params_propagates_vendor_extras() {
619 let mut params = MetadataQueryParams::default();
620 params
621 .extra
622 .insert("acmeCorpAnchor".into(), json!("MD-cursor-1"));
623 let v = serde_json::to_value(¶ms).expect("serialize MetadataQueryParams");
624 assert_eq!(v["acmeCorpAnchor"], json!("MD-cursor-1"));
625 }
626
627 /// `MetadataQueryChangesParams::default()` serializes to an empty object.
628 #[test]
629 fn metadata_query_changes_params_default_is_empty_object() {
630 let v = serde_json::to_value(MetadataQueryChangesParams::default())
631 .expect("serialize MetadataQueryChangesParams");
632 assert_eq!(v, json!({}));
633 }
634
635 /// `MetadataQueryChangesParams.extra` flattens into serialized JSON.
636 #[test]
637 fn metadata_query_changes_params_propagates_vendor_extras() {
638 let mut params = MetadataQueryChangesParams::default();
639 params.extra.insert("acmeCorpUpTo".into(), json!("MD-99"));
640 let v = serde_json::to_value(¶ms).expect("serialize MetadataQueryChangesParams");
641 assert_eq!(v["acmeCorpUpTo"], json!("MD-99"));
642 }
643}