Skip to main content

skipper_core/
api.rs

1use std::sync::Mutex;
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use serde_json::{json, Value};
5
6use crate::credentials::{Credentials, ServiceAccountCredentials};
7use crate::error::{Error, Result};
8
9const SCOPE: &str = "https://www.googleapis.com/auth/spreadsheets";
10const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
11const SHEETS_BASE: &str = "https://sheets.googleapis.com/v4/spreadsheets";
12
13/// The four Google Sheets v4 calls Skipper needs. Implemented over HTTP by
14/// [`HttpSheetsApi`]; tests can provide an in-memory fake.
15pub trait SheetsApi {
16    /// `GET /spreadsheets/{id}` — metadata (sheet titles and ids).
17    fn get_spreadsheet(&self, spreadsheet_id: &str) -> Result<Value>;
18    /// `GET /spreadsheets/{id}/values/{range}`.
19    fn get_values(&self, spreadsheet_id: &str, range: &str) -> Result<Value>;
20    /// `POST /spreadsheets/{id}/values/{range}:append?valueInputOption=RAW`.
21    fn append_values(
22        &self,
23        spreadsheet_id: &str,
24        range: &str,
25        rows: Vec<Vec<String>>,
26    ) -> Result<()>;
27    /// `POST /spreadsheets/{id}:batchUpdate`.
28    fn batch_update(&self, spreadsheet_id: &str, requests: Vec<Value>) -> Result<()>;
29}
30
31/// Sheets API over HTTPS, authenticated with a service-account JWT
32/// (OAuth 2.0 `jwt-bearer` grant). The access token is cached until shortly
33/// before it expires.
34pub struct HttpSheetsApi {
35    account: ServiceAccountCredentials,
36    agent: ureq::Agent,
37    token: Mutex<Option<(String, Instant)>>,
38}
39
40impl HttpSheetsApi {
41    pub fn new(credentials: &Credentials) -> Result<Self> {
42        let account = credentials.service_account()?;
43        if account.client_email.is_empty() || account.private_key.is_empty() {
44            return Err(Error::new(
45                "credentials missing client_email or private_key",
46            ));
47        }
48        Ok(HttpSheetsApi {
49            account,
50            agent: ureq::AgentBuilder::new()
51                .timeout(Duration::from_secs(30))
52                .build(),
53            token: Mutex::new(None),
54        })
55    }
56
57    fn access_token(&self) -> Result<String> {
58        let mut guard = self.token.lock().unwrap_or_else(|e| e.into_inner());
59        if let Some((tok, exp)) = guard.as_ref() {
60            if Instant::now() < *exp {
61                return Ok(tok.clone());
62            }
63        }
64        let token_uri = if self.account.token_uri.is_empty() {
65            DEFAULT_TOKEN_URI
66        } else {
67            &self.account.token_uri
68        };
69        let now = SystemTime::now()
70            .duration_since(UNIX_EPOCH)
71            .map_err(|e| Error(e.to_string()))?
72            .as_secs();
73        let claims = json!({
74            "iss": self.account.client_email,
75            "scope": SCOPE,
76            "aud": token_uri,
77            "iat": now,
78            "exp": now + 3600,
79        });
80        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
81        if !self.account.private_key_id.is_empty() {
82            header.kid = Some(self.account.private_key_id.clone());
83        }
84        let key = jsonwebtoken::EncodingKey::from_rsa_pem(self.account.private_key.as_bytes())?;
85        let assertion = jsonwebtoken::encode(&header, &claims, &key)?;
86
87        let resp: Value = self
88            .agent
89            .post(token_uri)
90            .send_form(&[
91                ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
92                ("assertion", &assertion),
93            ])?
94            .into_json()?;
95        let tok = resp["access_token"]
96            .as_str()
97            .ok_or_else(|| Error::new("token response without access_token"))?
98            .to_string();
99        let ttl = resp["expires_in"]
100            .as_u64()
101            .unwrap_or(3600)
102            .saturating_sub(60);
103        *guard = Some((tok.clone(), Instant::now() + Duration::from_secs(ttl)));
104        Ok(tok)
105    }
106
107    fn bearer(&self) -> Result<String> {
108        Ok(format!("Bearer {}", self.access_token()?))
109    }
110}
111
112impl SheetsApi for HttpSheetsApi {
113    fn get_spreadsheet(&self, id: &str) -> Result<Value> {
114        let url = format!("{SHEETS_BASE}/{}", encode(id));
115        Ok(self
116            .agent
117            .get(&url)
118            .set("Authorization", &self.bearer()?)
119            .call()?
120            .into_json()?)
121    }
122
123    fn get_values(&self, id: &str, range: &str) -> Result<Value> {
124        let url = format!("{SHEETS_BASE}/{}/values/{}", encode(id), encode(range));
125        Ok(self
126            .agent
127            .get(&url)
128            .set("Authorization", &self.bearer()?)
129            .call()?
130            .into_json()?)
131    }
132
133    fn append_values(&self, id: &str, range: &str, rows: Vec<Vec<String>>) -> Result<()> {
134        let url = format!(
135            "{SHEETS_BASE}/{}/values/{}:append?valueInputOption=RAW",
136            encode(id),
137            encode(range)
138        );
139        self.agent
140            .post(&url)
141            .set("Authorization", &self.bearer()?)
142            .send_json(json!({ "values": rows }))?;
143        Ok(())
144    }
145
146    fn batch_update(&self, id: &str, requests: Vec<Value>) -> Result<()> {
147        let url = format!("{SHEETS_BASE}/{}:batchUpdate", encode(id));
148        self.agent
149            .post(&url)
150            .set("Authorization", &self.bearer()?)
151            .send_json(json!({ "requests": requests }))?;
152        Ok(())
153    }
154}
155
156/// Percent-encode a URL path segment (RFC 3986 unreserved chars kept).
157fn encode(s: &str) -> String {
158    let mut out = String::with_capacity(s.len());
159    for b in s.bytes() {
160        match b {
161            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
162                out.push(b as char)
163            }
164            _ => out.push_str(&format!("%{b:02X}")),
165        }
166    }
167    out
168}
169
170#[cfg(test)]
171mod tests {
172    #[test]
173    fn encodes_path_segments() {
174        assert_eq!(super::encode("My Sheet/1'"), "My%20Sheet%2F1%27");
175        assert_eq!(super::encode("skipper-rust"), "skipper-rust");
176    }
177}