Google Sheets as a REST API with Rust and Axum
Use SheetsAPI to turn any Google Sheet into a typed REST endpoint, backed by an Axum server with reqwest, serde, and a TTL cache.
Google Sheets gets a bad reputation in backend circles, but for small datasets - feature flags, pricing tables, content managed by non-technical stakeholders - it is genuinely useful. SheetsAPI gives you a clean HTTP interface on top of any spreadsheet without managing OAuth flows or service accounts yourself. This post walks through building a small Axum service in Rust that proxies SheetsAPI, adds a TTL cache, and exposes typed read and write endpoints.
What SheetsAPI looks like
Every sheet gets a stable URL:
GET https://sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
POST https://sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
Query parameters limit, offset, and filter[col]=val control pagination and filtering. The POST body is a JSON object whose keys match your sheet's column headers. Authentication is a bearer token in the Authorization header.
A typical response:
{
"data": [{ "id": "1", "name": "Acme", "plan": "pro" }],
"meta": { "total": 42, "limit": 25, "offset": 0 }
}Project setup
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.4"
thiserror = "1"Typed domain model
Define structs that match the shape of your sheet. serde handles the mapping automatically.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Customer {
pub id: String,
pub name: String,
pub plan: String,
}
#[derive(Debug, Deserialize)]
pub struct SheetsMeta {
pub total: u64,
pub limit: u64,
pub offset: u64,
}
#[derive(Debug, Deserialize)]
pub struct SheetsResponse<T> {
pub data: Vec<T>,
pub meta: SheetsMeta,
}The generic SheetsResponse<T> means you can reuse this across every sheet in your spreadsheet.
SheetsClient
Wrap reqwest::Client in a struct that knows your credentials and base URL. Arc<Client> lets you share it cheaply across Axum handlers.
use std::sync::Arc;
use reqwest::Client;
use serde::de::DeserializeOwned;
pub struct SheetsClient {
inner: Arc<Client>,
base_url: String,
user_key: String,
token: String,
}
impl SheetsClient {
pub fn new(user_key: impl Into<String>, token: impl Into<String>) -> Self {
Self {
inner: Arc::new(Client::new()),
base_url: "https://sheetsapi.io/api/spreadsheets".into(),
user_key: user_key.into(),
token: token.into(),
}
}
pub async fn get<T: DeserializeOwned>(
&self,
sheet: &str,
params: &[(&str, &str)],
) -> Result<SheetsResponse<T>, reqwest::Error> {
let url = format!("{}/{}/{}", self.base_url, self.user_key, sheet);
self.inner
.get(&url)
.bearer_auth(&self.token)
.query(params)
.send()
.await?
.error_for_status()?
.json::<SheetsResponse<T>>()
.await
}
pub async fn append<B: Serialize, T: DeserializeOwned>(
&self,
sheet: &str,
body: &B,
) -> Result<T, reqwest::Error> {
let url = format!("{}/{}/{}", self.base_url, self.user_key, sheet);
self.inner
.post(&url)
.bearer_auth(&self.token)
.json(body)
.send()
.await?
.error_for_status()?
.json::<T>()
.await
}
}TTL cache with RwLock
Sheets data changes infrequently. A simple in-memory cache backed by tokio::sync::RwLock<HashMap> avoids hammering the API on every request.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
#[derive(Clone)]
struct CacheEntry<T> {
value: T,
inserted_at: Instant,
}
pub struct TtlCache<T> {
store: RwLock<HashMap<String, CacheEntry<T>>>,
ttl: Duration,
}
impl<T: Clone> TtlCache<T> {
pub fn new(ttl: Duration) -> Self {
Self {
store: RwLock::new(HashMap::new()),
ttl,
}
}
pub async fn get(&self, key: &str) -> Option<T> {
let guard = self.store.read().await;
guard.get(key).and_then(|entry| {
if entry.inserted_at.elapsed() < self.ttl {
Some(entry.value.clone())
} else {
None
}
})
}
pub async fn set(&self, key: String, value: T) {
let mut guard = self.store.write().await;
guard.insert(key, CacheEntry { value, inserted_at: Instant::now() });
}
}RwLock lets multiple readers proceed concurrently; writes are exclusive. The cache does not evict stale entries proactively - a background task or lazy eviction on write is left as an exercise.
Error handling
Axum requires handlers to return something that implements IntoResponse. A custom error type keeps handler code clean.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("upstream request failed: {0}")]
Upstream(#[from] reqwest::Error),
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}Axum router and state
Collect the client and cache into a shared AppState. Axum's State extractor clones the Arc on every request, not the underlying data.
use axum::{
extract::{Path, Query, State},
routing::{get, post},
Json, Router,
};
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)]
struct AppState {
sheets: Arc<SheetsClient>,
cache: Arc<TtlCache<Vec<Customer>>>,
}
async fn list_customers(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<SheetsResponse<Customer>>, AppError> {
let cache_key = format!("customers:{:?}", params);
if let Some(cached) = state.cache.get(&cache_key).await {
let meta = SheetsMeta { total: cached.len() as u64, limit: 25, offset: 0 };
return Ok(Json(SheetsResponse { data: cached, meta }));
}
let query_pairs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let response: SheetsResponse<Customer> =
state.sheets.get("customers", &query_pairs).await?;
state.cache.set(cache_key, response.data.clone()).await;
Ok(Json(response))
}
#[derive(Debug, Deserialize)]
struct NewCustomer {
name: String,
plan: String,
}
async fn create_customer(
State(state): State<AppState>,
Json(payload): Json<NewCustomer>,
) -> Result<Json<Customer>, AppError> {
if payload.name.trim().is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
let created: Customer = state.sheets.append("customers", &payload).await?;
Ok(Json(created))
}
#[tokio::main]
async fn main() {
let state = AppState {
sheets: Arc::new(SheetsClient::new(
std::env::var("SHEETS_USER_KEY").expect("SHEETS_USER_KEY"),
std::env::var("SHEETS_TOKEN").expect("SHEETS_TOKEN"),
)),
cache: Arc::new(TtlCache::new(Duration::from_secs(60))),
};
let app = Router::new()
.route("/customers", get(list_customers).post(create_customer))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Putting it together
Run the server with your credentials exported:
export SHEETS_USER_KEY=your_user_key
export SHEETS_TOKEN=sk_your_token
cargo runThen read and write rows:
# list customers
curl http://localhost:3000/customers
# filter by plan
curl "http://localhost:3000/customers?filter[plan]=pro"
# append a row
curl -X POST http://localhost:3000/customers \
-H "Content-Type: application/json" \
-d '{"name": "Initech", "plan": "starter"}'What to build on top of this
The patterns here compose well. Add tower_http::trace::TraceLayer for structured request logs, swap the in-process cache for Redis when you have multiple replicas, or use axum::middleware to enforce an API key before requests reach your handlers. The SheetsClient itself can be tested by pointing it at a mock HTTP server - wiremock or httpmock both work cleanly with reqwest.
SheetsAPI removes the OAuth plumbing that normally makes Sheets integration tedious. With Rust's type system enforcing the shape of your data at compile time and Axum's ergonomic extractor model, you end up with a small, reliable service that your team can actually hand off to a non-engineer for data entry.