evaluate build() fns implicitly when executing

This commit is contained in:
Brent Schroeter 2026-01-11 20:42:42 +00:00
parent 4a76f13d58
commit b2d1bdcaac
9 changed files with 120 additions and 79 deletions

View file

@ -32,7 +32,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let client = Client::new_from_access_token(&settings.access_token)?; let client = Client::new_from_access_token(&settings.access_token)?;
println!("Testing Client::list_bases()..."); println!("Testing Client::list_bases()...");
let mut bases = client.list_bases().build()?.stream_items(); let mut bases = client.list_bases().stream_items()?;
while let Some(res) = bases.next().await { while let Some(res) = bases.next().await {
dbg!(res?); dbg!(res?);
} }
@ -44,19 +44,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
notes: Some("Just for fun, no other reason.".to_owned()), notes: Some("Just for fun, no other reason.".to_owned()),
status: Some(RecordStatus::InProgress), status: Some(RecordStatus::InProgress),
}]) }])
.with_base_id(settings.base_id.clone()) .with_base_id(&settings.base_id)
.with_table_id(settings.table_id.clone()) .with_table_id(&settings.table_id)
.build()?
.execute() .execute()
.await?; .await?;
println!("Testing Client::list_records()..."); println!("Testing Client::list_records()...");
let records = client let records = client
.list_records() .list_records()
.with_base_id(settings.base_id.clone()) .with_base_id(&settings.base_id)
.with_table_id(settings.table_id.clone()) .with_table_id(&settings.table_id)
.build()? .stream_items::<TestRecord>()?
.stream_items::<TestRecord>()
.collect::<Vec<_>>() .collect::<Vec<_>>()
.await .await
.into_iter() .into_iter()
@ -67,20 +65,18 @@ async fn main() -> Result<(), Box<dyn Error>> {
let record = client let record = client
.get_record() .get_record()
.with_base_id(settings.base_id.clone()) .with_base_id(&settings.base_id)
.with_table_id(settings.table_id.clone()) .with_table_id(&settings.table_id)
.with_record_id("does_not_exist".to_string()) .with_record_id("does_not_exist")
.build()?
.fetch_optional::<TestRecord>() .fetch_optional::<TestRecord>()
.await?; .await?;
assert!(record.is_none()); assert!(record.is_none());
let record = client let record = client
.get_record() .get_record()
.with_base_id(settings.base_id.clone()) .with_base_id(&settings.base_id)
.with_table_id(settings.table_id.clone()) .with_table_id(&settings.table_id)
.with_record_id(records.first().unwrap().id.clone()) .with_record_id(&records.first().unwrap().id)
.build()?
.fetch_optional::<TestRecord>() .fetch_optional::<TestRecord>()
.await?; .await?;
dbg!(record); dbg!(record);

View file

@ -68,19 +68,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
status: Status::InProgress, status: Status::InProgress,
attachments: vec![], attachments: vec![],
}]) }])
.with_base_id("***".to_owned()) .with_base_id("***")
.with_table_id("***".to_owned()) .with_table_id("***")
.build()?
.execute() .execute()
.await?; .await?;
let mut rec_stream = client let mut rec_stream = client
.list_records() .list_records()
.with_base_id("***".to_owned()) .with_base_id("***")
.with_table_id("***".to_owned()) .with_table_id("***")
.with_filter("{status} = 'Todo' || {status} = 'In Progress'".to_owned()) .with_filter("{status} = 'Todo' || {status} = 'In Progress'")
.build()? .stream_items::<MyRecord>()?;
.stream_items::<MyRecord>();
while let Some(result) = rec_stream.next().await { while let Some(result) = rec_stream.next().await {
dbg!(result?.fields); dbg!(result?.fields);

View file

@ -57,9 +57,8 @@ impl Client {
/// ("status".to_owned(), "In progress".to_owned()), /// ("status".to_owned(), "In progress".to_owned()),
/// ]), /// ]),
/// ]) /// ])
/// .with_base_id("***".to_owned()) /// .with_base_id("***")
/// .with_table_id("***".to_owned()) /// .with_table_id("***")
/// .build()?
/// .execute() /// .execute()
/// .await?; /// .await?;
/// # Ok(()) /// # Ok(())
@ -94,10 +93,9 @@ impl Client {
/// # let client = Client::new_from_access_token("*****")?; /// # let client = Client::new_from_access_token("*****")?;
/// let result = client /// let result = client
/// .get_record() /// .get_record()
/// .with_base_id("***".to_owned()) /// .with_base_id("***")
/// .with_table_id("***".to_owned()) /// .with_table_id("***")
/// .with_record_id("***".to_owned()) /// .with_record_id("***")
/// .build()?
/// .fetch_optional::<HashMap<String, String>>() /// .fetch_optional::<HashMap<String, String>>()
/// .await?; /// .await?;
/// dbg!(result); /// dbg!(result);
@ -124,8 +122,7 @@ impl Client {
/// # let client = Client::new_from_access_token("*****")?; /// # let client = Client::new_from_access_token("*****")?;
/// let mut base_stream = client /// let mut base_stream = client
/// .list_bases() /// .list_bases()
/// .build()? /// .stream_items()?;
/// .stream_items();
/// ///
/// while let Some(result) = base_stream.next().await { /// while let Some(result) = base_stream.next().await {
/// dbg!(result?); /// dbg!(result?);
@ -155,10 +152,9 @@ impl Client {
/// # let client = Client::new_from_access_token("*****")?; /// # let client = Client::new_from_access_token("*****")?;
/// let mut rec_stream = client /// let mut rec_stream = client
/// .list_records() /// .list_records()
/// .with_base_id("***".to_owned()) /// .with_base_id("***")
/// .with_table_id("***".to_owned()) /// .with_table_id("***")
/// .build()? /// .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 {
/// dbg!(result?.fields); /// dbg!(result?.fields);

View file

@ -4,14 +4,23 @@ use derive_builder::Builder;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde::{Deserialize, Serialize, de::DeserializeOwned};
use crate::{client::Client, errors::ExecutionError, types::AirtableRecord}; use crate::{
client::Client,
errors::{ExecutionError, Result},
types::AirtableRecord,
};
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
#[builder(pattern = "owned", setter(prefix = "with"))] #[builder(
build_fn(error = "ExecutionError", private),
pattern = "owned",
setter(prefix = "with")
)]
pub struct CreateRecordsQuery<T> pub struct CreateRecordsQuery<T>
where where
T: Serialize, T: Serialize,
{ {
#[builder(setter(into))]
base_id: String, base_id: String,
#[builder(vis = "pub(crate)")] #[builder(vis = "pub(crate)")]
@ -20,6 +29,7 @@ where
#[builder(vis = "pub(crate)")] #[builder(vis = "pub(crate)")]
records: Vec<T>, records: Vec<T>,
#[builder(setter(into))]
table_id: String, table_id: String,
} }
@ -44,7 +54,7 @@ pub struct CreateRecordsDetails {
pub reasons: Vec<String>, pub reasons: Vec<String>,
} }
impl<T> CreateRecordsQuery<T> impl<T> CreateRecordsQueryBuilder<T>
where where
T: Clone + Debug + DeserializeOwned + Serialize, T: Clone + Debug + DeserializeOwned + Serialize,
{ {
@ -54,7 +64,9 @@ where
/// underlying `reqwest::Error`. This may be improved in future releases /// underlying `reqwest::Error`. This may be improved in future releases
/// to better differentiate between network, serialization, deserialization, /// to better differentiate between network, serialization, deserialization,
/// and API errors. /// and API errors.
pub async fn execute(self) -> Result<CreateRecordsResponse<T>, ExecutionError> { pub async fn execute(self) -> Result<CreateRecordsResponse<T>> {
let query = self.build()?;
#[derive(Serialize)] #[derive(Serialize)]
struct Record<RT: Serialize> { struct Record<RT: Serialize> {
fields: RT, fields: RT,
@ -63,13 +75,14 @@ where
struct RequestBody<RT: Serialize> { struct RequestBody<RT: Serialize> {
records: Vec<Record<RT>>, 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 base_id = utf8_percent_encode(&query.base_id, NON_ALPHANUMERIC).to_string();
let http_resp = self let table_id = utf8_percent_encode(&query.table_id, NON_ALPHANUMERIC).to_string();
let http_resp = query
.client .client
.post_path(&format!("v0/{base_id}/{table_id}")) .post_path(&format!("v0/{base_id}/{table_id}"))
.json(&RequestBody { .json(&RequestBody {
records: self records: query
.records .records
.into_iter() .into_iter()
.map(|rec| Record { fields: rec }) .map(|rec| Record { fields: rec })

View file

@ -5,6 +5,9 @@ use thiserror::Error;
pub enum ExecutionError { pub enum ExecutionError {
#[error("error making http request to airtable api: {0}")] #[error("error making http request to airtable api: {0}")]
Reqwest(reqwest::Error), Reqwest(reqwest::Error),
#[error("incomplete airtable api request information: {0}")]
Builder(derive_builder::UninitializedFieldError),
} }
impl From<reqwest::Error> for ExecutionError { impl From<reqwest::Error> for ExecutionError {
@ -12,3 +15,16 @@ impl From<reqwest::Error> for ExecutionError {
Self::Reqwest(value) Self::Reqwest(value)
} }
} }
// In addition to the self-evident purpose of type conversion, this allows our
// type to be specified as the error type for auto-generated `build()` methods,
// by annotating the relevant struct declaration with
// `#[builder(build_fn(error = "crate::errors::ExecutionError"))]`.
impl From<derive_builder::UninitializedFieldError> for ExecutionError {
fn from(value: derive_builder::UninitializedFieldError) -> Self {
Self::Builder(value)
}
}
// Custom `Result` type helps to make complex method signatures more concise.
pub type Result<T> = std::result::Result<T, ExecutionError>;

View file

@ -4,36 +4,50 @@ use derive_builder::Builder;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use crate::{client::Client, errors::ExecutionError, types::AirtableRecord}; use crate::{
client::Client,
errors::{ExecutionError, Result},
types::AirtableRecord,
};
#[derive(Builder, Clone, Debug, Serialize)] #[derive(Builder, Clone, Debug, Serialize)]
#[builder(pattern = "owned", setter(prefix = "with"))] #[builder(
build_fn(error = "ExecutionError", private),
pattern = "owned",
setter(prefix = "with")
)]
pub struct GetRecordQuery { pub struct GetRecordQuery {
#[builder(setter(into))]
#[serde(skip)] #[serde(skip)]
base_id: String, base_id: String,
#[serde(skip)]
#[builder(vis = "pub(crate)")] #[builder(vis = "pub(crate)")]
#[serde(skip)]
client: Client, client: Client,
#[builder(setter(into))]
#[serde(skip)] #[serde(skip)]
record_id: String, record_id: String,
#[builder(setter(into))]
#[serde(skip)] #[serde(skip)]
table_id: String, table_id: String,
} }
impl GetRecordQuery { impl GetRecordQueryBuilder {
pub async fn fetch_optional<T>(self) -> Result<Option<AirtableRecord<T>>, ExecutionError> pub async fn fetch_optional<T>(self) -> Result<Option<AirtableRecord<T>>>
where where
T: Clone + Debug + DeserializeOwned, T: Clone + Debug + DeserializeOwned,
{ {
let base_id = utf8_percent_encode(&self.base_id, NON_ALPHANUMERIC).to_string(); let query = self.build()?;
let table_id = utf8_percent_encode(&self.table_id, NON_ALPHANUMERIC).to_string(); let http_resp = query
let record_id = utf8_percent_encode(&self.record_id, NON_ALPHANUMERIC).to_string();
let http_resp = self
.client .client
.get_path(&format!("v0/{base_id}/{table_id}/{record_id}")) .get_path(&format!(
"v0/{base_id}/{table_id}/{record_id}",
base_id = utf8_percent_encode(&query.base_id, NON_ALPHANUMERIC),
table_id = utf8_percent_encode(&query.table_id, NON_ALPHANUMERIC),
record_id = utf8_percent_encode(&query.record_id, NON_ALPHANUMERIC),
))
.send() .send()
.await?; .await?;
match http_resp.error_for_status() { match http_resp.error_for_status() {

View file

@ -55,19 +55,17 @@
//! status: Status::InProgress, //! status: Status::InProgress,
//! attachments: vec![], //! attachments: vec![],
//! }]) //! }])
//! .with_base_id("***".to_owned()) //! .with_base_id("***")
//! .with_table_id("***".to_owned()) //! .with_table_id("***")
//! .build()?
//! .execute() //! .execute()
//! .await?; //! .await?;
//! //!
//! let mut rec_stream = client //! let mut rec_stream = client
//! .list_records() //! .list_records()
//! .with_base_id("***".to_owned()) //! .with_base_id("***")
//! .with_table_id("***".to_owned()) //! .with_table_id("***")
//! .with_filter(Some("{status} = 'Todo' || {status} = 'In Progress'".to_owned())) //! .with_filter("{status} = 'Todo' || {status} = 'In Progress'")
//! .build()? //! .stream_items::<MyRecord>()?;
//! .stream_items::<MyRecord>();
//! //!
//! while let Some(result) = rec_stream.next().await { //! while let Some(result) = rec_stream.next().await {
//! dbg!(result?.fields); //! dbg!(result?.fields);

View file

@ -6,12 +6,16 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
client::Client, client::Client,
errors::ExecutionError, errors::{ExecutionError, Result},
pagination::{PaginatedQuery, PaginatedResponse, execute_paginated}, pagination::{PaginatedQuery, PaginatedResponse, execute_paginated},
}; };
#[derive(Builder, Clone, Debug, Serialize)] #[derive(Builder, Clone, Debug, Serialize)]
#[builder(pattern = "owned", setter(prefix = "with"))] #[builder(
build_fn(error = "ExecutionError", private),
pattern = "owned",
setter(prefix = "with")
)]
pub struct ListBasesQuery { pub struct ListBasesQuery {
#[serde(skip)] #[serde(skip)]
#[builder(vis = "pub(crate)")] #[builder(vis = "pub(crate)")]
@ -39,9 +43,10 @@ impl PaginatedQuery<Base, ListBasesResponse> for ListBasesQuery {
} }
} }
impl ListBasesQuery { impl ListBasesQueryBuilder {
pub fn stream_items(self) -> Pin<Box<impl Stream<Item = Result<Base, ExecutionError>>>> { pub fn stream_items(self) -> Result<Pin<Box<impl Stream<Item = Result<Base>>>>> {
execute_paginated::<Base, ListBasesResponse>(self) self.build()
.map(execute_paginated::<Base, ListBasesResponse>)
} }
} }

View file

@ -7,25 +7,30 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned};
use crate::{ use crate::{
client::Client, client::Client,
errors::ExecutionError, errors::{ExecutionError, Result},
pagination::{PaginatedQuery, PaginatedResponse, execute_paginated}, pagination::{PaginatedQuery, PaginatedResponse, execute_paginated},
types::AirtableRecord, types::AirtableRecord,
}; };
#[derive(Builder, Clone, Debug, Serialize)] #[derive(Builder, Clone, Debug, Serialize)]
#[builder(pattern = "owned", setter(prefix = "with"))] #[builder(
build_fn(error = "ExecutionError", private),
pattern = "owned",
setter(prefix = "with")
)]
pub struct ListRecordsQuery { pub struct ListRecordsQuery {
#[builder(setter(into))]
#[serde(skip)] #[serde(skip)]
base_id: String, base_id: String,
#[serde(skip)]
#[builder(vis = "pub(crate)")] #[builder(vis = "pub(crate)")]
#[serde(skip)]
client: Client, client: Client,
/// Only data for fields whose names or IDs are in this list will be /// 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 /// included in the result. If you don't need every field, you can use this
/// parameter to reduce the amount of data transferred. /// parameter to reduce the amount of data transferred.
#[builder(default)] #[builder(default, setter(into, strip_option))]
fields: Option<Vec<String>>, fields: Option<Vec<String>>,
/// A formula used to filter records. The formula will be evaluated for /// A formula used to filter records. The formula will be evaluated for
@ -34,7 +39,7 @@ pub struct ListRecordsQuery {
/// ///
/// If combined with the view parameter, only records in that view which /// If combined with the view parameter, only records in that view which
/// satisfy the formula will be returned. /// satisfy the formula will be returned.
#[builder(default)] #[builder(default, setter(into, strip_option))]
// filterByFormula is renamed so that the builder method, that is, // filterByFormula is renamed so that the builder method, that is,
// `.with_filter()`, reads more cleanly. // `.with_filter()`, reads more cleanly.
#[serde(rename = "filterByFormula")] #[serde(rename = "filterByFormula")]
@ -43,10 +48,11 @@ pub struct ListRecordsQuery {
#[builder(default, private)] #[builder(default, private)]
offset: Option<String>, offset: Option<String>,
#[builder(default, setter(into, strip_option))]
#[serde(rename = "pageSize")] #[serde(rename = "pageSize")]
#[builder(default)]
page_size: Option<usize>, page_size: Option<usize>,
#[builder(setter(into))]
#[serde(skip)] #[serde(skip)]
table_id: String, table_id: String,
} }
@ -72,14 +78,13 @@ where
} }
} }
impl ListRecordsQuery { impl ListRecordsQueryBuilder {
pub fn stream_items<T>( pub fn stream_items<T>(self) -> Result<Pin<Box<impl Stream<Item = Result<AirtableRecord<T>>>>>>
self,
) -> Pin<Box<impl Stream<Item = Result<AirtableRecord<T>, ExecutionError>>>>
where where
T: Clone + Debug + DeserializeOwned, T: Clone + Debug + DeserializeOwned,
{ {
execute_paginated::<AirtableRecord<T>, ListRecordsResponse<T>>(self) self.build()
.map(execute_paginated::<AirtableRecord<T>, ListRecordsResponse<T>>)
} }
} }