Skip to main content

omni_dev/drive/slides/
client.rs

1//! Slides v1 REST API client.
2//!
3//! A typed wrapper around [`GoogleApiClient`] — the same transport
4//! `DriveClient` and `SheetsClient` use — pinned to `slides.googleapis.com`.
5//! Everything interesting (OAuth session, 401 refresh-and-retry, quota
6//! backoff, request logging, error-envelope parsing) is inherited, so this
7//! module is only a default host, an env override, and a distinct *type*.
8//!
9//! That distinct type is the point, not incidental: `FilesApi::new` takes a
10//! `&DriveClient`, so a Slides-hosted client cannot be handed to it and
11//! silently issue `/drive/v3/files` against the Slides host. A shared
12//! `DriveClient` re-based onto another URL would compile.
13
14use anyhow::Result;
15
16use crate::drive::api_client::GoogleApiClient;
17use crate::drive::auth::DriveCredentials;
18use crate::drive::client::{DriveClient, SERVICE_TAG};
19use crate::utils::env::{EnvSource, SystemEnv};
20
21/// Environment variable overriding the Slides API host wholesale.
22///
23/// Mirrors `DRIVE_API_URL`/`SHEETS_API_URL`/`GMAIL_API_URL`; process-env
24/// only, never written to `settings.json`. Used to point at a wiremock server
25/// or route through a forced egress proxy.
26pub const SLIDES_API_URL: &str = "SLIDES_API_URL";
27
28/// Human-readable API name for Slides error messages.
29///
30/// The request-log tag stays `SERVICE_TAG` (`"drive"`) so one feature's
31/// traffic is not split across multiple services; only the *message* differs.
32const API_NAME: &str = "Slides";
33
34/// HTTP client for the Slides v1 REST API.
35pub struct SlidesClient {
36    inner: GoogleApiClient,
37}
38
39impl std::fmt::Debug for SlidesClient {
40    // Hand-written for the same reason `DriveClient`'s is: never mention the
41    // session, rather than trusting every nested `Secret` to stay wrapped.
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("SlidesClient")
44            .field("base_url", &self.inner.base_url())
45            .finish_non_exhaustive()
46    }
47}
48
49impl SlidesClient {
50    /// The real Slides API host.
51    ///
52    /// Unlike Drive — which lives on the general `www.googleapis.com` with a
53    /// `/drive/v3/...` prefix — Slides has its own subdomain, so no base-URL
54    /// tweak to `DriveClient` could ever reach it.
55    const DEFAULT_BASE_URL: &'static str = "https://slides.googleapis.com";
56
57    /// Builds a client against `base_url` with already-loaded credentials.
58    ///
59    /// Prefer [`Self::from_drive_client`] when a `DriveClient` is already in
60    /// hand: this constructor opens a *second* OAuth session, which means a
61    /// second token refresh per invocation.
62    pub fn new(base_url: &str, credentials: &DriveCredentials) -> Result<Self> {
63        Ok(Self {
64            inner: GoogleApiClient::new(base_url, credentials, SERVICE_TAG, API_NAME)?,
65        })
66    }
67
68    /// Derives a Slides client from a Drive client, sharing its OAuth session
69    /// and connection pool.
70    ///
71    /// This is the constructor every real caller wants. A `slides replace` has
72    /// to talk to *both* APIs — Drive for `files.get` and the ancestor-chain
73    /// walk that feeds the write gate, Slides for the text — and two
74    /// independent sessions would refresh the same refresh token twice for
75    /// one command.
76    ///
77    /// Honours `SLIDES_API_URL`; the Drive client's own base URL is irrelevant
78    /// here, since the two hosts are unrelated.
79    pub fn from_drive_client(drive: &DriveClient) -> Result<Self> {
80        Self::from_drive_client_with(&SystemEnv, drive)
81    }
82
83    /// [`from_drive_client`](Self::from_drive_client) over an injected
84    /// [`EnvSource`], so tests can exercise the `SLIDES_API_URL` override via
85    /// `MapEnv` without mutating the process environment.
86    pub(crate) fn from_drive_client_with(
87        env: &impl EnvSource,
88        drive: &DriveClient,
89    ) -> Result<Self> {
90        let base_url = Self::resolve_base_url(env);
91        Ok(Self {
92            inner: drive.transport().derived(&base_url, SERVICE_TAG, API_NAME),
93        })
94    }
95
96    fn resolve_base_url(env: &impl EnvSource) -> String {
97        env.var(SLIDES_API_URL)
98            .filter(|s| !s.is_empty())
99            .unwrap_or_else(|| Self::DEFAULT_BASE_URL.to_string())
100    }
101
102    /// Returns the API base URL (without trailing slash).
103    #[must_use]
104    pub fn base_url(&self) -> &str {
105        self.inner.base_url()
106    }
107
108    /// The shared transport, for the `SlidesApi` façade's requests.
109    pub(in crate::drive) fn transport(&self) -> &GoogleApiClient {
110        &self.inner
111    }
112}
113
114/// A Drive + Slides client pair pointed at one wiremock server, shared by the
115/// engine and CLI tests so neither re-derives the token-endpoint wiring.
116#[cfg(test)]
117pub(crate) mod test_support {
118    use super::{DriveClient, DriveCredentials, SlidesClient, SLIDES_API_URL};
119    use crate::drive::auth::DriveGrantedScopes;
120    use crate::test_support::env::MapEnv;
121    use crate::utils::secret::Secret;
122    use wiremock::matchers::{method, path};
123    use wiremock::{Mock, MockServer, ResponseTemplate};
124
125    /// Mounts a `/token` endpoint on `server` and returns clients whose Drive
126    /// and Slides hosts are both `server`.
127    #[allow(clippy::unwrap_used)]
128    pub(crate) async fn mock_clients(server: &MockServer) -> (DriveClient, SlidesClient) {
129        Mock::given(method("POST"))
130            .and(path("/token"))
131            .respond_with(
132                ResponseTemplate::new(200)
133                    .set_body_json(serde_json::json!({"access_token":"test", "expires_in":3600})),
134            )
135            .mount(server)
136            .await;
137        let credentials = DriveCredentials {
138            client_id: "client".into(),
139            client_secret: Secret::new("secret"),
140            refresh_token: Secret::new("refresh"),
141            scope: DriveGrantedScopes::READONLY,
142        };
143        let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
144        crate::drive::client::test_support::replace_session(
145            &mut drive,
146            &credentials,
147            &format!("{}/token", server.uri()),
148        );
149        let slides = SlidesClient::from_drive_client_with(
150            &MapEnv::new().with(SLIDES_API_URL, &server.uri()),
151            &drive,
152        )
153        .unwrap();
154        (drive, slides)
155    }
156}
157
158#[cfg(test)]
159#[allow(clippy::unwrap_used, clippy::expect_used)]
160mod tests {
161    use super::*;
162    use crate::drive::auth::DriveGrantedScopes;
163    use crate::test_support::env::MapEnv;
164    use crate::utils::secret::Secret;
165
166    fn test_credentials() -> DriveCredentials {
167        DriveCredentials {
168            client_id: "client-1".to_string(),
169            client_secret: Secret::new("secret-1"),
170            refresh_token: Secret::new("refresh-1"),
171            scope: DriveGrantedScopes::READONLY,
172        }
173    }
174
175    fn drive_client() -> DriveClient {
176        DriveClient::new("https://www.googleapis.com", &test_credentials()).unwrap()
177    }
178
179    #[test]
180    fn derives_the_real_slides_host_by_default() {
181        // Via a fresh MapEnv, not the real SystemEnv — a stray SLIDES_API_URL
182        // in the process environment must not make this flaky (mirrors the
183        // Drive/Sheets/Gmail precedent).
184        let slides = SlidesClient::from_drive_client_with(&MapEnv::new(), &drive_client()).unwrap();
185        assert_eq!(slides.base_url(), "https://slides.googleapis.com");
186    }
187
188    #[test]
189    fn honours_the_slides_api_url_override() {
190        let env = MapEnv::new().with(SLIDES_API_URL, "http://127.0.0.1:9/slides");
191        let slides = SlidesClient::from_drive_client_with(&env, &drive_client()).unwrap();
192        assert_eq!(slides.base_url(), "http://127.0.0.1:9/slides");
193    }
194
195    #[test]
196    fn ignores_an_empty_slides_api_url_override() {
197        let env = MapEnv::new().with(SLIDES_API_URL, "");
198        let slides = SlidesClient::from_drive_client_with(&env, &drive_client()).unwrap();
199        assert_eq!(slides.base_url(), "https://slides.googleapis.com");
200    }
201
202    #[test]
203    fn deriving_does_not_disturb_the_drive_client_host() {
204        let drive = drive_client();
205        let _slides = SlidesClient::from_drive_client_with(&MapEnv::new(), &drive).unwrap();
206        assert_eq!(drive.base_url(), "https://www.googleapis.com");
207    }
208
209    /// A Slides client and a Sheets client derived from the same Drive client
210    /// must land on different hosts. Pins the "distinct type, distinct host"
211    /// claim now that there are four of them and a copy-paste slip would
212    /// point Slides at `sheets.googleapis.com` with nothing else complaining.
213    #[test]
214    fn slides_and_sheets_derive_to_different_hosts() {
215        use crate::drive::sheets::client::SheetsClient;
216        let drive = drive_client();
217        let slides = SlidesClient::from_drive_client_with(&MapEnv::new(), &drive).unwrap();
218        let sheets = SheetsClient::from_drive_client_with(&MapEnv::new(), &drive).unwrap();
219        assert_eq!(slides.base_url(), "https://slides.googleapis.com");
220        assert_eq!(sheets.base_url(), "https://sheets.googleapis.com");
221    }
222
223    #[test]
224    fn new_strips_a_trailing_slash() {
225        let slides =
226            SlidesClient::new("https://slides.googleapis.com/", &test_credentials()).unwrap();
227        assert_eq!(slides.base_url(), "https://slides.googleapis.com");
228    }
229
230    #[tokio::test]
231    async fn a_slides_failure_names_slides_not_drive() {
232        // The Sheets precedent's regression guard, transposed: a 403 from
233        // the Slides host previously would have announced itself as a *Drive*
234        // failure, sending the reader to the wrong API's slides.
235        let server = wiremock::MockServer::start().await;
236        wiremock::Mock::given(wiremock::matchers::method("GET"))
237            .and(wiremock::matchers::path("/boom"))
238            .respond_with(
239                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
240                    "error": {"code": 403, "message": "The caller does not have permission",
241                              "status": "PERMISSION_DENIED"},
242                })),
243            )
244            .mount(&server)
245            .await;
246        let response = reqwest::get(format!("{}/boom", server.uri()))
247            .await
248            .unwrap();
249
250        let err = GoogleApiClient::response_to_error(API_NAME, response).await;
251        let text = err.to_string();
252        assert!(text.starts_with("Slides API request failed"), "{text}");
253        assert!(!text.contains("Drive API request failed"), "{text}");
254        assert!(!text.contains("Sheets API request failed"), "{text}");
255    }
256
257    #[test]
258    fn debug_never_mentions_the_session_or_a_secret() {
259        let slides = SlidesClient::from_drive_client_with(&MapEnv::new(), &drive_client()).unwrap();
260        let debug = format!("{slides:?}");
261        assert!(debug.contains("SlidesClient"));
262        assert!(!debug.contains("session"));
263        assert!(!debug.contains("secret-1"));
264        assert!(!debug.contains("refresh-1"));
265    }
266}