Google Sheets as a REST API in Rust
Fetch and query Google Sheet data in Rust using SheetsAPI with reqwest, serde, typed structs, async/await, and Axum web server examples.
3 min read
Rust's ownership model, async/await support via Tokio, and serde make it a great
language for reliable API clients. SheetsAPI returns plain JSON, so you can deserialize
directly into typed Rust structs with no runtime panics.
Prerequisites
- Rust 1.76+ with
cargo - A Google Sheet with a header row
- A SheetsAPI account and a
YOUR_USER_KEYkey from the dashboard
1. Dependencies
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }2. Define response types
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct SheetMeta {
pub total: usize,
pub limit: usize,
pub offset: usize,
}
#[derive(Debug, Deserialize)]
pub struct SheetResponse<T> {
pub data: Vec<T>,
pub meta: SheetMeta,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Product {
pub name: String,
pub price: String,
pub category: String,
#[serde(default)]
pub stock: String,
}#[serde(default)] avoids a panic when a column is missing in a row.
3. Basic fetch
use reqwest::Client;
const BASE: &str = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let url = format!("{BASE}/YOUR_USER_KEY/Products?limit=20&sort=name");
let resp: SheetResponse<Product> = client
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
println!("Total: {}", resp.meta.total);
for p in &resp.data {
println!(" {} - {}", p.name, p.price);
}
Ok(())
}Replace YOUR_USER_KEY with your actual key.
4. Reusable typed client
use reqwest::{Client, header};
use std::collections::HashMap;
pub struct SheetsClient {
http: Client,
user_key: String,
}
#[derive(Default)]
pub struct QueryOptions {
pub limit: Option<usize>,
pub offset: Option<usize>,
pub search: Option<String>,
pub sort: Option<String>,
pub fields: Option<String>,
}
impl SheetsClient {
pub fn new(user_key: impl Into<String>, api_key: Option<&str>) -> Self {
let mut headers = header::HeaderMap::new();
if let Some(key) = api_key {
let bearer = format!("Bearer {key}");
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&bearer).unwrap(),
);
}
let http = Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap();
SheetsClient { http, user_key: user_key.into() }
}
pub async fn query<T: for<'de> serde::Deserialize<'de>>(
&self,
sheet: &str,
opts: QueryOptions,
) -> Result<SheetResponse<T>, reqwest::Error> {
let mut params: HashMap<&str, String> = HashMap::new();
if let Some(l) = opts.limit { params.insert("limit", l.to_string()); }
if let Some(o) = opts.offset { params.insert("offset", o.to_string()); }
if let Some(s) = opts.search { params.insert("search", s); }
if let Some(s) = opts.sort { params.insert("sort", s); }
if let Some(f) = opts.fields { params.insert("fields", f); }
self.http
.get(format!("{BASE}/{}/{sheet}", self.user_key))
.query(¶ms)
.send()
.await?
.error_for_status()?
.json()
.await
}
pub async fn fetch_all<T: for<'de> serde::Deserialize<'de>>(
&self,
sheet: &str,
page_size: usize,
) -> Result<Vec<T>, reqwest::Error> {
let mut all = Vec::new();
let mut offset = 0;
loop {
let page = self.query::<T>(sheet, QueryOptions {
limit: Some(page_size),
offset: Some(offset),
..Default::default()
}).await?;
let done = offset + page_size >= page.meta.total;
all.extend(page.data);
if done { break; }
offset += page_size;
}
Ok(all)
}
}Usage:
let client = SheetsClient::new("YOUR_USER_KEY", Some("sk_your_api_key_here"));
let result = client.query::<Product>("Products", QueryOptions {
limit: Some(50),
search: Some("category:electronics".to_owned()),
sort: Some("-price".to_owned()),
..Default::default()
}).await?;
println!("Found {} products", result.meta.total);5. Error handling with thiserror
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SheetsError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Unauthorized - check your API key")]
Unauthorized,
#[error("Rate limit exceeded - retry after {retry_after}s")]
RateLimit { retry_after: u64 },
#[error("API error {status}: {message}")]
Api { status: u16, message: String },
}6. Axum web server
Expose your Sheet data as a JSON API:
use axum::{extract::Query, routing::get, Json, Router};
use std::collections::HashMap;
async fn products(
Query(params): Query<HashMap<String, String>>,
) -> Json<SheetResponse<Product>> {
let client = SheetsClient::new("YOUR_USER_KEY", Some("sk_your_api_key_here"));
let search = params.get("q").map(|q| format!("name:{q}"));
let result = client.query::<Product>("Products", QueryOptions {
limit: Some(20),
search,
sort: Some("name".to_owned()),
..Default::default()
}).await.unwrap();
Json(result)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/products", get(products));
let listener = tokio::net::TcpListener::bind("0.0.0.0:5885").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Add Axum to Cargo.toml:
axum = "0.7"7. Query parameter reference
| Parameter | Example | Description |
|---|---|---|
limit | 50 | Rows per page (default: 20, max: 500) |
offset | 0 | Row offset for pagination |
search | category:books | Filter by field:value |
sort | price or -price | Ascending / descending |
fields | name,price | Return only named columns |
Summary
| Concern | Approach |
|---|---|
| HTTP | reqwest with features = ["json"] |
| Deserialization | serde::Deserialize + #[derive] |
| Auth | HeaderMap with Authorization: Bearer |
| Async | Tokio + async/await |
| Web server | Axum handlers with Query extractor |
| Errors | thiserror + custom enum |
SheetsAPI returns plain JSON - reqwest and serde handle the rest with
compile-time guarantees and zero runtime panics.