initial commit

This commit is contained in:
Brent Schroeter 2025-09-15 00:56:01 -07:00
commit 5ed334a454
16 changed files with 4831 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target
ferrtable-test.config.*

86
README.md Normal file
View file

@ -0,0 +1,86 @@
# Ferrtable: Ferris the Crab's Favorite Airtable Client
A power vacuum churns where the venerable
[airtable-api](https://crates.io/crates/airtable-api) (archived but not
forgotten) once stood tall. The world calls for a new leader to carry the
fallen torch. From the dust rises Ferrtable: to abase SQL supremacy, to turn
all the tables, to rise inexorably to the top of the field, and to set the
record straight.
## Status: Work in Progress
Only a limited set of operations (e.g., creating and listing records) are
currently supported. The goal is to implement coverage for at least the full
set of non-enterprise API endpoints, but my initial emphasis is on getting a
relatively small subset built and tested well.
## Usage
```rust
use futures::prelude::*;
// Ferrtable allows us to use any record types that implement Clone,
// Deserialize, and Serialize.
#[derive(Clone, Debug, Deserialize, Serialize)]
struct MyRecord {
#[serde(rename = "Name")]
name: String,
#[serde(rename = "Notes")]
notes: String,
#[serde(rename = "Assignee")]
assignee: Option<String>,
#[serde(rename = "Status")]
status: Status,
#[serde(rename = "Attachments")]
attachments: Vec<ferrtable::cell_values::AttachmentRead>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
enum Status {
Todo,
#[serde(rename = "In progress")]
InProgress,
Done,
}
#[tokio::main]
async fn main() {
let client = ferrtable::Client::new_from_access_token("******").unwrap();
client
.create_records([MyRecord {
name: "Steal Improbability Drive".to_owned(),
notes: "Just for fun, no other reason.".to_owned(),
assignee: None,
status: Status::InProgress,
attachments: vec![],
}])
.with_base_id("***".to_owned())
.with_table_id("***".to_owned())
.build()
.unwrap()
.execute()
.await
.unwrap();
let mut rec_stream = client
.list_records()
.with_base_id("***".to_owned())
.with_table_id("***".to_owned())
.with_filter("{status} = 'Todo' || {status} = 'In Progress'".to_owned())
.build()
.unwrap()
.execute::<MyRecord>()
while let Some(result) = rec_stream.next().await {
let rec = result.unwrap();
dbg!(rec.fields);
}
}
```

2254
ferrtable-test/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
ferrtable-test/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "ferrtable-test"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { version = "1.0.99", features = ["backtrace"] }
chrono = { version = "0.4.42", features = ["serde"] }
config = "0.15.15"
ferrtable = { path = "../ferrtable" }
futures = "0.3.31"
serde = { version = "1.0.223", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }

View file

@ -0,0 +1,60 @@
use ferrtable::Client;
use futures::prelude::*;
use serde::{Deserialize, Serialize};
use crate::settings::Settings;
mod settings;
#[derive(Clone, Debug, Deserialize, Serialize)]
struct TestRecord {
#[serde(rename = "Name")]
name: Option<String>,
#[serde(rename = "Notes")]
notes: Option<String>,
#[serde(rename = "Status")]
status: Option<RecordStatus>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
enum RecordStatus {
Todo,
#[serde(rename = "In progress")]
InProgress,
Done,
}
#[tokio::main]
async fn main() {
let settings = Settings::load().unwrap();
let client = Client::new_from_access_token(&settings.access_token).unwrap();
println!("Testing Client::create_records()...");
client
.create_records([TestRecord {
name: Some("Steal Improbability Drive".to_owned()),
notes: Some("Just for fun, no other reason.".to_owned()),
status: Some(RecordStatus::InProgress),
}])
.with_base_id(settings.base_id.clone())
.with_table_id(settings.table_id.clone())
.build()
.unwrap()
.execute()
.await
.unwrap();
println!("Testing Client::list_records()...");
let mut records = client
.list_records()
.with_base_id(settings.base_id.clone())
.with_table_id(settings.table_id.clone())
.build()
.unwrap()
.execute::<TestRecord>();
while let Some(res) = records.next().await {
dbg!(res.unwrap().fields);
}
println!("All tests succeeded.");
}

View file

@ -0,0 +1,23 @@
use anyhow::Result;
use config::Config;
use serde::Deserialize;
/// Test application configuration values.
#[derive(Clone, Deserialize)]
pub(crate) struct Settings {
pub(crate) access_token: String,
pub(crate) base_id: String,
pub(crate) table_id: String,
}
impl Settings {
/// Load configuration values from a file "ferrtable-test.config.*" and/or
/// environment variables prefixed with "FERRTABLE_TEST_".
pub(crate) fn load() -> Result<Settings> {
Ok(Config::builder()
.add_source(config::Environment::with_prefix("FERRTABLE_TEST"))
.add_source(config::File::with_name("ferrtable-test.config"))
.build()?
.try_deserialize()?)
}
}

1802
ferrtable/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
ferrtable/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "ferrtable"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = { version = "0.4.42", features = ["serde"] }
derive_builder = { version = "0.20.2", features = ["clippy"] }
futures = "0.3.31"
percent-encoding = "2.3.2"
reqwest = { version = "0.12.23", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
thiserror = "2.0.16"

View file

@ -0,0 +1,174 @@
//! Rust types for serializing and deserializing non-scalar cell values.
use std::fmt::Debug;
use serde::{Deserialize, Serialize};
/// Long text (with AI output enabled)
///
/// AI generated text can depend on other cells in the same record and can be
/// in a loading state.
///
/// Read only.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AiTextValue {
state: String,
#[serde(rename = "isStale")]
is_stale: bool,
#[serde(rename = "errorType")]
error_type: Option<String>,
value: Option<String>,
}
/// Attachments allow you to add images, documents, or other files which can
/// then be viewed or downloaded.
///
/// URLs returned will expire 2 hours after being returned from our API. If you
/// want to persist the attachments, we recommend downloading them instead of
/// saving the URL. See our support article for more information.
///
/// Attachment cell values are Vecs of Attachments.
///
/// Read only. Use AttachmentWrite for writing to fields.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AttachmentRead {
/// Unique attachment id
id: String,
/// Content type, e.g. "image/jpeg"
#[serde(rename = "type")]
type_: String,
/// Filename, e.g. "foo.jpg"
filename: String,
/// Height, in pixels (these may be available if the attachment is an
/// image)
height: Option<i32>,
/// File size, in bytes
size: usize,
/// url, e.g. "https://v5.airtableusercontent.com/foo".
///
/// URLs returned will expire 2 hours after being returned from our API.
/// If you want to persist the attachments, we recommend downloading
/// them instead of saving the URL. See our support article for more
/// information.
url: String,
/// Width, in pixels (these may be available if the attachment is an
/// image)
width: Option<i32>,
// TODO: Add `thumbnails` field.
}
/// To create new attachments, provide the url and optionally filename.
///
/// You must also provide the id's for any existing attachment objects you
/// wish to keep.
///
/// Note that in most cases the API does not currently return an error code
/// for failed attachment object creation given attachment uploading happens
/// in an asynchronous manner, such cases will manifest with the attachment
/// object either being cleared from the cell or persisted with generated
/// URLs that return error responses when queried. If the same attachment
/// URL fails to upload multiple times in a short time interval then the API
/// may return an ATTACHMENTS_FAILED_UPLOADING error code in the details
/// field of the response and the attachment object will be cleared from the
/// cell synchronously.
///
/// We also require URLs used to upload have the https:// or http://
/// protocol (Note: http:// support will be removed in the near future),
/// have a limit of 3 max redirects, and a file size limit of 1GB. In
/// addition, URLs must be publicly accessible, in cases where cookie
/// authentication or logging in to access the file is required, the login
/// page HTML will be downloaded instead of the file.
///
/// Write only. Use AttachmentRead for reading from fields.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum AttachmentWrite {
Keep {
/// When writing an attachment object, be sure to include all existing
/// attachment objects by providing an id. This can be retrieved using
/// the get record endpoint.
///
/// To remove attachments, include the existing array of attachment
/// objects, excluding any that you wish to remove.
id: String,
},
Upload {
url: String,
/// Filename, e.g. "foo.jpg"
filename: Option<String>,
},
}
/// Use the Airtable iOS or Android app to scan barcodes.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Barcode {
/// Barcode symbology, e.g. "upce" or "code39"
#[serde(rename = "type")]
pub type_: Option<String>,
/// Barcode data
pub text: Option<String>,
}
/// A button that can be clicked from the Airtable UI to open a URL or open an
/// extension.
///
/// Read only.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Button {
/// Button label
label: String,
/// For "Open URL" actions, the computed url value
url: Option<String>,
}
/// A collaborator field lets you add collaborators to your records.
/// Collaborators can optionally be notified when they're added (using the field
/// settings in the UI). A single collaborator field has been configured to only
/// reference one user collaborator.
///
/// Read only. Use CollaboratorWrite for writing to fields.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct CollaboratorRead {
/// User id or group id
id: String,
/// User's email address
email: Option<String>,
/// User's display name (may be omitted if the user hasn't created an
/// account)
name: Option<String>,
/// User's collaborator permission Level
///
/// This is only included if you're observing a webhooks response.
#[serde(rename = "permissionLevel")]
permission_level: Option<String>,
/// User's profile picture
///
/// This is only included if it exists for the user and you're observing
/// a webhooks response.
#[serde(rename = "profilePicUrl")]
profile_pic_url: Option<String>,
}
/// Write only. Use CollaboratorRead for reading from fields.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum CollaboratorWrite {
Id {
/// The user id, group id of the user
id: String,
},
Email {
/// The user's email address
email: String,
},
}
/// Option<FormulaResult> should cover all values generated by a formula field.
///
/// Read only.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum FormulaResult {
String(String),
Number(f32),
Bool(bool),
Strings(Vec<String>),
Numbers(Vec<f32>),
}

86
ferrtable/src/client.rs Normal file
View file

@ -0,0 +1,86 @@
use std::fmt::Debug;
use serde::Serialize;
use crate::{create_records::CreateRecordsQueryBuilder, list_records::ListRecordsQueryBuilder};
const DEFAULT_API_ROOT: &str = "https://api.airtable.com";
#[derive(Clone)]
pub struct Client {
// TODO: Does API root need to be customizable per client, e.g. for on-prem
// enterprise deployments, or is that not a thing?
api_root: String,
client: reqwest::Client,
token: String,
}
// Implement the Default trait so that `derive_builder` will allow fields
// containing the Client type to be defined with `#[builder(setter(skip))]`.
//
// In order to avoid repeating ourselves, this may also be used in
// `new_from_access_token()`.
impl Default for Client {
/// WARNING: Default is implemented only to satisfy trait bound checks
/// internally. You may use it externally as a placeholder value, but be
/// aware that it will not be able to make any authenticated API requests.
fn default() -> Self {
Self {
api_root: DEFAULT_API_ROOT.to_owned(),
client: reqwest::ClientBuilder::default()
.https_only(true)
.build()
.expect("reqwest client is always built with the same configuration here"),
token: "".to_owned(),
}
}
}
impl Client {
pub fn new_from_access_token(token: &str) -> Result<Self, reqwest::Error> {
Ok(Self {
token: token.to_owned(),
..Default::default()
})
}
/// Constructs a builder for inserting up to 10 records at a time into a
/// table.
///
/// Specify the base and table IDs with its `.with_base_id()` and
/// `.with_table_id()` methods.
pub fn create_records<I, T>(&self, records: I) -> CreateRecordsQueryBuilder<T>
where
T: Serialize,
I: IntoIterator<Item = T>,
{
CreateRecordsQueryBuilder::default()
.with_client(self.clone())
.with_records(records.into_iter().collect())
}
/// Constructs a builder for listing records in a table or view.
///
/// Specify the base and table IDs with its `.with_base_id()` and
/// `.with_table_id()` methods.
pub fn list_records(&self) -> ListRecordsQueryBuilder {
ListRecordsQueryBuilder::default().with_client(self.clone())
}
/// Constructs a RequestBuilder with URL "{self.api_root}/{path}" and the
/// Authorization header set to the correct bearer auth value.
pub(crate) fn post_path(&self, path: &str) -> reqwest::RequestBuilder {
let Self {
api_root, token, ..
} = self;
self.client
.post(format!("{api_root}/{path}"))
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
}
}
impl Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ferrtable::Client {{ *** }}")
}
}

View file

@ -0,0 +1,85 @@
use derive_builder::Builder;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::errors::ExecutionError;
use crate::types::AirtableRecord;
#[derive(Builder, Clone, Debug)]
#[builder(pattern = "owned", setter(prefix = "with"))]
pub struct CreateRecordsQuery<T>
where
T: Serialize,
{
base_id: String,
#[builder(vis = "pub(crate)")]
client: Client,
#[builder(vis = "pub(crate)")]
records: Vec<T>,
table_id: String,
}
#[derive(Clone, Deserialize)]
pub struct CreateRecordsResponse<T>
where
T: Clone + Serialize,
{
/// Records successfully created in Airtable.
pub records: Vec<AirtableRecord<T>>,
/// Additional information, present if the operations only partially succeed.
pub details: Option<CreateRecordsDetails>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct CreateRecordsDetails {
/// Expected value is "partialSuccess".
pub message: String,
/// Expected values are "attachmentsFailedUploading", "attachmentUploadRateIsTooHigh".
pub reasons: Vec<String>,
}
impl<'a, T> CreateRecordsQuery<T>
where
T: Clone + DeserializeOwned + Serialize,
{
/// Execute the API request.
///
/// Currently, failures return a one-size-fits-all error wrapping the
/// underlying `reqwest::Error`. This may be improved in future releases
/// to better differentiate between network, serialization, deserialization,
/// and API errors.
pub async fn execute(self) -> Result<CreateRecordsResponse<T>, ExecutionError> {
#[derive(Serialize)]
struct Record<RT: Serialize> {
fields: RT,
}
#[derive(Serialize)]
struct RequestBody<RT: Serialize> {
records: Vec<Record<RT>>,
}
let base_id = utf8_percent_encode(&self.base_id, NON_ALPHANUMERIC).to_string();
let table_id = utf8_percent_encode(&self.table_id, NON_ALPHANUMERIC).to_string();
let http_resp = self
.client
.post_path(&format!("v0/{base_id}/{table_id}"))
.json(&RequestBody {
records: self
.records
.into_iter()
.map(|rec| Record { fields: rec })
.collect(),
})
.send()
.await?
.error_for_status()?;
let deserialized_resp: CreateRecordsResponse<T> = http_resp.json().await?;
Ok(deserialized_resp)
}
}

13
ferrtable/src/errors.rs Normal file
View file

@ -0,0 +1,13 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ExecutionError {
#[error("error making http request to airtable api: {0}")]
Reqwest(reqwest::Error),
}
impl From<reqwest::Error> for ExecutionError {
fn from(value: reqwest::Error) -> Self {
Self::Reqwest(value)
}
}

10
ferrtable/src/lib.rs Normal file
View file

@ -0,0 +1,10 @@
pub mod cell_values;
pub mod client;
pub mod errors;
pub mod types;
// Each API operation is organized into a dedicated Rust module.
pub mod create_records;
pub mod list_records;
pub use client::Client;

View file

@ -0,0 +1,182 @@
use std::collections::VecDeque;
use std::pin::Pin;
use derive_builder::Builder;
use futures::prelude::*;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::errors::ExecutionError;
use crate::types::AirtableRecord;
#[derive(Builder, Clone, Debug, Serialize)]
#[builder(pattern = "owned", setter(prefix = "with"))]
pub struct ListRecordsQuery {
#[serde(skip)]
base_id: String,
#[serde(skip)]
#[builder(vis = "pub(crate)")]
client: Client,
/// Only data for fields whose names or IDs are in this list will be
/// included in the result. If you don't need every field, you can use this
/// parameter to reduce the amount of data transferred.
#[builder(default)]
fields: Option<Vec<String>>,
// filterByFormula is renamed so that the builder method, that is,
// `.with_filter()`, reads more cleanly.
/// A formula used to filter records. The formula will be evaluated for
/// each record, and if the result is not 0, false, "", NaN, [], or #Error!
/// the record will be included in the response.
///
/// If combined with the view parameter, only records in that view which
/// satisfy the formula will be returned.
#[serde(rename = "filterByFormula")]
#[builder(default)]
filter: Option<String>,
/// To fetch the next page of records, include offset from the previous
/// request in the next request's parameters.
#[builder(default, vis = "pub(crate)")]
offset: Option<String>,
#[serde(rename = "pageSize")]
#[builder(default)]
page_size: Option<usize>,
#[serde(skip)]
table_id: String,
}
#[derive(Clone, Deserialize)]
struct ListRecordsResponse<T>
where
T: Clone + Serialize,
{
/// If there are more records, the response will contain an offset. Pass
/// this offset into the next request to fetch the next page of records.
offset: Option<String>,
records: VecDeque<AirtableRecord<T>>,
}
// Acts similarly to a `?` operator, but for the result stream. Upon an error,
// it short-circuit returns the error as the final item in the stream.
macro_rules! handle_stream_err {
($fallible:expr, state = $state:expr) => {
match $fallible {
Ok(value) => value,
Err(err) => {
return Some((
Err(ExecutionError::from(err)),
StreamState {
buffered: VecDeque::new(),
started: true,
query: ListRecordsQuery {
offset: None,
..$state.query
},
},
));
}
}
};
}
impl ListRecordsQuery {
/// Execute the API request.
///
/// Currently, failures return a one-size-fits-all error wrapping the
/// underlying `reqwest::Error`. This may be improved in future releases
/// to better differentiate between network, serialization, deserialization,
/// and API errors.
///
/// Pagination is handled automatically, and items are returned as a
/// seemingly continuous stream of Results. If an error is encountered while
/// fetching a page, the Err will be yielded immediately and no further
/// items will be returned.
///
/// # Examples
///
/// ```
/// let mut rec_stream = client
/// .list_records()
/// .with_base_id("***")
/// .with_table_id("***")
/// .build()
/// .unwrap()
/// .execute::<HashMap<String, String>>()
///
/// while let Some(result) = rec_stream.next().await {
/// let rec = result.unwrap();
/// dbg!(rec.fields);
/// }
/// ```
pub fn execute<T>(
self,
) -> Pin<Box<impl Stream<Item = Result<AirtableRecord<T>, ExecutionError>>>>
where
T: Clone + DeserializeOwned + Serialize + Unpin,
{
struct StreamState<T>
where
T: Clone + Serialize + Unpin,
{
buffered: VecDeque<AirtableRecord<T>>,
query: ListRecordsQuery,
started: bool,
}
// Stream has to be pinned to the heap so that the closure inside
// doesn't need to implement Unpin (which I don't think it can).
Box::pin(futures::stream::unfold(
StreamState {
buffered: VecDeque::new(),
query: self.clone(),
started: false,
},
|mut state: StreamState<T>| async move {
if let Some(value) = state.buffered.pop_front() {
// Iterate through a pre-loaded page.
return Some((Ok(value), state));
}
if state.query.offset.is_some() || !state.started {
// Fetch the next page.
state.started = true;
let base_id =
utf8_percent_encode(&state.query.base_id, NON_ALPHANUMERIC).to_string();
let table_id =
utf8_percent_encode(&state.query.table_id, NON_ALPHANUMERIC).to_string();
let http_resp = handle_stream_err!(
handle_stream_err!(
state
.query
.client
.post_path(&format!("v0/{base_id}/{table_id}/listRecords",))
.json(&state.query)
.send()
.await,
state = state
)
.error_for_status(),
state = state
);
let deserialized_resp: ListRecordsResponse<T> =
handle_stream_err!(http_resp.json().await, state = state);
state.buffered = deserialized_resp.records;
state.query.offset = deserialized_resp.offset;
if let Some(value) = state.buffered.pop_front() {
// Yield the first item from the newly fetched page.
return Some((Ok(value), state));
}
}
// No more items buffered and no subsequent page to fetch.
None
},
))
}
}

24
ferrtable/src/types.rs Normal file
View file

@ -0,0 +1,24 @@
use chrono::{DateTime, Utc};
use serde::Deserialize;
#[derive(Clone, Deserialize)]
pub struct AirtableRecord<T>
where
T: Clone,
{
/// Record ID.
pub id: String,
/// Timestamp of record creation.
#[serde(rename = "createdTime")]
pub created_time: DateTime<Utc>,
/// Contents of record data.
pub fields: T,
/// The number of comments (if there are any) on the record.
///
/// Only received when explicitly requested.
#[serde(rename = "commentCount")]
pub comment_count: Option<usize>,
}

2
mise.toml Normal file
View file

@ -0,0 +1,2 @@
[tools]
rust = "latest"