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
13pub trait SheetsApi {
16 fn get_spreadsheet(&self, spreadsheet_id: &str) -> Result<Value>;
18 fn get_values(&self, spreadsheet_id: &str, range: &str) -> Result<Value>;
20 fn append_values(
22 &self,
23 spreadsheet_id: &str,
24 range: &str,
25 rows: Vec<Vec<String>>,
26 ) -> Result<()>;
27 fn batch_update(&self, spreadsheet_id: &str, requests: Vec<Value>) -> Result<()>;
29}
30
31pub 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
156fn 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}