Compare commits

..

No commits in common. "2e9f787154540627667481743b1b21e3d00b1a4d" and "1d7d5c8a59c0f13caf465be526b81d8a6505cc2e" have entirely different histories.

7 changed files with 71 additions and 172 deletions

101
README.md
View file

@ -1,16 +1,11 @@
# Ferrtable: Ferris the Crab's Favorite Airtable Client # Ferrtable: Ferris the Crab's Favorite Airtable Client
Ferrtable provides async Rust bindings for a subset of the A power vacuum churns where the venerable
[Airtable web API](https://airtable.com/developers/web/api/introduction). [airtable-api](https://crates.io/crates/airtable-api) (archived but not
Notably, it allows records to be read from and written to arbitrary Rust types forgotten) once stood tall. The world calls for a new leader to carry the
that implement the `Clone`, `serde::Deserialize`, and `serde::Serialize` traits. 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
This crate follows in the footsteps of the record straight.
[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 ## Status: Work in Progress
@ -28,64 +23,64 @@ use futures::prelude::*;
// Deserialize, and Serialize. // Deserialize, and Serialize.
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
struct MyRecord { struct MyRecord {
#[serde(rename = "Name")] #[serde(rename = "Name")]
name: String, name: String,
#[serde(rename = "Notes")] #[serde(rename = "Notes")]
notes: String, notes: String,
#[serde(rename = "Assignee")] #[serde(rename = "Assignee")]
assignee: Option<String>, assignee: Option<String>,
#[serde(rename = "Status")] #[serde(rename = "Status")]
status: Status, status: Status,
#[serde(rename = "Attachments")] #[serde(rename = "Attachments")]
attachments: Vec<ferrtable::cell_values::AttachmentRead>, attachments: Vec<ferrtable::cell_values::AttachmentRead>,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
enum Status { enum Status {
Todo, Todo,
#[serde(rename = "In progress")] #[serde(rename = "In progress")]
InProgress, InProgress,
Done, Done,
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let client = ferrtable::Client::new_from_access_token("******").unwrap(); let client = ferrtable::Client::new_from_access_token("******").unwrap();
client client
.create_records([MyRecord { .create_records([MyRecord {
name: "Steal Improbability Drive".to_owned(), name: "Steal Improbability Drive".to_owned(),
notes: "Just for fun, no other reason.".to_owned(), notes: "Just for fun, no other reason.".to_owned(),
assignee: None, assignee: None,
status: Status::InProgress, status: Status::InProgress,
attachments: vec![], attachments: vec![],
}]) }])
.with_base_id("***".to_owned()) .with_base_id("***".to_owned())
.with_table_id("***".to_owned()) .with_table_id("***".to_owned())
.build() .build()
.unwrap() .unwrap()
.execute() .execute()
.await .await
.unwrap(); .unwrap();
let mut rec_stream = client let mut rec_stream = client
.list_records() .list_records()
.with_base_id("***".to_owned()) .with_base_id("***".to_owned())
.with_table_id("***".to_owned()) .with_table_id("***".to_owned())
.with_filter("{status} = 'Todo' || {status} = 'In Progress'".to_owned()) .with_filter("{status} = 'Todo' || {status} = 'In Progress'".to_owned())
.build() .build()
.unwrap() .unwrap()
.stream_items::<MyRecord>(); .stream_items::<MyRecord>();
while let Some(result) = rec_stream.next().await { while let Some(result) = rec_stream.next().await {
let rec = result.unwrap(); let rec = result.unwrap();
dbg!(rec.fields); dbg!(rec.fields);
} }
} }
``` ```

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
chrono = { version = "0.4.42", features = ["serde"] }
derive_builder = { version = "0.20.2", features = ["clippy"] } derive_builder = { version = "0.20.2", features = ["clippy"] }
futures = "0.3.31" futures = "0.3.31"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
@ -11,7 +12,3 @@ reqwest = { version = "0.12.23", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143" serde_json = "1.0.143"
thiserror = "2.0.16" thiserror = "2.0.16"
chrono = { version = "0.4.42", features = ["serde"], optional = true }
[features]
chrono = ["dep:chrono"]

View file

@ -12,12 +12,12 @@ use serde::{Deserialize, Serialize};
/// Read only. /// Read only.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AiTextValue { pub struct AiTextValue {
pub state: String, state: String,
#[serde(rename = "isStale")] #[serde(rename = "isStale")]
pub is_stale: bool, is_stale: bool,
#[serde(rename = "errorType")] #[serde(rename = "errorType")]
pub error_type: Option<String>, error_type: Option<String>,
pub value: Option<String>, value: Option<String>,
} }
/// Attachments allow you to add images, documents, or other files which can /// Attachments allow you to add images, documents, or other files which can
@ -33,27 +33,27 @@ pub struct AiTextValue {
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AttachmentRead { pub struct AttachmentRead {
/// Unique attachment id /// Unique attachment id
pub id: String, id: String,
/// Content type, e.g. "image/jpeg" /// Content type, e.g. "image/jpeg"
#[serde(rename = "type")] #[serde(rename = "type")]
pub type_: String, type_: String,
/// Filename, e.g. "foo.jpg" /// Filename, e.g. "foo.jpg"
pub filename: String, filename: String,
/// Height, in pixels (these may be available if the attachment is an /// Height, in pixels (these may be available if the attachment is an
/// image) /// image)
pub height: Option<i32>, height: Option<i32>,
/// File size, in bytes /// File size, in bytes
pub size: usize, size: usize,
/// url, e.g. `"https://v5.airtableusercontent.com/foo"`. /// url, e.g. "https://v5.airtableusercontent.com/foo".
/// ///
/// URLs returned will expire 2 hours after being returned from our API. /// URLs returned will expire 2 hours after being returned from our API.
/// If you want to persist the attachments, we recommend downloading /// If you want to persist the attachments, we recommend downloading
/// them instead of saving the URL. See our support article for more /// them instead of saving the URL. See our support article for more
/// information. /// information.
pub url: String, url: String,
/// Width, in pixels (these may be available if the attachment is an /// Width, in pixels (these may be available if the attachment is an
/// image) /// image)
pub width: Option<i32>, width: Option<i32>,
// TODO: Add `thumbnails` field. // TODO: Add `thumbnails` field.
} }
@ -115,9 +115,9 @@ pub struct Barcode {
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Button { pub struct Button {
/// Button label /// Button label
pub label: String, label: String,
/// For "Open URL" actions, the computed url value /// For "Open URL" actions, the computed url value
pub url: Option<String>, url: Option<String>,
} }
/// A collaborator field lets you add collaborators to your records. /// A collaborator field lets you add collaborators to your records.
@ -129,23 +129,23 @@ pub struct Button {
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct CollaboratorRead { pub struct CollaboratorRead {
/// User id or group id /// User id or group id
pub id: String, id: String,
/// User's email address /// User's email address
pub email: Option<String>, email: Option<String>,
/// User's display name (may be omitted if the user hasn't created an /// User's display name (may be omitted if the user hasn't created an
/// account) /// account)
pub name: Option<String>, name: Option<String>,
/// User's collaborator permission Level /// User's collaborator permission Level
/// ///
/// This is only included if you're observing a webhooks response. /// This is only included if you're observing a webhooks response.
#[serde(rename = "permissionLevel")] #[serde(rename = "permissionLevel")]
pub permission_level: Option<String>, permission_level: Option<String>,
/// User's profile picture /// User's profile picture
/// ///
/// This is only included if it exists for the user and you're observing /// This is only included if it exists for the user and you're observing
/// a webhooks response. /// a webhooks response.
#[serde(rename = "profilePicUrl")] #[serde(rename = "profilePicUrl")]
pub profile_pic_url: Option<String>, profile_pic_url: Option<String>,
} }
/// Write only. Use CollaboratorRead for reading from fields. /// Write only. Use CollaboratorRead for reading from fields.
@ -161,8 +161,7 @@ pub enum CollaboratorWrite {
}, },
} }
/// `Option<FormulaResult>` should cover all values generated by a formula /// Option<FormulaResult> should cover all values generated by a formula field.
/// field.
/// ///
/// Read only. /// Read only.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]

View file

@ -1,88 +1,3 @@
//! # 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

@ -56,7 +56,7 @@ pub struct Base {
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
struct ListBasesResponse { pub struct ListBasesResponse {
/// If there are more records, the response will contain an offset. Pass /// 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. /// this offset into the next request to fetch the next page of records.
offset: Option<String>, offset: Option<String>,

View file

@ -84,7 +84,7 @@ impl ListRecordsQuery {
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
struct ListRecordsResponse<T> pub struct ListRecordsResponse<T>
where where
T: Clone, T: Clone,
{ {

View file

@ -1,4 +1,3 @@
#[cfg(feature = "chrono")]
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::Deserialize;
@ -11,15 +10,9 @@ where
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,