1use anyhow::{Context, Result};
8use url::Url;
9
10use crate::drive::api_client::GoogleApiClient;
11use crate::drive::files_api::{append_write_scope_hint, WriteCapability};
12use crate::drive::sheets::a1;
13use crate::drive::sheets::client::SheetsClient;
14use crate::drive::sheets::types::{
15 AppendValuesResponse, BatchGetValuesResponse, BatchUpdateRequest, BatchUpdateRequestItem,
16 BatchUpdateResponse, ClearValuesResponse, CopySheetToAnotherSpreadsheetRequest, DataFilter,
17 SearchDeveloperMetadataRequest, SearchDeveloperMetadataResponse, SheetProperties, Spreadsheet,
18 UpdateValuesResponse, ValueRange,
19};
20
21const SPREADSHEET_FIELDS: &str = "spreadsheetId,\
40 properties(title,locale,timeZone,autoRecalc,iterativeCalculationSettings),\
41 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines))";
42
43const SPREADSHEET_FIELDS_WITH_PROTECTIONS: &str = "spreadsheetId,properties.title,\
50 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
51 sheets.protectedRanges(protectedRangeId,range,description,warningOnly,editors.users)";
52
53const SPREADSHEET_FIELDS_WITH_FILTER_VIEWS: &str = "spreadsheetId,properties.title,\
60 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
61 sheets.basicFilter(range,sortSpecs,criteria),\
62 sheets.filterViews(filterViewId,title,range,namedRangeId,tableId,sortSpecs,criteria)";
63
64const SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS: &str = "spreadsheetId,properties.title,\
73 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
74 sheets.conditionalFormats(ranges,booleanRule,gradientRule)";
75
76const SPREADSHEET_FIELDS_WITH_NAMED_RANGES: &str = "spreadsheetId,properties.title,\
86 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
87 namedRanges(namedRangeId,name,range)";
88
89const SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS: &str = "spreadsheetId,properties.title,\
103 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
104 sheets.charts,sheets.slicers";
105
106const SPREADSHEET_FIELDS_WITH_PIVOT_TABLES: &str = "spreadsheetId,properties.title,\
116 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
117 sheets.data(startRow,startColumn,rowData.values(pivotTable))";
118
119const CELL_PIVOT_FIELDS: &str = "spreadsheetId,properties.title,\
127 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
128 sheets.data(startRow,startColumn,rowData.values(pivotTable,formattedValue))";
129
130const CELL_FORMAT_FIELDS: &str = "spreadsheetId,properties.title,\
153 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
154 sheets.data(startRow,startColumn,rowData.values(userEnteredFormat(backgroundColorStyle,textFormat(bold,italic,strikethrough,underline,foregroundColorStyle),numberFormat,horizontalAlignment,borders(top,bottom,left,right)),note,dataValidation))";
155
156const SPREADSHEET_FIELDS_WITH_BANDING: &str = "spreadsheetId,properties.title,\
163 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
164 sheets.bandedRanges(bandedRangeId,range,rowProperties,columnProperties)";
165
166const SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS: &str = "spreadsheetId,properties.title,\
173 sheets.properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)),\
174 sheets.rowGroups(range,depth,collapsed),sheets.columnGroups(range,depth,collapsed)";
175
176pub(crate) const MAX_RANGES_PER_BATCH: usize = 50;
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
190pub enum ValueRenderOption {
191 #[default]
196 Formatted,
197 Unformatted,
201 Formula,
203}
204
205impl ValueRenderOption {
206 #[must_use]
208 pub fn as_str(self) -> &'static str {
209 match self {
210 Self::Formatted => "FORMATTED_VALUE",
211 Self::Unformatted => "UNFORMATTED_VALUE",
212 Self::Formula => "FORMULA",
213 }
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
221pub enum ValueInputOption {
222 #[default]
234 UserEntered,
235 Raw,
238}
239
240impl ValueInputOption {
241 #[must_use]
243 pub fn as_str(self) -> &'static str {
244 match self {
245 Self::UserEntered => "USER_ENTERED",
246 Self::Raw => "RAW",
247 }
248 }
249}
250
251#[derive(Debug)]
253pub struct SheetsApi<'a> {
254 client: &'a SheetsClient,
255}
256
257impl<'a> SheetsApi<'a> {
258 #[must_use]
260 pub fn new(client: &'a SheetsClient) -> Self {
261 Self { client }
262 }
263
264 pub async fn get_spreadsheet(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
268 let url = build_spreadsheet_get_url(self.client.base_url(), spreadsheet_id)?;
269 self.client
270 .transport()
271 .get_parsed(url.as_str(), "Failed to parse Sheets spreadsheet metadata")
272 .await
273 }
274
275 pub async fn get_spreadsheet_with_protections(
279 &self,
280 spreadsheet_id: &str,
281 ) -> Result<Spreadsheet> {
282 let url =
283 build_spreadsheet_get_with_protections_url(self.client.base_url(), spreadsheet_id)?;
284 self.client
285 .transport()
286 .get_parsed(
287 url.as_str(),
288 "Failed to parse Sheets spreadsheet metadata (with protections)",
289 )
290 .await
291 }
292
293 pub async fn get_spreadsheet_with_filter_views(
297 &self,
298 spreadsheet_id: &str,
299 ) -> Result<Spreadsheet> {
300 let url =
301 build_spreadsheet_get_with_filter_views_url(self.client.base_url(), spreadsheet_id)?;
302 self.client
303 .transport()
304 .get_parsed(
305 url.as_str(),
306 "Failed to parse Sheets spreadsheet metadata (with filter views)",
307 )
308 .await
309 }
310
311 pub async fn get_spreadsheet_with_conditional_formats(
320 &self,
321 spreadsheet_id: &str,
322 ) -> Result<Spreadsheet> {
323 let url = build_spreadsheet_get_with_conditional_formats_url(
324 self.client.base_url(),
325 spreadsheet_id,
326 )?;
327 self.client
328 .transport()
329 .get_parsed(
330 url.as_str(),
331 "Failed to parse Sheets spreadsheet metadata (with conditional formats)",
332 )
333 .await
334 }
335
336 pub async fn get_spreadsheet_with_named_ranges(
340 &self,
341 spreadsheet_id: &str,
342 ) -> Result<Spreadsheet> {
343 let url =
344 build_spreadsheet_get_with_named_ranges_url(self.client.base_url(), spreadsheet_id)?;
345 self.client
346 .transport()
347 .get_parsed(
348 url.as_str(),
349 "Failed to parse Sheets spreadsheet metadata (with named ranges)",
350 )
351 .await
352 }
353
354 pub async fn get_spreadsheet_with_embedded_objects(
360 &self,
361 spreadsheet_id: &str,
362 ) -> Result<Spreadsheet> {
363 let url = build_spreadsheet_get_with_embedded_objects_url(
364 self.client.base_url(),
365 spreadsheet_id,
366 )?;
367 self.client
368 .transport()
369 .get_parsed(
370 url.as_str(),
371 "Failed to parse Sheets spreadsheet metadata (with embedded objects)",
372 )
373 .await
374 }
375
376 pub async fn get_spreadsheet_with_pivot_tables(
380 &self,
381 spreadsheet_id: &str,
382 ) -> Result<Spreadsheet> {
383 let url =
384 build_spreadsheet_get_with_pivot_tables_url(self.client.base_url(), spreadsheet_id)?;
385 self.client
386 .transport()
387 .get_parsed(
388 url.as_str(),
389 "Failed to parse Sheets spreadsheet metadata (with pivot tables)",
390 )
391 .await
392 }
393
394 pub async fn get_spreadsheet_with_banding(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
399 let url = build_spreadsheet_get_with_banding_url(self.client.base_url(), spreadsheet_id)?;
400 self.client
401 .transport()
402 .get_parsed(
403 url.as_str(),
404 "Failed to parse Sheets spreadsheet metadata (with banded ranges)",
405 )
406 .await
407 }
408
409 pub async fn get_spreadsheet_with_dimension_groups(
414 &self,
415 spreadsheet_id: &str,
416 ) -> Result<Spreadsheet> {
417 let url = build_spreadsheet_get_with_dimension_groups_url(
418 self.client.base_url(),
419 spreadsheet_id,
420 )?;
421 self.client
422 .transport()
423 .get_parsed(
424 url.as_str(),
425 "Failed to parse Sheets spreadsheet metadata (with dimension groups)",
426 )
427 .await
428 }
429
430 pub async fn get_cell_pivot(
439 &self,
440 spreadsheet_id: &str,
441 composed_a1: &str,
442 ) -> Result<Spreadsheet> {
443 let url = build_cell_pivot_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
444 self.client
445 .transport()
446 .get_parsed(url.as_str(), "Failed to parse Sheets cell pivot response")
447 .await
448 }
449
450 pub async fn get_cell_formats(
455 &self,
456 spreadsheet_id: &str,
457 composed_a1: &str,
458 ) -> Result<Spreadsheet> {
459 let url = build_cell_format_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
460 self.client
461 .transport()
462 .get_parsed(url.as_str(), "Failed to parse Sheets cell format response")
463 .await
464 }
465
466 pub async fn values_get(
468 &self,
469 spreadsheet_id: &str,
470 range: &str,
471 render: ValueRenderOption,
472 ) -> Result<ValueRange> {
473 let url = build_values_get_url(self.client.base_url(), spreadsheet_id, range, render)?;
474 self.client
475 .transport()
476 .get_parsed(url.as_str(), "Failed to parse Sheets values response")
477 .await
478 }
479
480 pub async fn values_batch_get(
485 &self,
486 spreadsheet_id: &str,
487 ranges: &[String],
488 render: ValueRenderOption,
489 ) -> Result<BatchGetValuesResponse> {
490 let url =
491 build_values_batch_get_url(self.client.base_url(), spreadsheet_id, ranges, render)?;
492 self.client
493 .transport()
494 .get_parsed(url.as_str(), "Failed to parse Sheets batchGet response")
495 .await
496 }
497
498 pub(crate) async fn batch_get_every_sheet(
511 &self,
512 spreadsheet_id: &str,
513 titles: &[String],
514 render: ValueRenderOption,
515 ) -> Result<Vec<(Option<String>, ValueRange)>> {
516 let mut out = Vec::with_capacity(titles.len());
517 for chunk in titles.chunks(MAX_RANGES_PER_BATCH) {
518 let ranges: Vec<String> = chunk.iter().map(|t| a1::quote_sheet_title(t)).collect();
519 let response = self
520 .values_batch_get(spreadsheet_id, &ranges, render)
521 .await?;
522 for value_range in response.value_ranges {
523 let title = value_range.range.as_deref().and_then(a1::sheet_title_of);
524 out.push((title, value_range));
525 }
526 }
527 Ok(out)
528 }
529
530 pub(in crate::drive) async fn values_update(
536 &self,
537 spreadsheet_id: &str,
538 range: &str,
539 values: &[Vec<String>],
540 input: ValueInputOption,
541 ) -> Result<UpdateValuesResponse> {
542 let url = build_values_update_url(self.client.base_url(), spreadsheet_id, range, input)?;
543 let body = serde_json::json!({ "range": range, "values": values });
544 let response = self
545 .client
546 .transport()
547 .put_json(url.as_str(), &body)
548 .await?;
549 self.client
550 .transport()
551 .parse_response(response, "Failed to parse Sheets update response")
552 .await
553 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
554 }
555
556 pub(in crate::drive) async fn values_append(
558 &self,
559 spreadsheet_id: &str,
560 range: &str,
561 values: &[Vec<String>],
562 input: ValueInputOption,
563 ) -> Result<AppendValuesResponse> {
564 let url = build_values_append_url(self.client.base_url(), spreadsheet_id, range, input)?;
565 let body = serde_json::json!({ "range": range, "values": values });
566 let response = self
567 .client
568 .transport()
569 .post_json(url.as_str(), &body)
570 .await?;
571 self.client
572 .transport()
573 .parse_response(response, "Failed to parse Sheets append response")
574 .await
575 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
576 }
577
578 pub(in crate::drive) async fn values_clear(
580 &self,
581 spreadsheet_id: &str,
582 range: &str,
583 ) -> Result<ClearValuesResponse> {
584 let url = build_values_clear_url(self.client.base_url(), spreadsheet_id, range)?;
585 let response = self
586 .client
587 .transport()
588 .post_json(url.as_str(), &serde_json::json!({}))
589 .await?;
590 self.client
591 .transport()
592 .parse_response(response, "Failed to parse Sheets clear response")
593 .await
594 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
595 }
596
597 pub(in crate::drive) async fn batch_update(
609 &self,
610 spreadsheet_id: &str,
611 requests: Vec<BatchUpdateRequestItem>,
612 ) -> Result<BatchUpdateResponse> {
613 let url = build_batch_update_url(self.client.base_url(), spreadsheet_id)?;
614 let body = BatchUpdateRequest { requests };
615 let response = self
616 .client
617 .transport()
618 .post_json(url.as_str(), &body)
619 .await?;
620 self.client
621 .transport()
622 .parse_response(response, "Failed to parse Sheets batchUpdate response")
623 .await
624 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
625 }
626
627 pub(in crate::drive) async fn copy_to(
636 &self,
637 spreadsheet_id: &str,
638 sheet_id: i64,
639 destination_spreadsheet_id: &str,
640 ) -> Result<SheetProperties> {
641 let url = build_copy_to_url(self.client.base_url(), spreadsheet_id, sheet_id)?;
642 let body = CopySheetToAnotherSpreadsheetRequest {
643 destination_spreadsheet_id: destination_spreadsheet_id.to_string(),
644 };
645 let response = self
646 .client
647 .transport()
648 .post_json(url.as_str(), &body)
649 .await?;
650 self.client
651 .transport()
652 .parse_response(response, "Failed to parse Sheets copyTo response")
653 .await
654 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
655 }
656
657 pub async fn search_developer_metadata(
668 &self,
669 spreadsheet_id: &str,
670 filters: Vec<DataFilter>,
671 ) -> Result<SearchDeveloperMetadataResponse> {
672 let url = build_developer_metadata_search_url(self.client.base_url(), spreadsheet_id)?;
673 let body = SearchDeveloperMetadataRequest {
674 data_filters: filters,
675 };
676 let response = self
677 .client
678 .transport()
679 .post_json(url.as_str(), &body)
680 .await?;
681 self.client
682 .transport()
683 .parse_response(
684 response,
685 "Failed to parse Sheets developerMetadata.search response",
686 )
687 .await
688 }
689}
690
691fn build_spreadsheet_get_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
692 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
693 .context("Invalid Sheets base URL")?;
694 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
695 url.query_pairs_mut()
696 .append_pair("fields", SPREADSHEET_FIELDS);
697 Ok(url)
698}
699
700fn build_spreadsheet_get_with_protections_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
701 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
702 .context("Invalid Sheets base URL")?;
703 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
704 url.query_pairs_mut()
705 .append_pair("fields", SPREADSHEET_FIELDS_WITH_PROTECTIONS);
706 Ok(url)
707}
708
709fn build_spreadsheet_get_with_filter_views_url(
710 base_url: &str,
711 spreadsheet_id: &str,
712) -> Result<Url> {
713 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
714 .context("Invalid Sheets base URL")?;
715 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
716 url.query_pairs_mut()
717 .append_pair("fields", SPREADSHEET_FIELDS_WITH_FILTER_VIEWS);
718 Ok(url)
719}
720
721fn build_spreadsheet_get_with_conditional_formats_url(
722 base_url: &str,
723 spreadsheet_id: &str,
724) -> Result<Url> {
725 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
726 .context("Invalid Sheets base URL")?;
727 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
728 url.query_pairs_mut()
729 .append_pair("fields", SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS);
730 Ok(url)
731}
732
733fn build_spreadsheet_get_with_named_ranges_url(
734 base_url: &str,
735 spreadsheet_id: &str,
736) -> Result<Url> {
737 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
738 .context("Invalid Sheets base URL")?;
739 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
740 url.query_pairs_mut()
741 .append_pair("fields", SPREADSHEET_FIELDS_WITH_NAMED_RANGES);
742 Ok(url)
743}
744
745fn build_spreadsheet_get_with_embedded_objects_url(
746 base_url: &str,
747 spreadsheet_id: &str,
748) -> Result<Url> {
749 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
750 .context("Invalid Sheets base URL")?;
751 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
752 url.query_pairs_mut()
753 .append_pair("fields", SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS);
754 Ok(url)
755}
756
757fn build_spreadsheet_get_with_pivot_tables_url(
758 base_url: &str,
759 spreadsheet_id: &str,
760) -> Result<Url> {
761 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
762 .context("Invalid Sheets base URL")?;
763 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
764 url.query_pairs_mut()
765 .append_pair("fields", SPREADSHEET_FIELDS_WITH_PIVOT_TABLES);
766 Ok(url)
767}
768
769fn build_spreadsheet_get_with_banding_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
770 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
771 .context("Invalid Sheets base URL")?;
772 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
773 url.query_pairs_mut()
774 .append_pair("fields", SPREADSHEET_FIELDS_WITH_BANDING);
775 Ok(url)
776}
777
778fn build_spreadsheet_get_with_dimension_groups_url(
779 base_url: &str,
780 spreadsheet_id: &str,
781) -> Result<Url> {
782 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
783 .context("Invalid Sheets base URL")?;
784 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
785 url.query_pairs_mut()
786 .append_pair("fields", SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS);
787 Ok(url)
788}
789
790fn build_cell_pivot_get_url(
791 base_url: &str,
792 spreadsheet_id: &str,
793 composed_a1: &str,
794) -> Result<Url> {
795 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
796 .context("Invalid Sheets base URL")?;
797 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
798 url.query_pairs_mut()
799 .append_pair("ranges", composed_a1)
800 .append_pair("fields", CELL_PIVOT_FIELDS);
801 Ok(url)
802}
803
804fn build_cell_format_get_url(
805 base_url: &str,
806 spreadsheet_id: &str,
807 composed_a1: &str,
808) -> Result<Url> {
809 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
810 .context("Invalid Sheets base URL")?;
811 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
812 url.query_pairs_mut()
813 .append_pair("ranges", composed_a1)
814 .append_pair("fields", CELL_FORMAT_FIELDS);
815 Ok(url)
816}
817
818fn build_values_get_url(
819 base_url: &str,
820 spreadsheet_id: &str,
821 range: &str,
822 render: ValueRenderOption,
823) -> Result<Url> {
824 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
825 .context("Invalid Sheets base URL")?;
826 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
827 url.query_pairs_mut()
828 .append_pair("valueRenderOption", render.as_str());
829 Ok(url)
830}
831
832fn build_values_batch_get_url(
833 base_url: &str,
834 spreadsheet_id: &str,
835 ranges: &[String],
836 render: ValueRenderOption,
837) -> Result<Url> {
838 anyhow::ensure!(
839 !ranges.is_empty(),
840 "values.batchGet requires at least one range"
841 );
842 anyhow::ensure!(
843 ranges.len() <= MAX_RANGES_PER_BATCH,
844 "values.batchGet was given {} ranges, over the {MAX_RANGES_PER_BATCH} per-request cap; \
845 callers must chunk",
846 ranges.len()
847 );
848 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
849 .context("Invalid Sheets base URL")?;
850 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values:batchGet"])?;
854 {
855 let mut pairs = url.query_pairs_mut();
856 for range in ranges {
857 pairs.append_pair("ranges", range);
858 }
859 pairs.append_pair("valueRenderOption", render.as_str());
860 }
861 Ok(url)
862}
863
864fn build_values_update_url(
865 base_url: &str,
866 spreadsheet_id: &str,
867 range: &str,
868 input: ValueInputOption,
869) -> Result<Url> {
870 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
871 .context("Invalid Sheets base URL")?;
872 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
873 url.query_pairs_mut()
874 .append_pair("valueInputOption", input.as_str());
875 Ok(url)
876}
877
878fn build_values_append_url(
879 base_url: &str,
880 spreadsheet_id: &str,
881 range: &str,
882 input: ValueInputOption,
883) -> Result<Url> {
884 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
885 .context("Invalid Sheets base URL")?;
886 GoogleApiClient::push_path_segments(
887 &mut url,
888 &[spreadsheet_id, "values", &format!("{range}:append")],
889 )?;
890 {
891 let mut pairs = url.query_pairs_mut();
892 pairs.append_pair("valueInputOption", input.as_str());
893 pairs.append_pair("insertDataOption", "INSERT_ROWS");
896 }
897 Ok(url)
898}
899
900fn build_values_clear_url(base_url: &str, spreadsheet_id: &str, range: &str) -> Result<Url> {
901 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
902 .context("Invalid Sheets base URL")?;
903 GoogleApiClient::push_path_segments(
904 &mut url,
905 &[spreadsheet_id, "values", &format!("{range}:clear")],
906 )?;
907 Ok(url)
908}
909
910fn build_batch_update_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
917 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
918 .context("Invalid Sheets base URL")?;
919 GoogleApiClient::push_path_segments(&mut url, &[&format!("{spreadsheet_id}:batchUpdate")])?;
920 Ok(url)
921}
922
923fn build_copy_to_url(base_url: &str, spreadsheet_id: &str, sheet_id: i64) -> Result<Url> {
929 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
930 .context("Invalid Sheets base URL")?;
931 GoogleApiClient::push_path_segments(
932 &mut url,
933 &[spreadsheet_id, "sheets", &format!("{sheet_id}:copyTo")],
934 )?;
935 Ok(url)
936}
937
938fn build_developer_metadata_search_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
944 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
945 .context("Invalid Sheets base URL")?;
946 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "developerMetadata:search"])?;
947 Ok(url)
948}
949
950#[cfg(test)]
951#[allow(clippy::unwrap_used, clippy::expect_used)]
952mod tests {
953 use super::*;
954
955 const BASE: &str = "https://sheets.googleapis.com";
956
957 #[test]
960 fn spreadsheet_get_url_masks_fields() {
961 let url = build_spreadsheet_get_url(BASE, "sheet-1").unwrap();
962 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
963 let fields = url
964 .query_pairs()
965 .find(|(k, _)| k == "fields")
966 .map(|(_, v)| v.to_string())
967 .expect("fields mask must always be sent");
968 assert!(fields.contains("sheets.properties"));
969 assert!(fields.contains("title"));
970 assert!(fields.contains("frozenRowCount"));
973 }
974
975 #[test]
976 fn spreadsheet_get_with_conditional_formats_url_masks_the_wider_fields() {
977 let url = build_spreadsheet_get_with_conditional_formats_url(BASE, "sheet-1").unwrap();
978 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
979 let fields = url
980 .query_pairs()
981 .find(|(k, _)| k == "fields")
982 .map(|(_, v)| v.to_string())
983 .expect("fields mask must always be sent");
984 assert!(fields.contains("sheets.conditionalFormats"));
985 assert!(fields.contains("booleanRule"));
986 assert!(fields.contains("gradientRule"));
987 assert!(fields.contains("frozenRowCount"));
990 }
991
992 #[tokio::test]
993 async fn get_spreadsheet_with_embedded_objects_surfaces_an_unparseable_base_url() {
994 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
999 use crate::drive::client::DriveClient;
1000 use crate::drive::sheets::client::SHEETS_API_URL;
1001 use crate::test_support::env::MapEnv;
1002 use crate::utils::secret::Secret;
1003
1004 let credentials = DriveCredentials {
1005 client_id: "client-1".to_string(),
1006 client_secret: Secret::new("secret-1"),
1007 refresh_token: Secret::new("refresh-1"),
1008 scope: DriveGrantedScopes::READONLY,
1009 };
1010 let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
1011 let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
1012 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1013
1014 let err = SheetsApi::new(&sheets)
1015 .get_spreadsheet_with_embedded_objects("sheet-1")
1016 .await
1017 .unwrap_err();
1018 assert!(
1019 err.to_string().contains("Invalid Sheets base URL"),
1020 "{err:#}"
1021 );
1022 }
1023
1024 #[test]
1025 fn spreadsheet_get_with_embedded_objects_url_masks_the_wider_fields() {
1026 let url = build_spreadsheet_get_with_embedded_objects_url(BASE, "sheet-1").unwrap();
1027 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1028 let fields = url
1029 .query_pairs()
1030 .find(|(k, _)| k == "fields")
1031 .map(|(_, v)| v.to_string())
1032 .expect("fields mask must always be sent");
1033 assert!(fields.contains("sheets.charts"));
1034 assert!(fields.contains("sheets.slicers"));
1035 assert!(fields.contains("frozenRowCount"));
1038 }
1039
1040 #[test]
1041 fn spreadsheet_get_with_pivot_tables_url_masks_the_wider_fields() {
1042 let url = build_spreadsheet_get_with_pivot_tables_url(BASE, "sheet-1").unwrap();
1043 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1044 let fields = url
1045 .query_pairs()
1046 .find(|(k, _)| k == "fields")
1047 .map(|(_, v)| v.to_string())
1048 .expect("fields mask must always be sent");
1049 assert!(fields.contains("sheets.data"));
1050 assert!(fields.contains("pivotTable"));
1051 assert!(!fields.contains("formattedValue"));
1052 assert!(fields.contains("frozenRowCount"));
1055 }
1056
1057 #[tokio::test]
1058 async fn get_spreadsheet_with_dimension_groups_surfaces_an_unparseable_base_url() {
1059 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1064 use crate::drive::client::DriveClient;
1065 use crate::drive::sheets::client::SHEETS_API_URL;
1066 use crate::test_support::env::MapEnv;
1067 use crate::utils::secret::Secret;
1068
1069 let credentials = DriveCredentials {
1070 client_id: "client-1".to_string(),
1071 client_secret: Secret::new("secret-1"),
1072 refresh_token: Secret::new("refresh-1"),
1073 scope: DriveGrantedScopes::READONLY,
1074 };
1075 let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
1076 let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
1077 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1078
1079 let err = SheetsApi::new(&sheets)
1080 .get_spreadsheet_with_dimension_groups("sheet-1")
1081 .await
1082 .unwrap_err();
1083 assert!(
1084 err.to_string().contains("Invalid Sheets base URL"),
1085 "{err:#}"
1086 );
1087 }
1088
1089 #[test]
1090 fn spreadsheet_get_with_dimension_groups_url_masks_the_wider_fields() {
1091 let url = build_spreadsheet_get_with_dimension_groups_url(BASE, "sheet-1").unwrap();
1092 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1093 let fields = url
1094 .query_pairs()
1095 .find(|(k, _)| k == "fields")
1096 .map(|(_, v)| v.to_string())
1097 .expect("fields mask must always be sent");
1098 assert!(fields.contains("rowGroups"));
1099 assert!(fields.contains("columnGroups"));
1100 }
1101
1102 #[test]
1103 fn cell_pivot_get_url_scopes_ranges_and_masks_fields() {
1104 let url = build_cell_pivot_get_url(BASE, "sheet-1", "'Report'!A1").unwrap();
1105 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1106 let ranges = url
1107 .query_pairs()
1108 .find(|(k, _)| k == "ranges")
1109 .map(|(_, v)| v.to_string())
1110 .expect("ranges must always be sent");
1111 assert_eq!(ranges, "'Report'!A1");
1112 let fields = url
1113 .query_pairs()
1114 .find(|(k, _)| k == "fields")
1115 .map(|(_, v)| v.to_string())
1116 .expect("fields mask must always be sent");
1117 assert!(fields.contains("pivotTable"));
1118 assert!(fields.contains("formattedValue"));
1119 assert!(fields.contains("frozenRowCount"));
1122 }
1123
1124 #[test]
1125 fn cell_format_get_url_scopes_ranges_and_masks_fields() {
1126 let url = build_cell_format_get_url(BASE, "sheet-1", "'Report'!A1:D10").unwrap();
1127 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1128 let ranges = url
1129 .query_pairs()
1130 .find(|(k, _)| k == "ranges")
1131 .map(|(_, v)| v.to_string())
1132 .expect("ranges must always be sent");
1133 assert_eq!(ranges, "'Report'!A1:D10");
1134 let fields = url
1135 .query_pairs()
1136 .find(|(k, _)| k == "fields")
1137 .map(|(_, v)| v.to_string())
1138 .expect("fields mask must always be sent");
1139 assert!(fields.contains("userEnteredFormat"));
1140 assert!(fields.contains("backgroundColorStyle"));
1141 assert!(fields.contains("borders(top,bottom,left,right)"));
1142 assert!(fields.contains("note"));
1143 assert!(fields.contains("dataValidation"));
1144 assert!(!fields.contains("effectiveFormat"));
1148 assert!(!fields.contains("formattedValue"));
1149 assert!(fields.contains("frozenRowCount"));
1150 }
1151
1152 #[test]
1155 fn values_get_url_percent_encodes_a_space_in_a_quoted_title() {
1156 let url = build_values_get_url(
1157 BASE,
1158 "sheet-1",
1159 "'My Sheet'!A1:B2",
1160 ValueRenderOption::Formatted,
1161 )
1162 .unwrap();
1163 assert!(
1164 url.as_str().contains("'My%20Sheet'!A1:B2"),
1165 "space must be encoded: {url}"
1166 );
1167 }
1168
1169 #[test]
1170 fn values_get_url_encodes_characters_that_would_reshape_the_url() {
1171 for (title, encoded) in [("A#B", "%23"), ("A?B", "%3F"), ("A/B", "%2F")] {
1175 let range = format!("'{title}'!A1");
1176 let url =
1177 build_values_get_url(BASE, "s", &range, ValueRenderOption::Formatted).unwrap();
1178 assert!(
1179 url.as_str().contains(encoded),
1180 "{title:?} must encode to {encoded}: {url}"
1181 );
1182 assert!(url.fragment().is_none(), "{title:?} leaked a fragment");
1183 assert!(url.query().is_some_and(|q| !q.contains("!A1")));
1184 let segments: Vec<&str> = url.path_segments().unwrap().collect();
1187 assert_eq!(segments.len(), 5, "{title:?} split the path: {url}");
1188 assert_eq!(segments[3], "values", "{title:?} shifted the path: {url}");
1189 }
1190 }
1191
1192 #[test]
1193 fn values_get_url_round_trips_the_range_through_decoding() {
1194 let range = "'Bob''s Sheet'!A1:C9";
1195 let url = build_values_get_url(BASE, "s", range, ValueRenderOption::Formatted).unwrap();
1196 let decoded = url
1197 .path_segments()
1198 .unwrap()
1199 .next_back()
1200 .map(percent_decode)
1201 .unwrap();
1202 assert_eq!(decoded, range);
1203 }
1204
1205 fn percent_decode(segment: &str) -> String {
1206 percent_encoding::percent_decode_str(segment)
1207 .decode_utf8()
1208 .unwrap()
1209 .to_string()
1210 }
1211
1212 #[test]
1213 fn values_get_url_sends_the_render_option() {
1214 for (render, wire) in [
1215 (ValueRenderOption::Formatted, "FORMATTED_VALUE"),
1216 (ValueRenderOption::Unformatted, "UNFORMATTED_VALUE"),
1217 (ValueRenderOption::Formula, "FORMULA"),
1218 ] {
1219 let url = build_values_get_url(BASE, "s", "A1", render).unwrap();
1220 let got = url
1221 .query_pairs()
1222 .find(|(k, _)| k == "valueRenderOption")
1223 .map(|(_, v)| v.to_string())
1224 .unwrap();
1225 assert_eq!(got, wire);
1226 }
1227 }
1228
1229 #[test]
1232 fn batch_get_url_repeats_the_ranges_parameter() {
1233 let ranges = vec!["'A'!A1:B2".to_string(), "'B'!A1".to_string()];
1234 let url =
1235 build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted).unwrap();
1236 assert_eq!(url.path(), "/v4/spreadsheets/s/values:batchGet");
1237 let got: Vec<String> = url
1238 .query_pairs()
1239 .filter(|(k, _)| k == "ranges")
1240 .map(|(_, v)| v.to_string())
1241 .collect();
1242 assert_eq!(got, ranges);
1243 }
1244
1245 #[test]
1246 fn batch_get_url_keeps_the_colon_suffix_unencoded() {
1247 let url = build_values_batch_get_url(
1248 BASE,
1249 "s",
1250 &["A1".to_string()],
1251 ValueRenderOption::Formatted,
1252 )
1253 .unwrap();
1254 assert!(
1255 url.as_str().contains("/values:batchGet?"),
1256 "the :batchGet suffix must survive path encoding: {url}"
1257 );
1258 }
1259
1260 #[test]
1261 fn batch_get_url_rejects_an_empty_range_list() {
1262 let err =
1263 build_values_batch_get_url(BASE, "s", &[], ValueRenderOption::Formatted).unwrap_err();
1264 assert!(err.to_string().contains("at least one range"), "{err}");
1265 }
1266
1267 #[test]
1268 fn batch_get_url_rejects_more_ranges_than_the_cap() {
1269 let ranges: Vec<String> = (0..=MAX_RANGES_PER_BATCH)
1270 .map(|i| format!("S{i}"))
1271 .collect();
1272 let err = build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted)
1273 .unwrap_err();
1274 assert!(err.to_string().contains("per-request cap"), "{err}");
1275 }
1276
1277 #[test]
1280 fn update_url_sends_the_value_input_option() {
1281 let url =
1282 build_values_update_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
1283 assert_eq!(url.path(), "/v4/spreadsheets/s/values/A1:B2");
1284 let input = url
1285 .query_pairs()
1286 .find(|(k, _)| k == "valueInputOption")
1287 .map(|(_, v)| v.to_string());
1288 assert_eq!(input, Some("USER_ENTERED".to_string()));
1289 }
1290
1291 #[test]
1292 fn update_url_percent_encodes_a_quoted_sheet_title() {
1293 let url =
1294 build_values_update_url(BASE, "s", "'My Sheet'!A1", ValueInputOption::Raw).unwrap();
1295 assert!(url.path().contains("'My%20Sheet'"), "{url}");
1296 }
1297
1298 #[test]
1299 fn append_url_keeps_the_colon_suffix_and_sets_insert_rows() {
1300 let url =
1301 build_values_append_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
1302 assert!(url.as_str().contains("/values/A1:B2:append"), "{url}");
1303 let insert_data_option = url
1304 .query_pairs()
1305 .find(|(k, _)| k == "insertDataOption")
1306 .map(|(_, v)| v.to_string());
1307 assert_eq!(insert_data_option, Some("INSERT_ROWS".to_string()));
1308 let input = url
1309 .query_pairs()
1310 .find(|(k, _)| k == "valueInputOption")
1311 .map(|(_, v)| v.to_string());
1312 assert_eq!(input, Some("USER_ENTERED".to_string()));
1313 }
1314
1315 #[test]
1316 fn clear_url_keeps_the_colon_suffix_and_sends_no_query() {
1317 let url = build_values_clear_url(BASE, "s", "A1:B2").unwrap();
1318 assert!(url.as_str().contains("/values/A1:B2:clear"), "{url}");
1319 assert!(url.query().is_none(), "{url}");
1320 }
1321
1322 #[test]
1325 fn copy_to_url_keeps_the_colon_suffix_on_the_sheet_id_segment() {
1326 let url = build_copy_to_url(BASE, "dest-or-source-id", 12345).unwrap();
1327 assert_eq!(
1328 url.path(),
1329 "/v4/spreadsheets/dest-or-source-id/sheets/12345:copyTo"
1330 );
1331 assert!(url.query().is_none(), "{url}");
1332 }
1333
1334 #[test]
1337 fn urls_respect_a_wiremock_style_base_with_a_port() {
1338 let url = build_values_get_url(
1339 "http://127.0.0.1:9123",
1340 "s",
1341 "'My Sheet'!A1",
1342 ValueRenderOption::Formatted,
1343 )
1344 .unwrap();
1345 assert_eq!(url.host_str(), Some("127.0.0.1"));
1346 assert_eq!(url.port(), Some(9123));
1347 assert!(url.path().starts_with("/v4/spreadsheets/s/values/"));
1348 }
1349
1350 #[test]
1351 fn render_option_default_is_formatted() {
1352 assert_eq!(ValueRenderOption::default(), ValueRenderOption::Formatted);
1353 }
1354}