omni_dev/drive/docs/api.rs
1//! Docs v1 API façade — typed wrappers over the endpoints the CLI needs,
2//! mirroring `crate::drive::sheets::api::SheetsApi`'s shape.
3//!
4//! Free `build_*_url` functions take a literal `base_url` so they are
5//! unit-testable without a client, exactly like `sheets/api.rs`'s and
6//! `files_api.rs`'s.
7//!
8//! # Why there is no `fields` mask
9//!
10//! `spreadsheets.get` is always masked, because unmasked it embeds every
11//! cell of every sheet. The mirror-image decision here is the opposite one,
12//! and it is deliberate rather than an omission — there is a test pinning
13//! it. Three reasons:
14//!
15//! 1. **A Docs mask cannot be made recursion-safe.** A `fields` mask spells
16//! nesting depth out literally, and a table may contain a table to
17//! arbitrary depth. Any fixed-depth mask therefore *silently drops
18//! document text* below its deepest named level — partial content
19//! indistinguishable from complete content, which is exactly the failure
20//! mode `MAX_SHEETS_PER_READ` refuses rather than tolerates.
21//! 2. **It would have to be written twice.** With `includeTabsContent=true`
22//! the content lives under `tabs.documentTab.body.content…`, not
23//! `body.content…`, so the mask's root depends on a query parameter. Two
24//! masks that must agree is two masks that will diverge.
25//! 3. **The payload is bounded in a way a spreadsheet's is not.** Google
26//! caps a Doc at roughly a million characters, so the worst-case response
27//! is large but bounded; a spreadsheet's cell count has no comparable
28//! ceiling.
29//!
30//! [`MAX_DOCUMENT_BYTES`] is the guard that replaces it. If payload size
31//! ever does become a problem, a mask belongs on the `info` path **only**
32//! (which needs a shallow skeleton and no recursion) and must never be
33//! applied to `read`.
34
35use anyhow::{Context, Result};
36use url::Url;
37
38use crate::drive::api_client::GoogleApiClient;
39use crate::drive::docs::client::DocsClient;
40use crate::drive::docs::types::Document;
41use crate::drive::docs::write_types::{
42 BatchUpdateDocumentRequest, BatchUpdateDocumentResponse, DocsRequest,
43};
44use crate::drive::error::DriveError;
45use crate::drive::files_api::{append_write_scope_hint, WriteCapability};
46
47/// Maximum `documents.get` response accepted into memory.
48///
49/// Best-effort, and honestly so: Google gzips and frequently uses chunked
50/// transfer encoding, so `Content-Length` is often absent and there is then
51/// nothing to check up front — the same caveat
52/// `files_api.rs::check_download_size` already carries. The real bound is
53/// Google's own document size cap; this catches the case it can see.
54const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024;
55
56/// How the API should render pending suggestions.
57///
58/// Engine-layer, deliberately free of any `clap` derive — the CLI keeps its
59/// own `ValueEnum` mirror, the same split `ValueRenderOption` and
60/// `DriveOperation` use.
61///
62/// This is a correctness knob, not decoration: a document with pending
63/// suggestions has a different index space depending on the view, so which
64/// view a read reports against determines whether its indices mean anything
65/// to a subsequent edit.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum SuggestionsViewMode {
68 /// Whatever the caller's access level implies — suggestions inline for
69 /// an editor, accepted for a reader.
70 #[default]
71 DefaultForCurrentAccess,
72 /// Suggestions shown inline, as tracked changes.
73 Inline,
74 /// The document as it would be with every suggestion accepted.
75 PreviewAccepted,
76 /// The document as it would be with every suggestion rejected.
77 PreviewWithoutSuggestions,
78}
79
80impl SuggestionsViewMode {
81 /// The wire value for the `suggestionsViewMode` query parameter.
82 #[must_use]
83 pub const fn as_str(self) -> &'static str {
84 match self {
85 Self::DefaultForCurrentAccess => "DEFAULT_FOR_CURRENT_ACCESS",
86 Self::Inline => "SUGGESTIONS_INLINE",
87 Self::PreviewAccepted => "PREVIEW_SUGGESTIONS_ACCEPTED",
88 Self::PreviewWithoutSuggestions => "PREVIEW_WITHOUT_SUGGESTIONS",
89 }
90 }
91}
92
93/// Docs API façade.
94#[derive(Debug)]
95pub struct DocsApi<'a> {
96 client: &'a DocsClient,
97}
98
99impl<'a> DocsApi<'a> {
100 /// Wraps an existing [`DocsClient`].
101 #[must_use]
102 pub fn new(client: &'a DocsClient) -> Self {
103 Self { client }
104 }
105
106 /// Fetches a document's full structural model.
107 ///
108 /// Always sends `includeTabsContent=true`; see
109 /// [`build_document_get_url`]. Never `fields`-masked; see the module
110 /// docs.
111 pub async fn get_document(
112 &self,
113 document_id: &str,
114 suggestions: SuggestionsViewMode,
115 ) -> Result<Document> {
116 let url = build_document_get_url(self.client.base_url(), document_id, suggestions)?;
117 let response = self.client.transport().get_json(url.as_str()).await?;
118 check_document_size(response.content_length())?;
119 self.client
120 .transport()
121 .parse_response(response, "Failed to parse Docs document")
122 .await
123 }
124
125 /// Applies **one** request to a document, under a revision lease.
126 ///
127 /// The single mutating entry point for Docs, and every part of the
128 /// signature is load-bearing (ADR-0076 §3, §4):
129 ///
130 /// - It takes one [`DocsRequest`], not a `Vec`, so "one verb, one
131 /// request, one batch, one log record" is a signature rather than a
132 /// convention — and the batch-ordering hazard, where an insertion
133 /// shifts every later request's indices, cannot arise.
134 /// - `required_revision_id` is `&str`, not `Option<&str>`, so there is
135 /// no unleased path to reach for.
136 /// - `pub(in crate::drive)` keeps it behind the same visibility fence as
137 /// `FilesApi::create` and `SheetsApi::values_update`, so nothing
138 /// outside `crate::drive` — where every engine runs the gate first —
139 /// can even compile a call to it.
140 pub(in crate::drive) async fn batch_update(
141 &self,
142 document_id: &str,
143 request: DocsRequest,
144 required_revision_id: &str,
145 ) -> Result<BatchUpdateDocumentResponse> {
146 let url = build_batch_update_url(self.client.base_url(), document_id)?;
147 let body = BatchUpdateDocumentRequest::new(request, required_revision_id);
148 let response = self
149 .client
150 .transport()
151 .post_json(url.as_str(), &body)
152 .await?;
153 self.client
154 .transport()
155 .parse_response(response, "Failed to parse Docs batchUpdate response")
156 .await
157 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
158 }
159}
160
161/// Whether `err` is Google refusing a `batchUpdate` because the revision
162/// lease no longer matches.
163///
164/// **Confirmed against the live API** (ADR-0076 §6): a stale
165/// `requiredRevisionId` returns HTTP 400 with the `google.rpc` envelope,
166/// `error.status` of `INVALID_ARGUMENT` and the message
167/// `"The required revision ID '<id>' does not match the latest revision."`.
168///
169/// `INVALID_ARGUMENT` is **not** Docs-specific — it is the same status a
170/// malformed request carries — which is exactly why this matches the status
171/// code in conjunction with a message substring rather than trusting the
172/// status alone. Matching on the status would classify every malformed
173/// request as a lost lease and tell the user to re-run something that can
174/// never succeed.
175///
176/// The failure direction is safe either way. A false negative degrades to
177/// `WriteResult::Failed`, which still carries the server's own message
178/// verbatim; nothing was written on either path, because a one-request
179/// `batchUpdate` is atomic. There is no input for which this returns `true`
180/// and a write has happened.
181pub(in crate::drive) fn is_stale_revision(err: &anyhow::Error) -> bool {
182 /// The distinctive part of Google's message, lowercased for comparison.
183 const STALE_MARKER: &str = "does not match the latest revision";
184
185 matches!(
186 err.downcast_ref::<DriveError>(),
187 Some(DriveError::ApiRequestFailed { status: 400, body, .. })
188 if body.to_ascii_lowercase().contains(STALE_MARKER)
189 )
190}
191
192/// Refuses a `documents.get` whose declared `Content-Length` exceeds
193/// [`MAX_DOCUMENT_BYTES`]. A missing length is allowed through — there is
194/// nothing to check up front in that case.
195fn check_document_size(content_length: Option<u64>) -> Result<()> {
196 if let Some(len) = content_length {
197 anyhow::ensure!(
198 len <= MAX_DOCUMENT_BYTES,
199 "refusing to load {len} bytes into memory (limit: {MAX_DOCUMENT_BYTES} bytes); \
200 this document is too large for `drive docs read`"
201 );
202 }
203 Ok(())
204}
205
206/// Builds the `documents.get` URL.
207///
208/// `includeTabsContent=true` is unconditional, and that is the load-bearing
209/// choice. Without it a three-tab document returns only the first tab's
210/// content, in a response *shaped identically* to a one-tab document — so
211/// silently reading a third of a document is indistinguishable from reading
212/// all of a small one. This is the same failure `drive read --content`
213/// already has on a Sheet (first sheet only), and the reason `sheets read`
214/// exists. Narrowing is a client-side `--tab` filter applied after the
215/// fetch, so the full tab list is always known.
216fn build_document_get_url(
217 base_url: &str,
218 document_id: &str,
219 suggestions: SuggestionsViewMode,
220) -> Result<Url> {
221 let mut url =
222 GoogleApiClient::api_url(base_url, "/v1/documents").context("Invalid Docs base URL")?;
223 GoogleApiClient::push_path_segments(&mut url, &[document_id])?;
224 {
225 let mut pairs = url.query_pairs_mut();
226 pairs.append_pair("includeTabsContent", "true");
227 pairs.append_pair("suggestionsViewMode", suggestions.as_str());
228 }
229 Ok(url)
230}
231
232/// Builds the `documents.batchUpdate` URL.
233///
234/// `:batchUpdate` is a suffix on the id path segment, so it is appended to
235/// the id *before* the segment is pushed — pushing it separately would
236/// percent-encode the `:` into its own segment.
237fn build_batch_update_url(base_url: &str, document_id: &str) -> Result<Url> {
238 let mut url =
239 GoogleApiClient::api_url(base_url, "/v1/documents").context("Invalid Docs base URL")?;
240 GoogleApiClient::push_path_segments(&mut url, &[&format!("{document_id}:batchUpdate")])?;
241 Ok(url)
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used, clippy::expect_used)]
246mod tests {
247 use super::*;
248
249 const BASE: &str = "https://docs.googleapis.com";
250
251 /// The deliberate inverse of `sheets/api.rs`'s
252 /// `spreadsheet_get_url_masks_fields`. A Docs mask cannot be made
253 /// recursion-safe, so its absence is a decision — pinned here so it is
254 /// not "fixed" later by someone reasoning from the Sheets precedent.
255 #[test]
256 fn document_get_url_sends_no_fields_mask() {
257 let url = build_document_get_url(BASE, "d1", SuggestionsViewMode::DefaultForCurrentAccess)
258 .unwrap();
259 assert!(
260 url.query_pairs().all(|(k, _)| k != "fields"),
261 "a fields mask would silently truncate nested tables: {url}"
262 );
263 }
264
265 /// Without this a multi-tab document silently reads as its first tab.
266 #[test]
267 fn document_get_url_always_includes_tabs_content() {
268 let url = build_document_get_url(BASE, "d1", SuggestionsViewMode::DefaultForCurrentAccess)
269 .unwrap();
270 assert_eq!(
271 url.query_pairs()
272 .find(|(k, _)| k == "includeTabsContent")
273 .map(|(_, v)| v.to_string()),
274 Some("true".to_string())
275 );
276 }
277
278 #[test]
279 fn document_get_url_sends_every_suggestions_view_mode() {
280 for (mode, wire) in [
281 (
282 SuggestionsViewMode::DefaultForCurrentAccess,
283 "DEFAULT_FOR_CURRENT_ACCESS",
284 ),
285 (SuggestionsViewMode::Inline, "SUGGESTIONS_INLINE"),
286 (
287 SuggestionsViewMode::PreviewAccepted,
288 "PREVIEW_SUGGESTIONS_ACCEPTED",
289 ),
290 (
291 SuggestionsViewMode::PreviewWithoutSuggestions,
292 "PREVIEW_WITHOUT_SUGGESTIONS",
293 ),
294 ] {
295 let url = build_document_get_url(BASE, "d1", mode).unwrap();
296 assert_eq!(
297 url.query_pairs()
298 .find(|(k, _)| k == "suggestionsViewMode")
299 .map(|(_, v)| v.to_string()),
300 Some(wire.to_string()),
301 "{mode:?}"
302 );
303 }
304 }
305
306 #[test]
307 fn suggestions_view_mode_defaults_to_current_access() {
308 assert_eq!(
309 SuggestionsViewMode::default(),
310 SuggestionsViewMode::DefaultForCurrentAccess
311 );
312 }
313
314 #[test]
315 fn document_get_url_keeps_the_id_as_one_path_segment() {
316 let url =
317 build_document_get_url(BASE, "1AbC_dEf-Gh", SuggestionsViewMode::default()).unwrap();
318 assert_eq!(url.path(), "/v1/documents/1AbC_dEf-Gh");
319 }
320
321 /// A document id is opaque today, but the façade still goes through
322 /// `push_path_segments` rather than `format!` so no second precedent for
323 /// interpolating into a path exists. This pins that an id containing a
324 /// URL-meaningful character stays in the path instead of reshaping it.
325 #[test]
326 fn a_url_meaningful_character_in_an_id_is_percent_encoded() {
327 let url = build_document_get_url(BASE, "a/b?c#d", SuggestionsViewMode::default()).unwrap();
328 assert_eq!(url.path(), "/v1/documents/a%2Fb%3Fc%23d");
329 assert!(url.fragment().is_none(), "{url}");
330 }
331
332 #[test]
333 fn urls_respect_a_wiremock_style_base_with_a_port() {
334 let url = build_document_get_url(
335 "http://127.0.0.1:8080",
336 "d1",
337 SuggestionsViewMode::default(),
338 )
339 .unwrap();
340 assert_eq!(url.host_str(), Some("127.0.0.1"));
341 assert_eq!(url.port(), Some(8080));
342 assert_eq!(url.path(), "/v1/documents/d1");
343 }
344
345 #[test]
346 fn check_document_size_refuses_a_response_over_the_cap() {
347 let err = check_document_size(Some(MAX_DOCUMENT_BYTES + 1)).unwrap_err();
348 assert!(err.to_string().contains("refusing to load"), "{err}");
349 }
350
351 #[test]
352 fn check_document_size_allows_a_response_at_the_cap() {
353 assert!(check_document_size(Some(MAX_DOCUMENT_BYTES)).is_ok());
354 }
355
356 /// Google gzips and chunks, so an absent length is the common case and
357 /// must not be treated as a refusal.
358 #[test]
359 fn check_document_size_allows_a_missing_content_length() {
360 assert!(check_document_size(None).is_ok());
361 }
362
363 #[test]
364 fn batch_update_url_appends_the_method_to_the_id_segment() {
365 let url = build_batch_update_url(BASE, "d1").unwrap();
366 // One segment, with a literal `:` — not `d1/%3AbatchUpdate`.
367 assert_eq!(url.path(), "/v1/documents/d1:batchUpdate");
368 }
369
370 #[test]
371 fn batch_update_url_respects_a_wiremock_style_base() {
372 let url = build_batch_update_url("http://127.0.0.1:8080", "d1").unwrap();
373 assert_eq!(url.port(), Some(8080));
374 assert_eq!(url.path(), "/v1/documents/d1:batchUpdate");
375 }
376
377 fn api_error(status: u16, body: &str) -> anyhow::Error {
378 DriveError::ApiRequestFailed {
379 api: "Docs",
380 status,
381 body: body.to_string(),
382 reason: Some("INVALID_ARGUMENT".to_string()),
383 }
384 .into()
385 }
386
387 /// The exact shape observed live (ADR-0076 §6).
388 #[test]
389 fn is_stale_revision_matches_the_confirmed_400() {
390 let err = api_error(
391 400,
392 "The required revision ID 'ALm37BXk3nQ' does not match the latest revision.",
393 );
394 assert!(is_stale_revision(&err));
395 }
396
397 /// The whole reason this matches a message substring rather than the
398 /// status: `INVALID_ARGUMENT` is what a *malformed* request carries too,
399 /// and telling the user to re-run one would be advice that can never
400 /// work.
401 #[test]
402 fn is_stale_revision_ignores_an_unrelated_invalid_argument_400() {
403 let err = api_error(400, "Invalid requests[0].insertText: index out of bounds");
404 assert!(!is_stale_revision(&err));
405 }
406
407 #[test]
408 fn is_stale_revision_ignores_a_403_and_a_500() {
409 for status in [403, 500] {
410 let err = api_error(status, "does not match the latest revision");
411 assert!(!is_stale_revision(&err), "status {status}");
412 }
413 }
414
415 #[test]
416 fn is_stale_revision_ignores_a_non_drive_error() {
417 assert!(!is_stale_revision(&anyhow::anyhow!(
418 "does not match the latest revision"
419 )));
420 }
421
422 /// The surface-level half of ADR-0076 §3's guarantee, modelled on
423 /// ADR-0061's `no_force_escape_hatch_exists_in_the_ui_surface`.
424 ///
425 /// The type fence in `write_types.rs` already stops `writeControl` being
426 /// omitted or made optional. It does **not** stop someone adding a
427 /// `targetRevisionId` field, or hand-building a `serde_json::json!` body
428 /// that bypasses the typed struct entirely — and that second route is
429 /// not hypothetical, since `sheets/api.rs` builds request bodies exactly
430 /// that way. This closes both, and also pins §4's claim that no
431 /// destructive request is constructible.
432 #[test]
433 fn no_destructive_or_unleased_request_is_reachable() {
434 let sources = [
435 ("api.rs", include_str!("api.rs")),
436 ("write_types.rs", include_str!("write_types.rs")),
437 ];
438 for (name, source) in sources {
439 // Production code only: the tests and docs below deliberately
440 // name the things they assert the absence of.
441 let code_only = source.split("#[cfg(test)]").next().unwrap_or(source);
442 for (number, line) in code_only.lines().enumerate() {
443 let code = line.trim_start();
444 if code.starts_with("//") {
445 continue; // prose may discuss a rebase; code may not request one
446 }
447 let bypasses = code.contains("targetRevisionId")
448 || code.contains("target_revision_id")
449 || code.contains("--force")
450 || code.contains("force: true");
451 assert!(
452 !bypasses,
453 "{name}:{}: the Docs write path must never bypass the lease: {line}",
454 number + 1
455 );
456 let destroys = code.contains("deleteContentRange")
457 || code.contains("deletePositionedObject")
458 || code.contains("deleteTableRow")
459 || code.contains("deleteTableColumn");
460 assert!(
461 !destroys,
462 "{name}:{}: no destructive Docs request may be constructible: {line}",
463 number + 1
464 );
465 }
466 }
467 }
468}