use anyhow::{Context, Result};
use url::Url;
use crate::drive::api_client::GoogleApiClient;
use crate::drive::docs::client::DocsClient;
use crate::drive::docs::types::Document;
use crate::drive::docs::write_types::{
BatchUpdateDocumentRequest, BatchUpdateDocumentResponse, DocsRequest,
};
use crate::drive::error::DriveError;
use crate::drive::files_api::{append_write_scope_hint, WriteCapability};
const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SuggestionsViewMode {
#[default]
DefaultForCurrentAccess,
Inline,
PreviewAccepted,
PreviewWithoutSuggestions,
}
impl SuggestionsViewMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::DefaultForCurrentAccess => "DEFAULT_FOR_CURRENT_ACCESS",
Self::Inline => "SUGGESTIONS_INLINE",
Self::PreviewAccepted => "PREVIEW_SUGGESTIONS_ACCEPTED",
Self::PreviewWithoutSuggestions => "PREVIEW_WITHOUT_SUGGESTIONS",
}
}
}
#[derive(Debug)]
pub struct DocsApi<'a> {
client: &'a DocsClient,
}
impl<'a> DocsApi<'a> {
#[must_use]
pub fn new(client: &'a DocsClient) -> Self {
Self { client }
}
pub async fn get_document(
&self,
document_id: &str,
suggestions: SuggestionsViewMode,
) -> Result<Document> {
let url = build_document_get_url(self.client.base_url(), document_id, suggestions)?;
let response = self.client.transport().get_json(url.as_str()).await?;
check_document_size(response.content_length())?;
self.client
.transport()
.parse_response(response, "Failed to parse Docs document")
.await
}
pub(in crate::drive) async fn batch_update(
&self,
document_id: &str,
request: DocsRequest,
required_revision_id: &str,
) -> Result<BatchUpdateDocumentResponse> {
let url = build_batch_update_url(self.client.base_url(), document_id)?;
let body = BatchUpdateDocumentRequest::new(request, required_revision_id);
let response = self
.client
.transport()
.post_json(url.as_str(), &body)
.await?;
self.client
.transport()
.parse_response(response, "Failed to parse Docs batchUpdate response")
.await
.map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
}
}
pub(in crate::drive) fn is_stale_revision(err: &anyhow::Error) -> bool {
const STALE_MARKER: &str = "does not match the latest revision";
matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::ApiRequestFailed { status: 400, body, .. })
if body.to_ascii_lowercase().contains(STALE_MARKER)
)
}
fn check_document_size(content_length: Option<u64>) -> Result<()> {
if let Some(len) = content_length {
anyhow::ensure!(
len <= MAX_DOCUMENT_BYTES,
"refusing to load {len} bytes into memory (limit: {MAX_DOCUMENT_BYTES} bytes); \
this document is too large for `drive docs read`"
);
}
Ok(())
}
fn build_document_get_url(
base_url: &str,
document_id: &str,
suggestions: SuggestionsViewMode,
) -> Result<Url> {
let mut url =
GoogleApiClient::api_url(base_url, "/v1/documents").context("Invalid Docs base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[document_id])?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("includeTabsContent", "true");
pairs.append_pair("suggestionsViewMode", suggestions.as_str());
}
Ok(url)
}
fn build_batch_update_url(base_url: &str, document_id: &str) -> Result<Url> {
let mut url =
GoogleApiClient::api_url(base_url, "/v1/documents").context("Invalid Docs base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[&format!("{document_id}:batchUpdate")])?;
Ok(url)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const BASE: &str = "https://docs.googleapis.com";
#[test]
fn document_get_url_sends_no_fields_mask() {
let url = build_document_get_url(BASE, "d1", SuggestionsViewMode::DefaultForCurrentAccess)
.unwrap();
assert!(
url.query_pairs().all(|(k, _)| k != "fields"),
"a fields mask would silently truncate nested tables: {url}"
);
}
#[test]
fn document_get_url_always_includes_tabs_content() {
let url = build_document_get_url(BASE, "d1", SuggestionsViewMode::DefaultForCurrentAccess)
.unwrap();
assert_eq!(
url.query_pairs()
.find(|(k, _)| k == "includeTabsContent")
.map(|(_, v)| v.to_string()),
Some("true".to_string())
);
}
#[test]
fn document_get_url_sends_every_suggestions_view_mode() {
for (mode, wire) in [
(
SuggestionsViewMode::DefaultForCurrentAccess,
"DEFAULT_FOR_CURRENT_ACCESS",
),
(SuggestionsViewMode::Inline, "SUGGESTIONS_INLINE"),
(
SuggestionsViewMode::PreviewAccepted,
"PREVIEW_SUGGESTIONS_ACCEPTED",
),
(
SuggestionsViewMode::PreviewWithoutSuggestions,
"PREVIEW_WITHOUT_SUGGESTIONS",
),
] {
let url = build_document_get_url(BASE, "d1", mode).unwrap();
assert_eq!(
url.query_pairs()
.find(|(k, _)| k == "suggestionsViewMode")
.map(|(_, v)| v.to_string()),
Some(wire.to_string()),
"{mode:?}"
);
}
}
#[test]
fn suggestions_view_mode_defaults_to_current_access() {
assert_eq!(
SuggestionsViewMode::default(),
SuggestionsViewMode::DefaultForCurrentAccess
);
}
#[test]
fn document_get_url_keeps_the_id_as_one_path_segment() {
let url =
build_document_get_url(BASE, "1AbC_dEf-Gh", SuggestionsViewMode::default()).unwrap();
assert_eq!(url.path(), "/v1/documents/1AbC_dEf-Gh");
}
#[test]
fn a_url_meaningful_character_in_an_id_is_percent_encoded() {
let url = build_document_get_url(BASE, "a/b?c#d", SuggestionsViewMode::default()).unwrap();
assert_eq!(url.path(), "/v1/documents/a%2Fb%3Fc%23d");
assert!(url.fragment().is_none(), "{url}");
}
#[test]
fn urls_respect_a_wiremock_style_base_with_a_port() {
let url = build_document_get_url(
"http://127.0.0.1:8080",
"d1",
SuggestionsViewMode::default(),
)
.unwrap();
assert_eq!(url.host_str(), Some("127.0.0.1"));
assert_eq!(url.port(), Some(8080));
assert_eq!(url.path(), "/v1/documents/d1");
}
#[test]
fn check_document_size_refuses_a_response_over_the_cap() {
let err = check_document_size(Some(MAX_DOCUMENT_BYTES + 1)).unwrap_err();
assert!(err.to_string().contains("refusing to load"), "{err}");
}
#[test]
fn check_document_size_allows_a_response_at_the_cap() {
assert!(check_document_size(Some(MAX_DOCUMENT_BYTES)).is_ok());
}
#[test]
fn check_document_size_allows_a_missing_content_length() {
assert!(check_document_size(None).is_ok());
}
#[test]
fn batch_update_url_appends_the_method_to_the_id_segment() {
let url = build_batch_update_url(BASE, "d1").unwrap();
assert_eq!(url.path(), "/v1/documents/d1:batchUpdate");
}
#[test]
fn batch_update_url_respects_a_wiremock_style_base() {
let url = build_batch_update_url("http://127.0.0.1:8080", "d1").unwrap();
assert_eq!(url.port(), Some(8080));
assert_eq!(url.path(), "/v1/documents/d1:batchUpdate");
}
fn api_error(status: u16, body: &str) -> anyhow::Error {
DriveError::ApiRequestFailed {
api: "Docs",
status,
body: body.to_string(),
reason: Some("INVALID_ARGUMENT".to_string()),
}
.into()
}
#[test]
fn is_stale_revision_matches_the_confirmed_400() {
let err = api_error(
400,
"The required revision ID 'ALm37BXk3nQ' does not match the latest revision.",
);
assert!(is_stale_revision(&err));
}
#[test]
fn is_stale_revision_ignores_an_unrelated_invalid_argument_400() {
let err = api_error(400, "Invalid requests[0].insertText: index out of bounds");
assert!(!is_stale_revision(&err));
}
#[test]
fn is_stale_revision_ignores_a_403_and_a_500() {
for status in [403, 500] {
let err = api_error(status, "does not match the latest revision");
assert!(!is_stale_revision(&err), "status {status}");
}
}
#[test]
fn is_stale_revision_ignores_a_non_drive_error() {
assert!(!is_stale_revision(&anyhow::anyhow!(
"does not match the latest revision"
)));
}
#[test]
fn no_unleased_or_unmodelled_destructive_request_is_reachable() {
let sources = [
("api.rs", include_str!("api.rs")),
("write_types.rs", include_str!("write_types.rs")),
("write.rs", include_str!("write.rs")),
("anchor.rs", include_str!("anchor.rs")),
("style.rs", include_str!("style.rs")),
(
"cli/style.rs",
include_str!("../../cli/drive/docs/style.rs"),
),
("table.rs", include_str!("table.rs")),
(
"cli/table.rs",
include_str!("../../cli/drive/docs/table.rs"),
),
("named_range.rs", include_str!("named_range.rs")),
(
"cli/named_range.rs",
include_str!("../../cli/drive/docs/named_range.rs"),
),
("create.rs", include_str!("create.rs")),
("client.rs", include_str!("client.rs")),
("types.rs", include_str!("types.rs")),
("structure.rs", include_str!("structure.rs")),
("target.rs", include_str!("target.rs")),
("read.rs", include_str!("read.rs")),
(
"cli/write.rs",
include_str!("../../cli/drive/docs/write.rs"),
),
];
let mut deletion_renames = 0;
let mut list_renames = 0;
let mut formatting_renames = 0;
let mut table_deletion_renames = 0;
let mut named_renames = 0;
for (name, source) in sources {
let code_only = source.split("#[cfg(test)]").next().unwrap_or(source);
for (number, line) in code_only.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") {
continue; }
let bypasses = code.contains("targetRevisionId")
|| code.contains("target_revision_id")
|| code.contains("--force")
|| code.contains("force: true");
assert!(
!bypasses,
"{name}:{}: the Docs write path must never bypass the lease: {line}",
number + 1
);
if code.contains("deleteContentRange") {
assert_eq!(name, "write_types.rs");
assert_eq!(code, "#[serde(rename = \"deleteContentRange\")]");
deletion_renames += 1;
}
for operation in ["createParagraphBullets", "deleteParagraphBullets"] {
if code.contains(operation) {
assert_eq!(name, "write_types.rs");
assert_eq!(code, format!("#[serde(rename = \"{operation}\")]"));
list_renames += 1;
}
}
for operation in ["updateTextStyle", "updateParagraphStyle"] {
if code.contains(operation) {
assert_eq!(name, "write_types.rs");
assert_eq!(code, format!("#[serde(rename = \"{operation}\")]"));
formatting_renames += 1;
}
}
if code.contains("deleteTableRow") || code.contains("deleteTableColumn") {
assert_eq!(name, "write_types.rs");
assert!(matches!(
code,
"#[serde(rename = \"deleteTableRow\")]"
| "#[serde(rename = \"deleteTableColumn\")]"
));
table_deletion_renames += 1;
}
for operation in [
"createNamedRange",
"deleteNamedRange",
"replaceNamedRangeContent",
] {
if code.contains(operation) {
assert_eq!(name, "write_types.rs");
assert!(code.starts_with("#[serde("));
named_renames += 1;
}
}
let destroys = code.contains("deletePositionedObject");
assert!(
!destroys,
"{name}:{}: no destructive Docs request may be constructible: {line}",
number + 1
);
}
}
assert_eq!(list_renames, 2, "exactly two typed list requests");
assert_eq!(deletion_renames, 1, "exactly one typed deletion request");
assert_eq!(
formatting_renames, 2,
"exactly two typed formatting operations"
);
assert_eq!(
table_deletion_renames, 2,
"exactly two typed dimension deletion requests"
);
assert_eq!(
named_renames, 4,
"three typed requests and one create reply"
);
}
}