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