use anyhow::{Context, Result};
use url::Url;
use crate::drive::api_client::GoogleApiClient;
use crate::drive::files_api::{append_write_scope_hint, WriteCapability};
use crate::drive::sheets::a1;
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::types::{
AppendValuesResponse, BatchGetValuesResponse, BatchUpdateRequest, BatchUpdateRequestItem,
BatchUpdateResponse, ClearValuesResponse, CopySheetToAnotherSpreadsheetRequest, DataFilter,
SearchDeveloperMetadataRequest, SearchDeveloperMetadataResponse, SheetProperties, Spreadsheet,
UpdateValuesResponse, ValueRange,
};
#[derive(Debug)]
pub(in crate::drive) enum BatchUpdateOutcome {
Applied(BatchUpdateResponse),
AppliedReplyUnreadable {
detail: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::drive) enum RetryHint {
NotIdempotent,
Idempotent,
}
pub(in crate::drive) fn applied_reply_unreadable_line(
summary: &str,
book: &str,
hint: RetryHint,
detail: &str,
) -> String {
let advice = match hint {
RetryHint::NotIdempotent => "do not retry; check the spreadsheet first",
RetryHint::Idempotent => "check the spreadsheet to confirm",
};
format!("Applied, but the reply could not be read: {summary} in {book} — {advice} ({detail})")
}
const SPREADSHEET_FIELDS: &str = "spreadsheetId,\
properties(title,locale,timeZone,autoRecalc,iterativeCalculationSettings),\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines))";
const SPREADSHEET_FIELDS_WITH_PROTECTIONS: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.protectedRanges(protectedRangeId,range,description,warningOnly,editors.users)";
const SPREADSHEET_FIELDS_WITH_FILTER_VIEWS: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.basicFilter(range,sortSpecs,criteria),\
sheets.filterViews(filterViewId,title,range,namedRangeId,tableId,sortSpecs,criteria)";
const SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.conditionalFormats(ranges,booleanRule,gradientRule)";
const SPREADSHEET_FIELDS_WITH_NAMED_RANGES: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
namedRanges(namedRangeId,name,range)";
const SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.charts,sheets.slicers";
const SPREADSHEET_FIELDS_WITH_PIVOT_TABLES: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.data(startRow,startColumn,rowData.values(pivotTable))";
const CELL_PIVOT_FIELDS: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.data(startRow,startColumn,rowData.values(pivotTable,formattedValue))";
const CELL_FORMAT_FIELDS: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.data(startRow,startColumn,rowData.values(userEnteredFormat(backgroundColorStyle,textFormat(bold,italic,strikethrough,underline,foregroundColorStyle),numberFormat,horizontalAlignment,borders(top,bottom,left,right)),note,dataValidation))";
const SPREADSHEET_FIELDS_WITH_BANDING: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
sheets.bandedRanges(bandedRangeId,range,rowProperties,columnProperties)";
const SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS: &str = "spreadsheetId,properties.title,\
sheets.properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)),\
sheets.rowGroups(range,depth,collapsed),sheets.columnGroups(range,depth,collapsed)";
pub(crate) const MAX_RANGES_PER_BATCH: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ValueRenderOption {
#[default]
Formatted,
Unformatted,
Formula,
}
impl ValueRenderOption {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Formatted => "FORMATTED_VALUE",
Self::Unformatted => "UNFORMATTED_VALUE",
Self::Formula => "FORMULA",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ValueInputOption {
#[default]
UserEntered,
Raw,
}
impl ValueInputOption {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::UserEntered => "USER_ENTERED",
Self::Raw => "RAW",
}
}
}
#[derive(Debug)]
pub struct SheetsApi<'a> {
client: &'a SheetsClient,
}
impl<'a> SheetsApi<'a> {
#[must_use]
pub fn new(client: &'a SheetsClient) -> Self {
Self { client }
}
pub async fn get_spreadsheet(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
let url = build_spreadsheet_get_url(self.client.base_url(), spreadsheet_id)?;
self.client
.transport()
.get_parsed(url.as_str(), "Failed to parse Sheets spreadsheet metadata")
.await
}
pub async fn get_spreadsheet_with_protections(
&self,
spreadsheet_id: &str,
) -> Result<Spreadsheet> {
let url =
build_spreadsheet_get_with_protections_url(self.client.base_url(), spreadsheet_id)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with protections)",
)
.await
}
pub async fn get_spreadsheet_with_filter_views(
&self,
spreadsheet_id: &str,
) -> Result<Spreadsheet> {
let url =
build_spreadsheet_get_with_filter_views_url(self.client.base_url(), spreadsheet_id)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with filter views)",
)
.await
}
pub async fn get_spreadsheet_with_conditional_formats(
&self,
spreadsheet_id: &str,
) -> Result<Spreadsheet> {
let url = build_spreadsheet_get_with_conditional_formats_url(
self.client.base_url(),
spreadsheet_id,
)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with conditional formats)",
)
.await
}
pub async fn get_spreadsheet_with_named_ranges(
&self,
spreadsheet_id: &str,
) -> Result<Spreadsheet> {
let url =
build_spreadsheet_get_with_named_ranges_url(self.client.base_url(), spreadsheet_id)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with named ranges)",
)
.await
}
pub async fn get_spreadsheet_with_embedded_objects(
&self,
spreadsheet_id: &str,
) -> Result<Spreadsheet> {
let url = build_spreadsheet_get_with_embedded_objects_url(
self.client.base_url(),
spreadsheet_id,
)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with embedded objects)",
)
.await
}
pub async fn get_spreadsheet_with_pivot_tables(
&self,
spreadsheet_id: &str,
) -> Result<Spreadsheet> {
let url =
build_spreadsheet_get_with_pivot_tables_url(self.client.base_url(), spreadsheet_id)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with pivot tables)",
)
.await
}
pub async fn get_spreadsheet_with_banding(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
let url = build_spreadsheet_get_with_banding_url(self.client.base_url(), spreadsheet_id)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with banded ranges)",
)
.await
}
pub async fn get_spreadsheet_with_dimension_groups(
&self,
spreadsheet_id: &str,
) -> Result<Spreadsheet> {
let url = build_spreadsheet_get_with_dimension_groups_url(
self.client.base_url(),
spreadsheet_id,
)?;
self.client
.transport()
.get_parsed(
url.as_str(),
"Failed to parse Sheets spreadsheet metadata (with dimension groups)",
)
.await
}
pub async fn get_cell_pivot(
&self,
spreadsheet_id: &str,
composed_a1: &str,
) -> Result<Spreadsheet> {
let url = build_cell_pivot_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
self.client
.transport()
.get_parsed(url.as_str(), "Failed to parse Sheets cell pivot response")
.await
}
pub async fn get_cell_formats(
&self,
spreadsheet_id: &str,
composed_a1: &str,
) -> Result<Spreadsheet> {
let url = build_cell_format_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
self.client
.transport()
.get_parsed(url.as_str(), "Failed to parse Sheets cell format response")
.await
}
pub async fn values_get(
&self,
spreadsheet_id: &str,
range: &str,
render: ValueRenderOption,
) -> Result<ValueRange> {
let url = build_values_get_url(self.client.base_url(), spreadsheet_id, range, render)?;
self.client
.transport()
.get_parsed(url.as_str(), "Failed to parse Sheets values response")
.await
}
pub async fn values_batch_get(
&self,
spreadsheet_id: &str,
ranges: &[String],
render: ValueRenderOption,
) -> Result<BatchGetValuesResponse> {
let url =
build_values_batch_get_url(self.client.base_url(), spreadsheet_id, ranges, render)?;
self.client
.transport()
.get_parsed(url.as_str(), "Failed to parse Sheets batchGet response")
.await
}
pub(crate) async fn batch_get_every_sheet(
&self,
spreadsheet_id: &str,
titles: &[String],
render: ValueRenderOption,
) -> Result<Vec<(Option<String>, ValueRange)>> {
let mut out = Vec::with_capacity(titles.len());
for chunk in titles.chunks(MAX_RANGES_PER_BATCH) {
let ranges: Vec<String> = chunk.iter().map(|t| a1::quote_sheet_title(t)).collect();
let response = self
.values_batch_get(spreadsheet_id, &ranges, render)
.await?;
for value_range in response.value_ranges {
let title = value_range.range.as_deref().and_then(a1::sheet_title_of);
out.push((title, value_range));
}
}
Ok(out)
}
pub(in crate::drive) async fn values_update(
&self,
spreadsheet_id: &str,
range: &str,
values: &[Vec<String>],
input: ValueInputOption,
) -> Result<UpdateValuesResponse> {
let url = build_values_update_url(self.client.base_url(), spreadsheet_id, range, input)?;
let body = serde_json::json!({ "range": range, "values": values });
let response = self
.client
.transport()
.put_json(url.as_str(), &body)
.await?;
self.client
.transport()
.parse_response(response, "Failed to parse Sheets update response")
.await
.map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
}
pub(in crate::drive) async fn values_append(
&self,
spreadsheet_id: &str,
range: &str,
values: &[Vec<String>],
input: ValueInputOption,
) -> Result<AppendValuesResponse> {
let url = build_values_append_url(self.client.base_url(), spreadsheet_id, range, input)?;
let body = serde_json::json!({ "range": range, "values": values });
let response = self
.client
.transport()
.post_json(url.as_str(), &body)
.await?;
self.client
.transport()
.parse_response(response, "Failed to parse Sheets append response")
.await
.map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
}
pub(in crate::drive) async fn values_clear(
&self,
spreadsheet_id: &str,
range: &str,
) -> Result<ClearValuesResponse> {
let url = build_values_clear_url(self.client.base_url(), spreadsheet_id, range)?;
let response = self
.client
.transport()
.post_json(url.as_str(), &serde_json::json!({}))
.await?;
self.client
.transport()
.parse_response(response, "Failed to parse Sheets clear response")
.await
.map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
}
pub(in crate::drive) async fn batch_update(
&self,
spreadsheet_id: &str,
requests: Vec<BatchUpdateRequestItem>,
) -> Result<BatchUpdateOutcome> {
let url = build_batch_update_url(self.client.base_url(), spreadsheet_id)?;
let body = BatchUpdateRequest { requests };
let response = self
.client
.transport()
.post_json(url.as_str(), &body)
.await?;
let reply = self
.client
.transport()
.parse_success_response::<BatchUpdateResponse>(
response,
"Failed to parse Sheets batchUpdate response",
)
.await
.map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))?;
Ok(match reply {
Ok(response) => BatchUpdateOutcome::Applied(response),
Err(detail) => BatchUpdateOutcome::AppliedReplyUnreadable { detail },
})
}
pub(in crate::drive) async fn copy_to(
&self,
spreadsheet_id: &str,
sheet_id: i64,
destination_spreadsheet_id: &str,
) -> Result<SheetProperties> {
let url = build_copy_to_url(self.client.base_url(), spreadsheet_id, sheet_id)?;
let body = CopySheetToAnotherSpreadsheetRequest {
destination_spreadsheet_id: destination_spreadsheet_id.to_string(),
};
let response = self
.client
.transport()
.post_json(url.as_str(), &body)
.await?;
self.client
.transport()
.parse_response(response, "Failed to parse Sheets copyTo response")
.await
.map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
}
pub async fn search_developer_metadata(
&self,
spreadsheet_id: &str,
filters: Vec<DataFilter>,
) -> Result<SearchDeveloperMetadataResponse> {
let url = build_developer_metadata_search_url(self.client.base_url(), spreadsheet_id)?;
let body = SearchDeveloperMetadataRequest {
data_filters: filters,
};
let response = self
.client
.transport()
.post_json(url.as_str(), &body)
.await?;
self.client
.transport()
.parse_response(
response,
"Failed to parse Sheets developerMetadata.search response",
)
.await
}
}
fn build_spreadsheet_get_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS);
Ok(url)
}
fn build_spreadsheet_get_with_protections_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_PROTECTIONS);
Ok(url)
}
fn build_spreadsheet_get_with_filter_views_url(
base_url: &str,
spreadsheet_id: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_FILTER_VIEWS);
Ok(url)
}
fn build_spreadsheet_get_with_conditional_formats_url(
base_url: &str,
spreadsheet_id: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS);
Ok(url)
}
fn build_spreadsheet_get_with_named_ranges_url(
base_url: &str,
spreadsheet_id: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_NAMED_RANGES);
Ok(url)
}
fn build_spreadsheet_get_with_embedded_objects_url(
base_url: &str,
spreadsheet_id: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS);
Ok(url)
}
fn build_spreadsheet_get_with_pivot_tables_url(
base_url: &str,
spreadsheet_id: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_PIVOT_TABLES);
Ok(url)
}
fn build_spreadsheet_get_with_banding_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_BANDING);
Ok(url)
}
fn build_spreadsheet_get_with_dimension_groups_url(
base_url: &str,
spreadsheet_id: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("fields", SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS);
Ok(url)
}
fn build_cell_pivot_get_url(
base_url: &str,
spreadsheet_id: &str,
composed_a1: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("ranges", composed_a1)
.append_pair("fields", CELL_PIVOT_FIELDS);
Ok(url)
}
fn build_cell_format_get_url(
base_url: &str,
spreadsheet_id: &str,
composed_a1: &str,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
url.query_pairs_mut()
.append_pair("ranges", composed_a1)
.append_pair("fields", CELL_FORMAT_FIELDS);
Ok(url)
}
fn build_values_get_url(
base_url: &str,
spreadsheet_id: &str,
range: &str,
render: ValueRenderOption,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
url.query_pairs_mut()
.append_pair("valueRenderOption", render.as_str());
Ok(url)
}
fn build_values_batch_get_url(
base_url: &str,
spreadsheet_id: &str,
ranges: &[String],
render: ValueRenderOption,
) -> Result<Url> {
anyhow::ensure!(
!ranges.is_empty(),
"values.batchGet requires at least one range"
);
anyhow::ensure!(
ranges.len() <= MAX_RANGES_PER_BATCH,
"values.batchGet was given {} ranges, over the {MAX_RANGES_PER_BATCH} per-request cap; \
callers must chunk",
ranges.len()
);
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values:batchGet"])?;
{
let mut pairs = url.query_pairs_mut();
for range in ranges {
pairs.append_pair("ranges", range);
}
pairs.append_pair("valueRenderOption", render.as_str());
}
Ok(url)
}
fn build_values_update_url(
base_url: &str,
spreadsheet_id: &str,
range: &str,
input: ValueInputOption,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
url.query_pairs_mut()
.append_pair("valueInputOption", input.as_str());
Ok(url)
}
fn build_values_append_url(
base_url: &str,
spreadsheet_id: &str,
range: &str,
input: ValueInputOption,
) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(
&mut url,
&[spreadsheet_id, "values", &format!("{range}:append")],
)?;
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("valueInputOption", input.as_str());
pairs.append_pair("insertDataOption", "INSERT_ROWS");
}
Ok(url)
}
fn build_values_clear_url(base_url: &str, spreadsheet_id: &str, range: &str) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(
&mut url,
&[spreadsheet_id, "values", &format!("{range}:clear")],
)?;
Ok(url)
}
fn build_batch_update_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[&format!("{spreadsheet_id}:batchUpdate")])?;
Ok(url)
}
fn build_copy_to_url(base_url: &str, spreadsheet_id: &str, sheet_id: i64) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(
&mut url,
&[spreadsheet_id, "sheets", &format!("{sheet_id}:copyTo")],
)?;
Ok(url)
}
fn build_developer_metadata_search_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
.context("Invalid Sheets base URL")?;
GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "developerMetadata:search"])?;
Ok(url)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const BASE: &str = "https://sheets.googleapis.com";
#[test]
fn spreadsheet_get_url_masks_fields() {
let url = build_spreadsheet_get_url(BASE, "sheet-1").unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
let fields = url
.query_pairs()
.find(|(k, _)| k == "fields")
.map(|(_, v)| v.to_string())
.expect("fields mask must always be sent");
assert!(fields.contains("sheets.properties"));
assert!(fields.contains("title"));
assert!(fields.contains("frozenRowCount"));
}
#[test]
fn spreadsheet_get_with_conditional_formats_url_masks_the_wider_fields() {
let url = build_spreadsheet_get_with_conditional_formats_url(BASE, "sheet-1").unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
let fields = url
.query_pairs()
.find(|(k, _)| k == "fields")
.map(|(_, v)| v.to_string())
.expect("fields mask must always be sent");
assert!(fields.contains("sheets.conditionalFormats"));
assert!(fields.contains("booleanRule"));
assert!(fields.contains("gradientRule"));
assert!(fields.contains("frozenRowCount"));
}
#[tokio::test]
async fn get_spreadsheet_with_embedded_objects_surfaces_an_unparseable_base_url() {
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::client::DriveClient;
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
let credentials = DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
};
let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
let err = SheetsApi::new(&sheets)
.get_spreadsheet_with_embedded_objects("sheet-1")
.await
.unwrap_err();
assert!(
err.to_string().contains("Invalid Sheets base URL"),
"{err:#}"
);
}
#[test]
fn spreadsheet_get_with_embedded_objects_url_masks_the_wider_fields() {
let url = build_spreadsheet_get_with_embedded_objects_url(BASE, "sheet-1").unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
let fields = url
.query_pairs()
.find(|(k, _)| k == "fields")
.map(|(_, v)| v.to_string())
.expect("fields mask must always be sent");
assert!(fields.contains("sheets.charts"));
assert!(fields.contains("sheets.slicers"));
assert!(fields.contains("frozenRowCount"));
}
#[test]
fn spreadsheet_get_with_pivot_tables_url_masks_the_wider_fields() {
let url = build_spreadsheet_get_with_pivot_tables_url(BASE, "sheet-1").unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
let fields = url
.query_pairs()
.find(|(k, _)| k == "fields")
.map(|(_, v)| v.to_string())
.expect("fields mask must always be sent");
assert!(fields.contains("sheets.data"));
assert!(fields.contains("pivotTable"));
assert!(!fields.contains("formattedValue"));
assert!(fields.contains("frozenRowCount"));
}
#[tokio::test]
async fn get_spreadsheet_with_dimension_groups_surfaces_an_unparseable_base_url() {
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::client::DriveClient;
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
let credentials = DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
};
let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
let err = SheetsApi::new(&sheets)
.get_spreadsheet_with_dimension_groups("sheet-1")
.await
.unwrap_err();
assert!(
err.to_string().contains("Invalid Sheets base URL"),
"{err:#}"
);
}
#[test]
fn spreadsheet_get_with_dimension_groups_url_masks_the_wider_fields() {
let url = build_spreadsheet_get_with_dimension_groups_url(BASE, "sheet-1").unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
let fields = url
.query_pairs()
.find(|(k, _)| k == "fields")
.map(|(_, v)| v.to_string())
.expect("fields mask must always be sent");
assert!(fields.contains("rowGroups"));
assert!(fields.contains("columnGroups"));
}
#[test]
fn cell_pivot_get_url_scopes_ranges_and_masks_fields() {
let url = build_cell_pivot_get_url(BASE, "sheet-1", "'Report'!A1").unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
let ranges = url
.query_pairs()
.find(|(k, _)| k == "ranges")
.map(|(_, v)| v.to_string())
.expect("ranges must always be sent");
assert_eq!(ranges, "'Report'!A1");
let fields = url
.query_pairs()
.find(|(k, _)| k == "fields")
.map(|(_, v)| v.to_string())
.expect("fields mask must always be sent");
assert!(fields.contains("pivotTable"));
assert!(fields.contains("formattedValue"));
assert!(fields.contains("frozenRowCount"));
}
#[test]
fn cell_format_get_url_scopes_ranges_and_masks_fields() {
let url = build_cell_format_get_url(BASE, "sheet-1", "'Report'!A1:D10").unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
let ranges = url
.query_pairs()
.find(|(k, _)| k == "ranges")
.map(|(_, v)| v.to_string())
.expect("ranges must always be sent");
assert_eq!(ranges, "'Report'!A1:D10");
let fields = url
.query_pairs()
.find(|(k, _)| k == "fields")
.map(|(_, v)| v.to_string())
.expect("fields mask must always be sent");
assert!(fields.contains("userEnteredFormat"));
assert!(fields.contains("backgroundColorStyle"));
assert!(fields.contains("borders(top,bottom,left,right)"));
assert!(fields.contains("note"));
assert!(fields.contains("dataValidation"));
assert!(!fields.contains("effectiveFormat"));
assert!(!fields.contains("formattedValue"));
assert!(fields.contains("frozenRowCount"));
}
#[test]
fn values_get_url_percent_encodes_a_space_in_a_quoted_title() {
let url = build_values_get_url(
BASE,
"sheet-1",
"'My Sheet'!A1:B2",
ValueRenderOption::Formatted,
)
.unwrap();
assert!(
url.as_str().contains("'My%20Sheet'!A1:B2"),
"space must be encoded: {url}"
);
}
#[test]
fn values_get_url_encodes_characters_that_would_reshape_the_url() {
for (title, encoded) in [("A#B", "%23"), ("A?B", "%3F"), ("A/B", "%2F")] {
let range = format!("'{title}'!A1");
let url =
build_values_get_url(BASE, "s", &range, ValueRenderOption::Formatted).unwrap();
assert!(
url.as_str().contains(encoded),
"{title:?} must encode to {encoded}: {url}"
);
assert!(url.fragment().is_none(), "{title:?} leaked a fragment");
assert!(url.query().is_some_and(|q| !q.contains("!A1")));
let segments: Vec<&str> = url.path_segments().unwrap().collect();
assert_eq!(segments.len(), 5, "{title:?} split the path: {url}");
assert_eq!(segments[3], "values", "{title:?} shifted the path: {url}");
}
}
#[test]
fn values_get_url_round_trips_the_range_through_decoding() {
let range = "'Bob''s Sheet'!A1:C9";
let url = build_values_get_url(BASE, "s", range, ValueRenderOption::Formatted).unwrap();
let decoded = url
.path_segments()
.unwrap()
.next_back()
.map(percent_decode)
.unwrap();
assert_eq!(decoded, range);
}
fn percent_decode(segment: &str) -> String {
percent_encoding::percent_decode_str(segment)
.decode_utf8()
.unwrap()
.to_string()
}
#[test]
fn values_get_url_sends_the_render_option() {
for (render, wire) in [
(ValueRenderOption::Formatted, "FORMATTED_VALUE"),
(ValueRenderOption::Unformatted, "UNFORMATTED_VALUE"),
(ValueRenderOption::Formula, "FORMULA"),
] {
let url = build_values_get_url(BASE, "s", "A1", render).unwrap();
let got = url
.query_pairs()
.find(|(k, _)| k == "valueRenderOption")
.map(|(_, v)| v.to_string())
.unwrap();
assert_eq!(got, wire);
}
}
#[test]
fn batch_get_url_repeats_the_ranges_parameter() {
let ranges = vec!["'A'!A1:B2".to_string(), "'B'!A1".to_string()];
let url =
build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted).unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/s/values:batchGet");
let got: Vec<String> = url
.query_pairs()
.filter(|(k, _)| k == "ranges")
.map(|(_, v)| v.to_string())
.collect();
assert_eq!(got, ranges);
}
#[test]
fn batch_get_url_keeps_the_colon_suffix_unencoded() {
let url = build_values_batch_get_url(
BASE,
"s",
&["A1".to_string()],
ValueRenderOption::Formatted,
)
.unwrap();
assert!(
url.as_str().contains("/values:batchGet?"),
"the :batchGet suffix must survive path encoding: {url}"
);
}
#[test]
fn batch_get_url_rejects_an_empty_range_list() {
let err =
build_values_batch_get_url(BASE, "s", &[], ValueRenderOption::Formatted).unwrap_err();
assert!(err.to_string().contains("at least one range"), "{err}");
}
#[test]
fn batch_get_url_rejects_more_ranges_than_the_cap() {
let ranges: Vec<String> = (0..=MAX_RANGES_PER_BATCH)
.map(|i| format!("S{i}"))
.collect();
let err = build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted)
.unwrap_err();
assert!(err.to_string().contains("per-request cap"), "{err}");
}
#[test]
fn update_url_sends_the_value_input_option() {
let url =
build_values_update_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
assert_eq!(url.path(), "/v4/spreadsheets/s/values/A1:B2");
let input = url
.query_pairs()
.find(|(k, _)| k == "valueInputOption")
.map(|(_, v)| v.to_string());
assert_eq!(input, Some("USER_ENTERED".to_string()));
}
#[test]
fn update_url_percent_encodes_a_quoted_sheet_title() {
let url =
build_values_update_url(BASE, "s", "'My Sheet'!A1", ValueInputOption::Raw).unwrap();
assert!(url.path().contains("'My%20Sheet'"), "{url}");
}
#[test]
fn append_url_keeps_the_colon_suffix_and_sets_insert_rows() {
let url =
build_values_append_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
assert!(url.as_str().contains("/values/A1:B2:append"), "{url}");
let insert_data_option = url
.query_pairs()
.find(|(k, _)| k == "insertDataOption")
.map(|(_, v)| v.to_string());
assert_eq!(insert_data_option, Some("INSERT_ROWS".to_string()));
let input = url
.query_pairs()
.find(|(k, _)| k == "valueInputOption")
.map(|(_, v)| v.to_string());
assert_eq!(input, Some("USER_ENTERED".to_string()));
}
#[test]
fn clear_url_keeps_the_colon_suffix_and_sends_no_query() {
let url = build_values_clear_url(BASE, "s", "A1:B2").unwrap();
assert!(url.as_str().contains("/values/A1:B2:clear"), "{url}");
assert!(url.query().is_none(), "{url}");
}
#[test]
fn copy_to_url_keeps_the_colon_suffix_on_the_sheet_id_segment() {
let url = build_copy_to_url(BASE, "dest-or-source-id", 12345).unwrap();
assert_eq!(
url.path(),
"/v4/spreadsheets/dest-or-source-id/sheets/12345:copyTo"
);
assert!(url.query().is_none(), "{url}");
}
async fn sheets_client_for(server: &wiremock::MockServer) -> SheetsClient {
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::client::DriveClient;
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token", "expires_in": 3600,
})),
)
.mount(server)
.await;
let credentials = DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
};
let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
crate::drive::client::test_support::replace_session(
&mut drive,
&credentials,
&format!("{}/token", server.uri()),
);
let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
SheetsClient::from_drive_client_with(&env, &drive).unwrap()
}
async fn mount_batch_update_reply(
server: &wiremock::MockServer,
template: wiremock::ResponseTemplate,
) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(template)
.expect(1)
.mount(server)
.await;
}
fn one_request() -> Vec<BatchUpdateRequestItem> {
vec![BatchUpdateRequestItem::DeleteEmbeddedObject(
crate::drive::sheets::types::DeleteEmbeddedObjectRequest { object_id: 1 },
)]
}
#[tokio::test]
async fn batch_update_parses_a_well_formed_2xx_reply_as_applied() {
let server = wiremock::MockServer::start().await;
let sheets = sheets_client_for(&server).await;
mount_batch_update_reply(
&server,
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"replies": [{"addChart": {"chart": {"chartId": 42}}}],
})),
)
.await;
let outcome = SheetsApi::new(&sheets)
.batch_update("sheet-1", one_request())
.await
.unwrap();
assert!(
matches!(outcome, BatchUpdateOutcome::Applied(_)),
"{outcome:?}"
);
}
#[tokio::test]
async fn batch_update_reports_a_2xx_with_a_non_json_body_as_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let sheets = sheets_client_for(&server).await;
mount_batch_update_reply(
&server,
wiremock::ResponseTemplate::new(200).set_body_string("not json"),
)
.await;
let outcome = SheetsApi::new(&sheets)
.batch_update("sheet-1", one_request())
.await
.unwrap();
let BatchUpdateOutcome::AppliedReplyUnreadable { detail } = outcome else {
panic!("expected AppliedReplyUnreadable, got {outcome:?}"); };
assert!(
detail.contains("Failed to parse Sheets batchUpdate response"),
"{detail}"
);
}
#[tokio::test]
async fn batch_update_reports_a_2xx_with_a_mistyped_reply_as_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let sheets = sheets_client_for(&server).await;
mount_batch_update_reply(
&server,
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"replies": [{"addChart": {"chart": {"chartId": "x"}}}],
})),
)
.await;
let outcome = SheetsApi::new(&sheets)
.batch_update("sheet-1", one_request())
.await
.unwrap();
assert!(
matches!(outcome, BatchUpdateOutcome::AppliedReplyUnreadable { .. }),
"{outcome:?}"
);
}
#[tokio::test]
async fn batch_update_reports_a_2xx_with_an_empty_body_as_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let sheets = sheets_client_for(&server).await;
mount_batch_update_reply(&server, wiremock::ResponseTemplate::new(200)).await;
let outcome = SheetsApi::new(&sheets)
.batch_update("sheet-1", one_request())
.await
.unwrap();
assert!(
matches!(outcome, BatchUpdateOutcome::AppliedReplyUnreadable { .. }),
"{outcome:?}"
);
}
#[tokio::test]
async fn batch_update_reports_a_non_2xx_as_an_error_not_applied() {
let server = wiremock::MockServer::start().await;
let sheets = sheets_client_for(&server).await;
mount_batch_update_reply(
&server,
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": {
"code": 403,
"message": "The caller does not have permission",
"status": "PERMISSION_DENIED",
}
})),
)
.await;
let err = SheetsApi::new(&sheets)
.batch_update("sheet-1", one_request())
.await
.unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("PERMISSION_DENIED"), "{message}");
assert!(message.contains("--write-full"), "{message}");
}
#[test]
fn applied_reply_unreadable_line_words_each_retry_hint() {
assert_eq!(
applied_reply_unreadable_line(
"add chart",
"'Budget'",
RetryHint::NotIdempotent,
"bad reply"
),
"Applied, but the reply could not be read: add chart in 'Budget' — \
do not retry; check the spreadsheet first (bad reply)"
);
assert_eq!(
applied_reply_unreadable_line("sort A1:B2", "'Budget'", RetryHint::Idempotent, "x"),
"Applied, but the reply could not be read: sort A1:B2 in 'Budget' — \
check the spreadsheet to confirm (x)"
);
}
#[test]
fn every_batch_update_caller_handles_an_unreadable_reply() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/drive");
let mut files = Vec::new();
let mut dirs = vec![root.join("sheets"), root.join("lease")];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
let mut callers = Vec::new();
for path in files {
let source = std::fs::read_to_string(&path).unwrap();
let production = source.rfind("\nmod tests").map_or(source.as_str(), |at| {
source[..at]
.rfind("#[cfg(test)]")
.map_or(&source[..at], |cfg| &source[..cfg])
});
if production.contains(".batch_update(") {
callers.push((
path,
production.contains("BatchUpdateOutcome::AppliedReplyUnreadable"),
));
}
}
assert!(
callers.len() >= 21,
"expected 21 callers, found {callers:?}"
);
let unhandled: Vec<_> = callers
.iter()
.filter(|(_, handled)| !handled)
.map(|(path, _)| path.display().to_string())
.collect();
assert!(
unhandled.is_empty(),
"these batch_update callers never handle AppliedReplyUnreadable: {unhandled:?}"
);
}
#[test]
fn urls_respect_a_wiremock_style_base_with_a_port() {
let url = build_values_get_url(
"http://127.0.0.1:9123",
"s",
"'My Sheet'!A1",
ValueRenderOption::Formatted,
)
.unwrap();
assert_eq!(url.host_str(), Some("127.0.0.1"));
assert_eq!(url.port(), Some(9123));
assert!(url.path().starts_with("/v4/spreadsheets/s/values/"));
}
#[test]
fn render_option_default_is_formatted() {
assert_eq!(ValueRenderOption::default(), ValueRenderOption::Formatted);
}
}