use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use crate::credentials::{Credentials, ServiceAccountCredentials};
use crate::error::{Error, Result};
const SCOPE: &str = "https://www.googleapis.com/auth/spreadsheets";
const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
const SHEETS_BASE: &str = "https://sheets.googleapis.com/v4/spreadsheets";
pub trait SheetsApi {
fn get_spreadsheet(&self, spreadsheet_id: &str) -> Result<Value>;
fn get_values(&self, spreadsheet_id: &str, range: &str) -> Result<Value>;
fn append_values(
&self,
spreadsheet_id: &str,
range: &str,
rows: Vec<Vec<String>>,
) -> Result<()>;
fn batch_update(&self, spreadsheet_id: &str, requests: Vec<Value>) -> Result<()>;
}
pub struct HttpSheetsApi {
account: ServiceAccountCredentials,
agent: ureq::Agent,
token: Mutex<Option<(String, Instant)>>,
}
impl HttpSheetsApi {
pub fn new(credentials: &Credentials) -> Result<Self> {
let account = credentials.service_account()?;
if account.client_email.is_empty() || account.private_key.is_empty() {
return Err(Error::new(
"credentials missing client_email or private_key",
));
}
Ok(HttpSheetsApi {
account,
agent: ureq::AgentBuilder::new()
.timeout(Duration::from_secs(30))
.build(),
token: Mutex::new(None),
})
}
fn access_token(&self) -> Result<String> {
let mut guard = self.token.lock().unwrap_or_else(|e| e.into_inner());
if let Some((tok, exp)) = guard.as_ref() {
if Instant::now() < *exp {
return Ok(tok.clone());
}
}
let token_uri = if self.account.token_uri.is_empty() {
DEFAULT_TOKEN_URI
} else {
&self.account.token_uri
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| Error(e.to_string()))?
.as_secs();
let claims = json!({
"iss": self.account.client_email,
"scope": SCOPE,
"aud": token_uri,
"iat": now,
"exp": now + 3600,
});
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
if !self.account.private_key_id.is_empty() {
header.kid = Some(self.account.private_key_id.clone());
}
let key = jsonwebtoken::EncodingKey::from_rsa_pem(self.account.private_key.as_bytes())?;
let assertion = jsonwebtoken::encode(&header, &claims, &key)?;
let resp: Value = self
.agent
.post(token_uri)
.send_form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
("assertion", &assertion),
])?
.into_json()?;
let tok = resp["access_token"]
.as_str()
.ok_or_else(|| Error::new("token response without access_token"))?
.to_string();
let ttl = resp["expires_in"]
.as_u64()
.unwrap_or(3600)
.saturating_sub(60);
*guard = Some((tok.clone(), Instant::now() + Duration::from_secs(ttl)));
Ok(tok)
}
fn bearer(&self) -> Result<String> {
Ok(format!("Bearer {}", self.access_token()?))
}
}
impl SheetsApi for HttpSheetsApi {
fn get_spreadsheet(&self, id: &str) -> Result<Value> {
let url = format!("{SHEETS_BASE}/{}", encode(id));
Ok(self
.agent
.get(&url)
.set("Authorization", &self.bearer()?)
.call()?
.into_json()?)
}
fn get_values(&self, id: &str, range: &str) -> Result<Value> {
let url = format!("{SHEETS_BASE}/{}/values/{}", encode(id), encode(range));
Ok(self
.agent
.get(&url)
.set("Authorization", &self.bearer()?)
.call()?
.into_json()?)
}
fn append_values(&self, id: &str, range: &str, rows: Vec<Vec<String>>) -> Result<()> {
let url = format!(
"{SHEETS_BASE}/{}/values/{}:append?valueInputOption=RAW",
encode(id),
encode(range)
);
self.agent
.post(&url)
.set("Authorization", &self.bearer()?)
.send_json(json!({ "values": rows }))?;
Ok(())
}
fn batch_update(&self, id: &str, requests: Vec<Value>) -> Result<()> {
let url = format!("{SHEETS_BASE}/{}:batchUpdate", encode(id));
self.agent
.post(&url)
.set("Authorization", &self.bearer()?)
.send_json(json!({ "requests": requests }))?;
Ok(())
}
}
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn encodes_path_segments() {
assert_eq!(super::encode("My Sheet/1'"), "My%20Sheet%2F1%27");
assert_eq!(super::encode("skipper-rust"), "skipper-rust");
}
}