Compare commits

...

6 commits

Author SHA1 Message Date
Brent Schroeter
eb5e2f4847 derive Debug for AirtableRecord 2025-09-16 23:24:13 -07:00
Brent Schroeter
7532694a92 prepare crate for publishing 2025-09-16 23:24:13 -07:00
Brent Schroeter
588654ea84 add mit license 2025-09-16 23:23:56 -07:00
Brent Schroeter
a379b5c5b5 move readme 2025-09-16 23:23:56 -07:00
Brent Schroeter
0af4256fa9 update docs 2025-09-16 23:23:56 -07:00
Brent Schroeter
c2a74ac45b place chrono dep behind feature 2025-09-16 23:23:56 -07:00
12 changed files with 272 additions and 126 deletions

20
LICENSE.md Normal file
View file

@ -0,0 +1,20 @@
MIT License
Copyright (c) 2025 Brent Schroeter
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,86 +0,0 @@
# 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()
.stream_items::<MyRecord>();
while let Some(result) = rec_stream.next().await {
let rec = result.unwrap();
dbg!(rec.fields);
}
}
```

1
README.md Symbolic link
View file

@ -0,0 +1 @@
./ferrtable/README.md

View file

@ -381,7 +381,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "ferrtable" name = "ferrtable"
version = "0.1.0" version = "0.0.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",

View file

@ -7,7 +7,7 @@ edition = "2024"
anyhow = { version = "1.0.99", features = ["backtrace"] } anyhow = { version = "1.0.99", features = ["backtrace"] }
chrono = { version = "0.4.42", features = ["serde"] } chrono = { version = "0.4.42", features = ["serde"] }
config = "0.15.15" config = "0.15.15"
ferrtable = { path = "../ferrtable" } ferrtable = { path = "../ferrtable", features = ["chrono"] }
futures = "0.3.31" futures = "0.3.31"
serde = { version = "1.0.223", features = ["derive"] } serde = { version = "1.0.223", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.145"

38
ferrtable/Cargo.lock generated
View file

@ -233,7 +233,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "ferrtable" name = "ferrtable"
version = "0.1.0" version = "0.0.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",
@ -546,9 +546,9 @@ dependencies = [
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.63" version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"core-foundation-sys", "core-foundation-sys",
@ -1540,15 +1540,15 @@ dependencies = [
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
dependencies = [ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link 0.1.3", "windows-link 0.2.0",
"windows-result", "windows-result 0.4.0",
"windows-strings", "windows-strings 0.5.0",
] ]
[[package]] [[package]]
@ -1592,8 +1592,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result", "windows-result 0.3.4",
"windows-strings", "windows-strings 0.4.2",
] ]
[[package]] [[package]]
@ -1605,6 +1605,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-result"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
dependencies = [
"windows-link 0.2.0",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.4.2" version = "0.4.2"
@ -1614,6 +1623,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-strings"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
dependencies = [
"windows-link 0.2.0",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"

View file

@ -1,14 +1,24 @@
[package] [package]
name = "ferrtable" name = "ferrtable"
version = "0.1.0" version = "0.0.1"
categories = ["api-bindings"]
description = "Ferris the crab's favorite Airtable library"
homepage = "https://forge.secondsystemtech.com/brent/ferrtable"
edition = "2024" edition = "2024"
keywords = ["api", "client"]
license = "MIT"
readme = "README.md"
repository = "https://forge.secondsystemtech.com/brent/ferrtable"
[dependencies] [dependencies]
chrono = { version = "0.4.42", features = ["serde"] } chrono = { version = "0.4.42", features = ["serde"], optional = true }
derive_builder = { version = "0.20.2", features = ["clippy"] } derive_builder = { version = "0.20.2" }
futures = "0.3.31" futures = "0.3"
percent-encoding = "2.3.2" percent-encoding = "2.3"
reqwest = { version = "0.12.23", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.143" serde_json = "1.0"
thiserror = "2.0.16" thiserror = "2.0"
[features]
chrono = ["dep:chrono"]

87
ferrtable/README.md Normal file
View file

@ -0,0 +1,87 @@
# Ferrtable: Ferris the Crab's Favorite Airtable Client
Ferrtable provides async Rust bindings for a subset of the
[Airtable web API](https://airtable.com/developers/web/api/introduction).
Notably, it allows records to be read from and written to arbitrary Rust types
that implement the `Clone`, `serde::Deserialize`, and `serde::Serialize` traits.
This crate follows in the footsteps of the
[airtable-api](https://crates.io/crates/airtable-api) crate from Oxide Computer
Company, which appears to have be archived and unmaintained since 2022.
By comparison, Ferrtable aims to provide a more flexible and expressive client
interface as well as greater control over paginated responses with the help of
async [streams](https://doc.rust-lang.org/book/ch17-04-streams.html).
## 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() -> Result<(), Box<dyn Error>> {
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()?
.execute()
.await?;
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()?
.stream_items::<MyRecord>();
while let Some(result) = rec_stream.next().await {
dbg!(result?.fields);
}
}
```

View file

@ -1,3 +1,5 @@
//! Main Ferrtable client used to make requests.
use std::fmt::Debug; use std::fmt::Debug;
use serde::Serialize; use serde::Serialize;
@ -52,11 +54,9 @@ impl Client {
/// ]) /// ])
/// .with_base_id("***".to_owned()) /// .with_base_id("***".to_owned())
/// .with_table_id("***".to_owned()) /// .with_table_id("***".to_owned())
/// .build() /// .build()?
/// .unwrap()
/// .execute() /// .execute()
/// .await /// .await?;
/// .unwrap();
/// ``` /// ```
pub fn create_records<I, T>(&self, records: I) -> CreateRecordsQueryBuilder<T> pub fn create_records<I, T>(&self, records: I) -> CreateRecordsQueryBuilder<T>
where where
@ -79,12 +79,11 @@ impl Client {
/// ///
/// let mut base_stream = client /// let mut base_stream = client
/// .list_bases() /// .list_bases()
/// .build() /// .build()?
/// .unwrap()
/// .stream_items(); /// .stream_items();
/// ///
/// while let Some(result) = base_stream.next().await { /// while let Some(result) = base_stream.next().await {
/// dbg!(result.unwrap()); /// dbg!(result?);
/// } /// }
/// ``` /// ```
pub fn list_bases(&self) -> ListBasesQueryBuilder { pub fn list_bases(&self) -> ListBasesQueryBuilder {
@ -104,13 +103,11 @@ impl Client {
/// .list_records() /// .list_records()
/// .with_base_id("***") /// .with_base_id("***")
/// .with_table_id("***") /// .with_table_id("***")
/// .build() /// .build()?
/// .unwrap()
/// .stream_items::<HashMap<String, serde_json::Value>>(); /// .stream_items::<HashMap<String, serde_json::Value>>();
/// ///
/// while let Some(result) = rec_stream.next().await { /// while let Some(result) = rec_stream.next().await {
/// let rec = result.unwrap(); /// dbg!(rec?.fields);
/// dbg!(rec.fields);
/// } /// }
/// ``` /// ```
pub fn list_records(&self) -> ListRecordsQueryBuilder { pub fn list_records(&self) -> ListRecordsQueryBuilder {

View file

@ -1,3 +1,5 @@
use std::fmt::Debug;
use derive_builder::Builder; use derive_builder::Builder;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@ -27,7 +29,7 @@ where
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
pub struct CreateRecordsResponse<T> pub struct CreateRecordsResponse<T>
where where
T: Clone + Serialize, T: Clone + Debug + Serialize,
{ {
/// Records successfully created in Airtable. /// Records successfully created in Airtable.
pub records: Vec<AirtableRecord<T>>, pub records: Vec<AirtableRecord<T>>,
@ -47,7 +49,7 @@ pub struct CreateRecordsDetails {
impl<T> CreateRecordsQuery<T> impl<T> CreateRecordsQuery<T>
where where
T: Clone + DeserializeOwned + Serialize, T: Clone + Debug + DeserializeOwned + Serialize,
{ {
/// Execute the API request. /// Execute the API request.
/// ///

View file

@ -1,3 +1,88 @@
//! # Ferrtable: Ferris the Crab's Favorite Airtable Client
//!
//! ## Status: Work in Progress
//!
//! Only a limited set of operations are currently supported. Any version bumps
//! before version 0.1 may include breaking changes to the crate API.
//!
//! ## Usage
//!
//! ```ignore
//! 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()
//! .stream_items::<MyRecord>();
//!
//! while let Some(result) = rec_stream.next().await {
//! let rec = result.unwrap();
//! dbg!(rec.fields);
//! }
//! }
//! ```
//!
//! ## Features
//!
//! ### `chrono`
//!
//! Deserializes certain Airtable timestamp fields as `chrono::DateTime` values
//! instead of [`String`]s. Disabled by default.
pub mod cell_values; pub mod cell_values;
pub mod client; pub mod client;
pub mod errors; pub mod errors;

View file

@ -1,4 +1,5 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fmt::Debug;
use std::pin::Pin; use std::pin::Pin;
use derive_builder::Builder; use derive_builder::Builder;
@ -53,7 +54,7 @@ pub struct ListRecordsQuery {
impl<T> PaginatedQuery<AirtableRecord<T>, ListRecordsResponse<T>> for ListRecordsQuery impl<T> PaginatedQuery<AirtableRecord<T>, ListRecordsResponse<T>> for ListRecordsQuery
where where
T: Clone + DeserializeOwned, T: Clone + Debug + DeserializeOwned,
{ {
fn get_offset(&self) -> Option<String> { fn get_offset(&self) -> Option<String> {
self.offset.clone() self.offset.clone()
@ -77,7 +78,7 @@ impl ListRecordsQuery {
self, self,
) -> Pin<Box<impl Stream<Item = Result<AirtableRecord<T>, ExecutionError>>>> ) -> Pin<Box<impl Stream<Item = Result<AirtableRecord<T>, ExecutionError>>>>
where where
T: Clone + DeserializeOwned, T: Clone + Debug + DeserializeOwned,
{ {
execute_paginated::<AirtableRecord<T>, ListRecordsResponse<T>>(self) execute_paginated::<AirtableRecord<T>, ListRecordsResponse<T>>(self)
} }
@ -86,7 +87,7 @@ impl ListRecordsQuery {
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
struct ListRecordsResponse<T> struct ListRecordsResponse<T>
where where
T: Clone, T: Clone + Debug,
{ {
offset: Option<String>, offset: Option<String>,
records: VecDeque<AirtableRecord<T>>, records: VecDeque<AirtableRecord<T>>,
@ -94,7 +95,7 @@ where
impl<T> PaginatedResponse<AirtableRecord<T>> for ListRecordsResponse<T> impl<T> PaginatedResponse<AirtableRecord<T>> for ListRecordsResponse<T>
where where
T: Clone + DeserializeOwned, T: Clone + Debug + DeserializeOwned,
{ {
fn get_offset(&self) -> Option<String> { fn get_offset(&self) -> Option<String> {
self.offset.clone() self.offset.clone()

View file

@ -1,18 +1,29 @@
use std::fmt::Debug;
#[cfg(feature = "chrono")]
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::Deserialize;
#[derive(Clone, Deserialize)] // TODO: Write custom implementation of `Debug` trait that allows the `T: Debug`
// bound to be removed.
#[derive(Clone, Debug, Deserialize)]
pub struct AirtableRecord<T> pub struct AirtableRecord<T>
where where
T: Clone, T: Clone + Debug,
{ {
/// Record ID. /// Record ID.
pub id: String, pub id: String,
/// Timestamp of record creation. /// Timestamp of record creation.
#[cfg(feature = "chrono")]
#[serde(rename = "createdTime")] #[serde(rename = "createdTime")]
pub created_time: DateTime<Utc>, pub created_time: DateTime<Utc>,
/// Timestamp of record creation.
#[cfg(not(feature = "chrono"))]
#[serde(rename = "createdTime")]
pub created_time: String,
/// Contents of record data. /// Contents of record data.
pub fields: T, pub fields: T,