Compare commits
6 commits
eb5e2f4847
...
2b5e5c5aae
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2b5e5c5aae | ||
![]() |
b5a961d7cc | ||
![]() |
11b92d78a0 | ||
![]() |
aa2558c679 | ||
![]() |
2e9f787154 | ||
![]() |
c8337c182a |
10 changed files with 268 additions and 124 deletions
20
LICENSE.md
Normal file
20
LICENSE.md
Normal 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.
|
86
README.md
86
README.md
|
@ -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
1
README.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
./ferrtable/README.md
|
38
ferrtable/Cargo.lock
generated
38
ferrtable/Cargo.lock
generated
|
@ -233,7 +233,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
|||
|
||||
[[package]]
|
||||
name = "ferrtable"
|
||||
version = "0.1.0"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"derive_builder",
|
||||
|
@ -546,9 +546,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.63"
|
||||
version = "0.1.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
|
@ -1540,15 +1540,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
version = "0.62.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-link 0.2.0",
|
||||
"windows-result 0.4.0",
|
||||
"windows-strings 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1592,8 +1592,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1605,6 +1605,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
|
@ -1614,6 +1623,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
|
|
@ -1,14 +1,24 @@
|
|||
[package]
|
||||
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"
|
||||
keywords = ["api", "client"]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://forge.secondsystemtech.com/brent/ferrtable"
|
||||
|
||||
[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"
|
||||
chrono = { version = "0.4.42", features = ["serde"], optional = true }
|
||||
derive_builder = { version = "0.20.2" }
|
||||
futures = "0.3"
|
||||
percent-encoding = "2.3"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
|
||||
[features]
|
||||
chrono = ["dep:chrono"]
|
||||
|
|
87
ferrtable/README.md
Normal file
87
ferrtable/README.md
Normal 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);
|
||||
}
|
||||
}
|
||||
```
|
|
@ -52,11 +52,9 @@ impl Client {
|
|||
/// ])
|
||||
/// .with_base_id("***".to_owned())
|
||||
/// .with_table_id("***".to_owned())
|
||||
/// .build()
|
||||
/// .unwrap()
|
||||
/// .build()?
|
||||
/// .execute()
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// .await?;
|
||||
/// ```
|
||||
pub fn create_records<I, T>(&self, records: I) -> CreateRecordsQueryBuilder<T>
|
||||
where
|
||||
|
@ -79,12 +77,11 @@ impl Client {
|
|||
///
|
||||
/// let mut base_stream = client
|
||||
/// .list_bases()
|
||||
/// .build()
|
||||
/// .unwrap()
|
||||
/// .build()?
|
||||
/// .stream_items();
|
||||
///
|
||||
/// while let Some(result) = base_stream.next().await {
|
||||
/// dbg!(result.unwrap());
|
||||
/// dbg!(result?);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn list_bases(&self) -> ListBasesQueryBuilder {
|
||||
|
@ -104,13 +101,11 @@ impl Client {
|
|||
/// .list_records()
|
||||
/// .with_base_id("***")
|
||||
/// .with_table_id("***")
|
||||
/// .build()
|
||||
/// .unwrap()
|
||||
/// .build()?
|
||||
/// .stream_items::<HashMap<String, serde_json::Value>>();
|
||||
///
|
||||
/// while let Some(result) = rec_stream.next().await {
|
||||
/// let rec = result.unwrap();
|
||||
/// dbg!(rec.fields);
|
||||
/// dbg!(rec?.fields);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn list_records(&self) -> ListRecordsQueryBuilder {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use derive_builder::Builder;
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
@ -27,7 +29,7 @@ where
|
|||
#[derive(Clone, Deserialize)]
|
||||
pub struct CreateRecordsResponse<T>
|
||||
where
|
||||
T: Clone + Serialize,
|
||||
T: Clone + Debug + Serialize,
|
||||
{
|
||||
/// Records successfully created in Airtable.
|
||||
pub records: Vec<AirtableRecord<T>>,
|
||||
|
@ -47,7 +49,7 @@ pub struct CreateRecordsDetails {
|
|||
|
||||
impl<T> CreateRecordsQuery<T>
|
||||
where
|
||||
T: Clone + DeserializeOwned + Serialize,
|
||||
T: Clone + Debug + DeserializeOwned + Serialize,
|
||||
{
|
||||
/// Execute the API request.
|
||||
///
|
||||
|
|
|
@ -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 client;
|
||||
pub mod errors;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::fmt::Debug;
|
||||
use std::pin::Pin;
|
||||
|
||||
use derive_builder::Builder;
|
||||
|
@ -53,7 +54,7 @@ pub struct ListRecordsQuery {
|
|||
|
||||
impl<T> PaginatedQuery<AirtableRecord<T>, ListRecordsResponse<T>> for ListRecordsQuery
|
||||
where
|
||||
T: Clone + DeserializeOwned,
|
||||
T: Clone + Debug + DeserializeOwned,
|
||||
{
|
||||
fn get_offset(&self) -> Option<String> {
|
||||
self.offset.clone()
|
||||
|
@ -77,7 +78,7 @@ impl ListRecordsQuery {
|
|||
self,
|
||||
) -> Pin<Box<impl Stream<Item = Result<AirtableRecord<T>, ExecutionError>>>>
|
||||
where
|
||||
T: Clone + DeserializeOwned,
|
||||
T: Clone + Debug + DeserializeOwned,
|
||||
{
|
||||
execute_paginated::<AirtableRecord<T>, ListRecordsResponse<T>>(self)
|
||||
}
|
||||
|
@ -86,7 +87,7 @@ impl ListRecordsQuery {
|
|||
#[derive(Clone, Deserialize)]
|
||||
struct ListRecordsResponse<T>
|
||||
where
|
||||
T: Clone,
|
||||
T: Clone + Debug,
|
||||
{
|
||||
offset: Option<String>,
|
||||
records: VecDeque<AirtableRecord<T>>,
|
||||
|
@ -94,7 +95,7 @@ where
|
|||
|
||||
impl<T> PaginatedResponse<AirtableRecord<T>> for ListRecordsResponse<T>
|
||||
where
|
||||
T: Clone + DeserializeOwned,
|
||||
T: Clone + Debug + DeserializeOwned,
|
||||
{
|
||||
fn get_offset(&self) -> Option<String> {
|
||||
self.offset.clone()
|
||||
|
|
|
@ -1,18 +1,29 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
#[cfg(feature = "chrono")]
|
||||
use chrono::{DateTime, Utc};
|
||||
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>
|
||||
where
|
||||
T: Clone,
|
||||
T: Clone + Debug,
|
||||
{
|
||||
/// Record ID.
|
||||
pub id: String,
|
||||
|
||||
/// Timestamp of record creation.
|
||||
#[cfg(feature = "chrono")]
|
||||
#[serde(rename = "createdTime")]
|
||||
pub created_time: DateTime<Utc>,
|
||||
|
||||
/// Timestamp of record creation.
|
||||
#[cfg(not(feature = "chrono"))]
|
||||
#[serde(rename = "createdTime")]
|
||||
pub created_time: String,
|
||||
|
||||
/// Contents of record data.
|
||||
pub fields: T,
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue