initial commit
This commit is contained in:
commit
5ed334a454
16 changed files with 4831 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
target
|
||||||
|
ferrtable-test.config.*
|
86
README.md
Normal file
86
README.md
Normal 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
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
14
ferrtable-test/Cargo.toml
Normal 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"] }
|
60
ferrtable-test/src/main.rs
Normal file
60
ferrtable-test/src/main.rs
Normal 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.");
|
||||||
|
}
|
23
ferrtable-test/src/settings.rs
Normal file
23
ferrtable-test/src/settings.rs
Normal 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
1802
ferrtable/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
14
ferrtable/Cargo.toml
Normal file
14
ferrtable/Cargo.toml
Normal 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"
|
174
ferrtable/src/cell_values.rs
Normal file
174
ferrtable/src/cell_values.rs
Normal 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
86
ferrtable/src/client.rs
Normal 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 {{ *** }}")
|
||||||
|
}
|
||||||
|
}
|
85
ferrtable/src/create_records.rs
Normal file
85
ferrtable/src/create_records.rs
Normal 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
13
ferrtable/src/errors.rs
Normal 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
10
ferrtable/src/lib.rs
Normal 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;
|
182
ferrtable/src/list_records.rs
Normal file
182
ferrtable/src/list_records.rs
Normal 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
24
ferrtable/src/types.rs
Normal 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
2
mise.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[tools]
|
||||||
|
rust = "latest"
|
Loading…
Add table
Reference in a new issue