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#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct Quota {
24 /// Server-assigned identifier.
25 pub id: Id,
26 /// Human-readable name for this quota (e.g. `"Message Storage"`).
27 pub name: String,
28 /// Scope of the quota: `"account"`, `"domain"`, or `"global"`.
29 pub scope: crate::types::QuotaScope,
30 /// Resource type: `"octets"` (byte-based) or `"count"` (object-count-based).
31 pub resource_type: String,
32 /// Data type names covered by this quota (e.g. `["Message", "Chat"]`).
33 pub types: Vec<String>,
34 /// Bytes currently consumed.
35 pub used: u64,
36 /// Hard limit in bytes; requests that would exceed this MUST fail.
37 pub hard_limit: u64,
38 /// Warning threshold in bytes; clients SHOULD warn the user above this.
39 #[serde(default)]
40 pub warn_limit: Option<u64>,
41 /// Soft limit in bytes (server may begin rejecting requests above this).
42 #[serde(default)]
43 pub soft_limit: Option<u64>,
44 /// Optional human-readable description.
45 #[serde(default)]
46 pub description: Option<String>,
47 /// Catch-all for vendor / site / private extension fields not covered
48 /// by the typed fields above. Preserves unknown fields across
49 /// deserialize/serialize round-trip per workspace extras-preservation
50 /// policy (see workspace AGENTS.md).
51 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
52 pub extra: serde_json::Map<String, serde_json::Value>,
53}
54
55impl super::SessionClient {
56 /// Fetch all Quota objects for the account (RFC 9425 §4.2 Quota/get).
57 ///
58 /// Returns all quota records for the primary JMAP Chat account. Each
59 /// [`Quota`] includes `used`, `hard_limit`, and optional `warn_limit` fields
60 /// that callers can use to display storage bars and warnings.
61 ///
62 /// The returned [`GetResponse::state`] token is preserved for
63 /// [`quota_changes`](Self::quota_changes) delta-sync support.
64 ///
65 /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
66 /// returns `true`. Returns `ClientError::InvalidSession` if the session
67 /// has no primary JMAP Chat account.
68 pub async fn quota_get(&self) -> Result<GetResponse<Quota>, jmap_base_client::ClientError> {
69 let (api_url, account_id) = self.session_parts()?;
70 let args = serde_json::json!({
71 "accountId": account_id,
72 "ids": serde_json::Value::Null,
73 });
74 let req = super::build_request("Quota/get", args, super::USING_QUOTA);
75 let resp = self.call_internal(api_url, &req).await?;
76 jmap_base_client::extract_response(&resp, super::CALL_ID)
77 }
78
79 /// Fetch changes to Quota objects since `since_state` (RFC 8620 §5.2 / Quota/changes).
80 ///
81 /// Returns ids of Quota objects created, updated, or destroyed since the
82 /// caller-supplied `since_state` token (typically the
83 /// [`GetResponse::state`] returned by an earlier
84 /// [`quota_get`](Self::quota_get) call).
85 ///
86 /// If [`ChangesResponse::has_more_changes`] is `true`, call again with
87 /// [`ChangesResponse::new_state`] as `since_state` until the flag is
88 /// `false`.
89 ///
90 /// `max_changes` caps the number of ids the server returns in a single
91 /// response; `None` lets the server choose. Servers are not required to
92 /// honour a `max_changes` hint exactly.
93 ///
94 /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
95 /// returns `true`. Returns [`jmap_base_client::ClientError::InvalidArgument`]
96 /// if `since_state` is empty, or [`jmap_base_client::ClientError::InvalidSession`]
97 /// if the session has no primary JMAP Chat account.
98 pub async fn quota_changes(
99 &self,
100 since_state: &State,
101 max_changes: Option<u64>,
102 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
103 // Defence-in-depth: even with the typed-`State` parameter (a transparent
104 // newtype around `String`), an empty state token is still a logically
105 // invalid value that should be caught client-side rather than producing
106 // a confusing server-side `cannotCalculateChanges` error.
107 if since_state.as_ref().is_empty() {
108 return Err(jmap_base_client::ClientError::InvalidArgument(
109 "quota_changes: since_state may not be empty".into(),
110 ));
111 }
112 let (api_url, account_id) = self.session_parts()?;
113 let mut args = serde_json::json!({
114 "accountId": account_id,
115 "sinceState": since_state,
116 });
117 if let Some(mc) = max_changes {
118 args["maxChanges"] = mc.into();
119 }
120 let req = super::build_request("Quota/changes", args, super::USING_QUOTA);
121 let resp = self.call_internal(api_url, &req).await?;
122 jmap_base_client::extract_response(&resp, super::CALL_ID)
123 }
124}
125
126// ---------------------------------------------------------------------------
127// Tests
128// ---------------------------------------------------------------------------
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use serde_json::json;
134
135 // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
136 //
137 // The test deserialises wire JSON containing a synthetic `acmeCorp*`
138 // vendor field and asserts it survives in `extra`. The vendor field
139 // name cannot collide with any field defined in RFC 9425 §4, so the
140 // test is independent of the code under test (workspace
141 // test-integrity rule).
142
143 /// `Quota.extra` captures unknown fields on deserialize.
144 #[test]
145 fn quota_preserves_vendor_extras() {
146 let raw = json!({
147 "id": "Q1",
148 "name": "Message Storage",
149 "scope": "account",
150 "resourceType": "octets",
151 "types": ["Message"],
152 "used": 1024,
153 "hardLimit": 1048576,
154 "acmeCorpBillingTier": "enterprise"
155 });
156 let obj: Quota = serde_json::from_value(raw).expect("Quota must deserialize");
157 assert_eq!(
158 obj.extra
159 .get("acmeCorpBillingTier")
160 .and_then(|v| v.as_str()),
161 Some("enterprise")
162 );
163 }
164}