1use 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
47const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum SuggestionsViewMode {
68 #[default]
71 DefaultForCurrentAccess,
72 Inline,
74 PreviewAccepted,
76 PreviewWithoutSuggestions,
78}
79
80impl SuggestionsViewMode {
81 #[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#[derive(Debug)]
95pub struct DocsApi<'a> {
96 client: &'a DocsClient,
97}
98
99impl<'a> DocsApi<'a> {
100 #[must_use]
102 pub fn new(client: &'a DocsClient) -> Self {
103 Self { client }
104 }
105
106 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 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
161pub(in crate::drive) fn is_stale_revision(err: &anyhow::Error) -> bool {
182 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
192fn 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
206fn 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
232fn 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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; }
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}