Skip to main content

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 permits only the typed content-range
431    /// deletion introduced by ADR-0094; other destructive requests stay absent.
432    #[test]
433    fn no_unleased_or_unmodelled_destructive_request_is_reachable() {
434        let sources = [
435            ("api.rs", include_str!("api.rs")),
436            ("write_types.rs", include_str!("write_types.rs")),
437            ("write.rs", include_str!("write.rs")),
438            ("anchor.rs", include_str!("anchor.rs")),
439            ("style.rs", include_str!("style.rs")),
440            (
441                "cli/style.rs",
442                include_str!("../../cli/drive/docs/style.rs"),
443            ),
444            ("table.rs", include_str!("table.rs")),
445            (
446                "cli/table.rs",
447                include_str!("../../cli/drive/docs/table.rs"),
448            ),
449            ("named_range.rs", include_str!("named_range.rs")),
450            (
451                "cli/named_range.rs",
452                include_str!("../../cli/drive/docs/named_range.rs"),
453            ),
454            ("create.rs", include_str!("create.rs")),
455            ("client.rs", include_str!("client.rs")),
456            ("types.rs", include_str!("types.rs")),
457            ("structure.rs", include_str!("structure.rs")),
458            ("target.rs", include_str!("target.rs")),
459            ("read.rs", include_str!("read.rs")),
460            (
461                "cli/write.rs",
462                include_str!("../../cli/drive/docs/write.rs"),
463            ),
464        ];
465        let mut deletion_renames = 0;
466        let mut list_renames = 0;
467        let mut formatting_renames = 0;
468        let mut table_deletion_renames = 0;
469        let mut named_renames = 0;
470        for (name, source) in sources {
471            // Production code only: the tests and docs below deliberately
472            // name the things they assert the absence of.
473            let code_only = source.split("#[cfg(test)]").next().unwrap_or(source);
474            for (number, line) in code_only.lines().enumerate() {
475                let code = line.trim_start();
476                if code.starts_with("//") {
477                    continue; // prose may discuss a rebase; code may not request one
478                }
479                let bypasses = code.contains("targetRevisionId")
480                    || code.contains("target_revision_id")
481                    || code.contains("--force")
482                    || code.contains("force: true");
483                assert!(
484                    !bypasses,
485                    "{name}:{}: the Docs write path must never bypass the lease: {line}",
486                    number + 1
487                );
488                if code.contains("deleteContentRange") {
489                    assert_eq!(name, "write_types.rs");
490                    assert_eq!(code, "#[serde(rename = \"deleteContentRange\")]");
491                    deletion_renames += 1;
492                }
493                for operation in ["createParagraphBullets", "deleteParagraphBullets"] {
494                    if code.contains(operation) {
495                        assert_eq!(name, "write_types.rs");
496                        assert_eq!(code, format!("#[serde(rename = \"{operation}\")]"));
497                        list_renames += 1;
498                    }
499                }
500                for operation in ["updateTextStyle", "updateParagraphStyle"] {
501                    if code.contains(operation) {
502                        assert_eq!(name, "write_types.rs");
503                        assert_eq!(code, format!("#[serde(rename = \"{operation}\")]"));
504                        formatting_renames += 1;
505                    }
506                }
507                if code.contains("deleteTableRow") || code.contains("deleteTableColumn") {
508                    assert_eq!(name, "write_types.rs");
509                    assert!(matches!(
510                        code,
511                        "#[serde(rename = \"deleteTableRow\")]"
512                            | "#[serde(rename = \"deleteTableColumn\")]"
513                    ));
514                    table_deletion_renames += 1;
515                }
516                for operation in [
517                    "createNamedRange",
518                    "deleteNamedRange",
519                    "replaceNamedRangeContent",
520                ] {
521                    if code.contains(operation) {
522                        assert_eq!(name, "write_types.rs");
523                        assert!(code.starts_with("#[serde("));
524                        named_renames += 1;
525                    }
526                }
527                let destroys = code.contains("deletePositionedObject");
528                assert!(
529                    !destroys,
530                    "{name}:{}: no destructive Docs request may be constructible: {line}",
531                    number + 1
532                );
533            }
534        }
535        assert_eq!(list_renames, 2, "exactly two typed list requests");
536        assert_eq!(deletion_renames, 1, "exactly one typed deletion request");
537        assert_eq!(
538            formatting_renames, 2,
539            "exactly two typed formatting operations"
540        );
541        assert_eq!(
542            table_deletion_renames, 2,
543            "exactly two typed dimension deletion requests"
544        );
545        assert_eq!(
546            named_renames, 4,
547            "three typed requests and one create reply"
548        );
549    }
550}