jmap_chat_client/methods/quota.rs
1//! Quota/get — urn:ietf:params:jmap:quota
2//!
3//! Retrieves storage quota information from the server. Only call when
4//! `ChatSessionExt::supports_quotas()` returns true.
5//!
6//! Spec: RFC 9425
7
8use serde::Deserialize;
9
10use jmap_types::{Id, State};
11
12use super::{ChangesResponse, GetResponse};
13
14/// A single JMAP Quota object (RFC 9425 §4).
15///
16/// Describes a storage limit that applies to one or more data types within
17/// a given scope. Poll with [`SessionClient::quota_get`] to display storage
18/// usage in the UI and warn the user when approaching limits.
19///
20/// [`SessionClient::quota_get`]: super::SessionClient::quota_get
21#[non_exhaustive]
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Quota {
25 /// Server-assigned identifier.
26 pub id: Id,
27 /// Human-readable name for this quota (e.g. `"Message Storage"`).
28 pub name: String,
29 /// Scope of the quota: `"account"`, `"domain"`, or `"global"`.
30 pub scope: crate::types::QuotaScope,
31 /// Resource type — `Count` (object-count-based) or `Octets`
32 /// (byte-based) per RFC 9425 §3.2. Element type is
33 /// [`crate::types::QuotaResourceType`] so callers can match on
34 /// typed variants directly; unknown wire strings land in
35 /// `QuotaResourceType::Other(s)` per the `impl_string_enum!`
36 /// round-trip contract.
37 pub resource_type: crate::types::QuotaResourceType,
38 /// Data type names covered by this quota (e.g. `["Message", "Chat"]`).
39 ///
40 /// Element type is `String` rather than a typed `DataTypeName` enum
41 /// because no such enum exists in the workspace foundation today —
42 /// JMAP data-type names span every extension (RFC 8621 Email,
43 /// Mailbox, Thread; draft-atwood-jmap-chat Chat, Message, Space;
44 /// RFC 8984/9425 Calendars; RFC 9553 Contacts; etc.) and a
45 /// cross-cutting enum would belong in `jmap-types` rather than
46 /// any single extension. Compare against the literal wire-form
47 /// type name (e.g. `"Message"`, `"Chat"`), or against constants
48 /// the consuming application maintains.
49 pub types: Vec<String>,
50 /// Bytes currently consumed.
51 pub used: u64,
52 /// Hard limit in bytes; requests that would exceed this MUST fail.
53 pub hard_limit: u64,
54 /// Warning threshold in bytes; clients SHOULD warn the user above this.
55 #[serde(default)]
56 pub warn_limit: Option<u64>,
57 /// Soft limit in bytes (server may begin rejecting requests above this).
58 #[serde(default)]
59 pub soft_limit: Option<u64>,
60 /// Optional human-readable description.
61 #[serde(default)]
62 pub description: Option<String>,
63 /// Catch-all for vendor / site / private extension fields not covered
64 /// by the typed fields above. Preserves unknown fields across
65 /// deserialize/serialize round-trip per workspace extras-preservation
66 /// policy (see workspace AGENTS.md).
67 ///
68 /// **Constraint**: keys in `extra` MUST NOT collide with the
69 /// typed-field wire names above (the camelCase spelling — e.g.
70 /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
71 /// `"fromAccountId"`, etc.). On collision the typed-field value
72 /// wins on the wire and the `extra` value is silently dropped at
73 /// serialization. Place vendor extensions under vendor-prefixed
74 /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
75 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
76 pub extra: serde_json::Map<String, serde_json::Value>,
77}
78
79impl super::SessionClient {
80 /// Fetch all Quota objects for the account (RFC 9425 §4.2 Quota/get).
81 ///
82 /// Returns all quota records for the primary JMAP Chat account. Each
83 /// [`Quota`] includes `used`, `hard_limit`, and optional `warn_limit` fields
84 /// that callers can use to display storage bars and warnings.
85 ///
86 /// The returned [`GetResponse::state`] token is preserved for
87 /// [`quota_changes`](Self::quota_changes) delta-sync support.
88 ///
89 /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
90 /// returns `true`.
91 ///
92 /// # Errors
93 ///
94 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
95 /// if the bound session has no primary account for
96 /// `urn:ietf:params:jmap:chat`.
97 /// - Any transport / protocol variant returned by
98 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
99 /// [`Http`](jmap_base_client::ClientError::Http),
100 /// [`Parse`](jmap_base_client::ClientError::Parse),
101 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
102 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
103 /// (wraps RFC 8620 §3.6.2 method-level errors such as
104 /// `accountNotFound`, `invalidArguments`, `serverFail`; servers
105 /// that do not advertise `urn:ietf:params:jmap:quota` return
106 /// `unknownCapability`),
107 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
108 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
109 /// or
110 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
111 pub async fn quota_get(&self) -> Result<GetResponse<Quota>, jmap_base_client::ClientError> {
112 let (api_url, account_id) = self.session_parts()?;
113 let args = serde_json::json!({
114 "accountId": account_id,
115 "ids": serde_json::Value::Null,
116 });
117 let req = super::build_request("Quota/get", args, super::USING_QUOTA);
118 let resp = self.call_internal(api_url, &req).await?;
119 jmap_base_client::extract_response(&resp, super::CALL_ID)
120 }
121
122 /// Fetch changes to Quota objects since `since_state` (RFC 8620 §5.2 / Quota/changes).
123 ///
124 /// Returns ids of Quota objects created, updated, or destroyed since the
125 /// caller-supplied `since_state` token (typically the
126 /// [`GetResponse::state`] returned by an earlier
127 /// [`quota_get`](Self::quota_get) call).
128 ///
129 /// If [`ChangesResponse::has_more_changes`] is `true`, call again with
130 /// [`ChangesResponse::new_state`] as `since_state` until the flag is
131 /// `false`.
132 ///
133 /// `max_changes` caps the number of ids the server returns in a single
134 /// response; `None` lets the server choose. Servers are not required to
135 /// honour a `max_changes` hint exactly.
136 ///
137 /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
138 /// returns `true`.
139 ///
140 /// # Errors
141 ///
142 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
143 /// if `since_state` is the empty string (defence-in-depth —
144 /// `State` constructed via [`State::from`](jmap_types::State::from)
145 /// accepts empty strings, but an empty `sinceState` is never
146 /// useful).
147 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
148 /// if the bound session has no primary account for
149 /// `urn:ietf:params:jmap:chat`.
150 /// - Any transport / protocol variant returned by
151 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
152 /// the matching error list on [`Self::quota_get`].
153 pub async fn quota_changes(
154 &self,
155 since_state: &State,
156 max_changes: Option<u64>,
157 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
158 // Defence-in-depth: even with the typed-`State` parameter (a transparent
159 // newtype around `String`), an empty state token is still a logically
160 // invalid value that should be caught client-side rather than producing
161 // a confusing server-side `cannotCalculateChanges` error.
162 if since_state.as_ref().is_empty() {
163 return Err(jmap_base_client::ClientError::InvalidArgument(
164 "quota_changes: since_state may not be empty".into(),
165 ));
166 }
167 let (api_url, account_id) = self.session_parts()?;
168 let mut args = serde_json::json!({
169 "accountId": account_id,
170 "sinceState": since_state,
171 });
172 if let Some(mc) = max_changes {
173 args["maxChanges"] = mc.into();
174 }
175 let req = super::build_request("Quota/changes", args, super::USING_QUOTA);
176 let resp = self.call_internal(api_url, &req).await?;
177 jmap_base_client::extract_response(&resp, super::CALL_ID)
178 }
179}
180
181// ---------------------------------------------------------------------------
182// Tests
183// ---------------------------------------------------------------------------
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use serde_json::json;
189
190 // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
191 //
192 // The test deserialises wire JSON containing a synthetic `acmeCorp*`
193 // vendor field and asserts it survives in `extra`. The vendor field
194 // name cannot collide with any field defined in RFC 9425 §4, so the
195 // test is independent of the code under test (workspace
196 // test-integrity rule).
197
198 /// `Quota.extra` captures unknown fields on deserialize.
199 #[test]
200 fn quota_preserves_vendor_extras() {
201 let raw = json!({
202 "id": "Q1",
203 "name": "Message Storage",
204 "scope": "account",
205 "resourceType": "octets",
206 "types": ["Message"],
207 "used": 1024,
208 "hardLimit": 1048576,
209 "acmeCorpBillingTier": "enterprise"
210 });
211 let obj: Quota = serde_json::from_value(raw).expect("Quota must deserialize");
212 assert_eq!(
213 obj.extra
214 .get("acmeCorpBillingTier")
215 .and_then(|v| v.as_str()),
216 Some("enterprise")
217 );
218 }
219}