omni_dev/drive/slides/
api.rs1use super::client::SlidesClient;
3use super::types::Presentation;
4use super::write_types::{
5 BatchUpdatePresentationRequest, BatchUpdatePresentationResponse, SlidesRequest,
6};
7use crate::drive::api_client::GoogleApiClient;
8use crate::drive::error::DriveError;
9use crate::drive::files_api::{append_write_scope_hint, WriteCapability};
10use anyhow::{Context, Result};
11use url::Url;
12
13const MAX_PRESENTATION_BYTES: usize = 64 * 1024 * 1024;
14
15#[derive(Debug)]
17pub struct SlidesApi<'a> {
18 client: &'a SlidesClient,
19}
20impl<'a> SlidesApi<'a> {
21 pub const fn new(client: &'a SlidesClient) -> Self {
23 Self { client }
24 }
25
26 pub async fn get_presentation(&self, id: &str) -> Result<Presentation> {
28 let url = build_presentation_get_url(self.client.base_url(), id)?;
29 let response = self.client.transport().get_json(url.as_str()).await?;
30 if !response.status().is_success() {
31 return Err(GoogleApiClient::response_to_error("Slides", response)
32 .await
33 .into());
34 }
35 let bytes = bounded_body(response, MAX_PRESENTATION_BYTES).await?;
36 serde_json::from_slice(&bytes).context("Failed to parse Slides presentation")
37 }
38
39 pub(in crate::drive) async fn batch_update(
42 &self,
43 id: &str,
44 request: SlidesRequest,
45 revision: &str,
46 ) -> Result<Result<BatchUpdatePresentationResponse, String>> {
47 anyhow::ensure!(
48 !revision.is_empty(),
49 "Slides requires a nonempty revision id"
50 );
51 let mut url = GoogleApiClient::api_url(self.client.base_url(), "/v1/presentations")?;
52 GoogleApiClient::push_path_segments(&mut url, &[&format!("{id}:batchUpdate")])?;
53 let body = BatchUpdatePresentationRequest::new(request, revision);
54 let response = self
55 .client
56 .transport()
57 .post_json(url.as_str(), &body)
58 .await?;
59 self.client
60 .transport()
61 .parse_success_response(response, "Failed to parse Slides batchUpdate response")
62 .await
63 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
64 }
65}
66
67fn build_presentation_get_url(base: &str, id: &str) -> Result<Url> {
68 let mut url =
69 GoogleApiClient::api_url(base, "/v1/presentations").context("Invalid Slides base URL")?;
70 GoogleApiClient::push_path_segments(&mut url, &[id])?;
71 Ok(url)
72}
73
74async fn bounded_body(mut response: reqwest::Response, cap: usize) -> Result<Vec<u8>> {
76 anyhow::ensure!(
77 response.content_length().is_none_or(|n| n <= cap as u64),
78 "Slides presentation exceeds the {cap} byte cap; use `omni-dev drive read <presentation-id> --content` for a text export"
79 );
80 let mut bytes = Vec::new();
81 while let Some(chunk) = response
82 .chunk()
83 .await
84 .context("Failed to read Slides presentation")?
85 {
86 anyhow::ensure!(
87 chunk.len() <= cap.saturating_sub(bytes.len()),
88 "Slides presentation exceeds the {cap} byte cap; use `omni-dev drive read <presentation-id> --content` for a text export"
89 );
90 bytes.extend_from_slice(&chunk);
91 }
92 Ok(bytes)
93}
94
95pub(in crate::drive) fn is_stale_revision(err: &anyhow::Error) -> bool {
98 matches!(err.downcast_ref::<DriveError>(), Some(DriveError::ApiRequestFailed { status: 400, body, .. }) if body.to_ascii_lowercase().contains("does not match the latest revision"))
99}
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used, clippy::expect_used)]
103mod tests {
104 use super::*;
105 #[test]
106 fn get_url_is_encoded_and_unmasked() {
107 let url = build_presentation_get_url("https://slides.googleapis.com", "a/b?c").unwrap();
108 assert_eq!(url.path(), "/v1/presentations/a%2Fb%3Fc");
109 assert_eq!(url.query(), None);
110 }
111 #[tokio::test]
112 async fn oversized_body_is_refused() {
113 let server = wiremock::MockServer::start().await;
114 wiremock::Mock::given(wiremock::matchers::method("GET"))
115 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("123456789"))
116 .mount(&server)
117 .await;
118 let response = reqwest::get(server.uri()).await.unwrap();
119 assert!(bounded_body(response, 8)
120 .await
121 .unwrap_err()
122 .to_string()
123 .contains("byte cap"));
124 }
125 #[tokio::test]
126 async fn chunked_body_cannot_bypass_the_decoded_size_bound() {
127 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
128 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
129 let addr = listener.local_addr().unwrap();
130 let server = tokio::spawn(async move {
131 let (mut socket, _) = listener.accept().await.unwrap();
132 let mut request = [0; 1024];
133 assert!(socket.read(&mut request).await.unwrap() > 0);
134 socket.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\n12345\r\n5\r\n67890\r\n0\r\n\r\n").await.unwrap();
135 });
136 let response = reqwest::get(format!("http://{addr}")).await.unwrap();
137 assert_eq!(response.content_length(), None);
138 assert!(bounded_body(response, 8)
139 .await
140 .unwrap_err()
141 .to_string()
142 .contains("byte cap"));
143 server.await.unwrap();
144 }
145
146 #[test]
147 fn omitted_zero_reply_count_is_zero() {
148 let response: BatchUpdatePresentationResponse =
149 serde_json::from_str(r#"{"replies":[{"replaceAllText":{}}]}"#).unwrap();
150 assert_eq!(response.occurrences_changed_for_replace(), 0);
151 }
152}