Skip to main content

omni_dev/drive/slides/
api.rs

1//! Slides endpoints. Full unmasked reads preserve nested groups and notes.
2use 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/// Slides API facade over a shared OAuth session.
16#[derive(Debug)]
17pub struct SlidesApi<'a> {
18    client: &'a SlidesClient,
19}
20impl<'a> SlidesApi<'a> {
21    /// Wraps an existing client.
22    pub const fn new(client: &'a SlidesClient) -> Self {
23        Self { client }
24    }
25
26    /// Fetches every slide, template and notes page without a fields mask.
27    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    /// One request with a mandatory revision assertion, behind the Drive gate.
40    /// An unreadable 2xx reply still means the mutation was applied (issue #2021).
41    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
74// Enforce the actual decoded-body bound even with chunked transfer encoding.
75async 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
95/// Conservative diagnostic only: an unfamiliar revision error remains Failed.
96/// The 400 status is documented; this message marker is not live-verified.
97pub(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}