airtable/
lib.rs

1//! # airtable
2//! 
3//! Rust wrapper for the Airtable API.  The official API's documentation can be
4//! found [here](https://airtable.com/api). This is also where you can find your API
5//! tokens. This is inspired by [Airrecord for Ruby](https://github.com/sirupsen/airrecord).
6//! 
7//! The wrapper is not complete, but has the basics and is easy to extend.
8//! 
9//! [Rustdocs](https://docs.rs/airtable/)
10//! 
11//! ### Installation
12//! 
13//! Add `airtable = "*"` to your `Cargo.toml`.
14//! 
15//! ### Example
16//! 
17//! ```
18//! extern crate dotenv;
19//! extern crate serde;
20//! 
21//! use dotenv::dotenv;
22//! use std::env;
23//! use serde::{Serialize, Deserialize};
24//!
25//! // You don't need to use dotenv. I use it here because it makes it much easier to test without
26//! // publishing my keys to the kingdom :-)
27//! dotenv().ok();
28//!
29//! // Define the schema in Airtable. You don't need to type out the full row schema.
30//! // You can use the serde annotation of `default` if it's optional and rename columns,
31//! // as I've done here to map from upper-case. You must define a string id identifier.
32//! //
33//! // In this case, I'm mapping words that I have highlighted on my kindle with the # of results
34//! // on Google so I can choose which ones to learn first.
35//! #[derive(Serialize, Deserialize, Debug, Default)]
36//! struct Word {
37//!     #[serde(default, skip_serializing)]
38//!     id: String,
39//!     #[serde(rename = "Word")]
40//!     word: String,
41//!     #[serde(rename = "Google")]
42//!     google: i64,
43//!     #[serde(rename = "Next", default)]
44//!     next: bool,
45//! }
46//!
47//! // We need to define two methods on the structure so that ids can be assigned to it.
48//! //
49//! // TODO: Convert this to be a `derive(Airtable)` and be automatically defined but panic if the
50//! // `id` is not a member of the struct and is a String. Contributions welcome for this or
51//! // another ergonomic solution.
52//! impl airtable::Record for Word {
53//!     fn set_id(&mut self, id: String) {
54//!         self.id = id;
55//!     }
56//! 
57//!     fn id(&self) -> &str {
58//!         &self.id
59//!     }
60//! }
61//!
62//! // Define the base object to operate on.
63//! let base = airtable::new::<Word>(
64//!     &env::var("AIRTABLE_KEY").unwrap(),
65//!     &env::var("AIRTABLE_BASE_WORDS_KEY").unwrap(),
66//!     "Words",
67//! );
68//!
69//! // Query on the base. This implements the Iterator Trait and will paginate when reaching a page
70//! // boundary. If you remove the `take(200)`, it'll just paginate through everything.
71//! let mut results: Vec<_> = base
72//!     .query()
73//!     .view("To Learn")
74//!     .sort("Next", airtable::SortDirection::Descending)
75//!     .sort("Google", airtable::SortDirection::Descending)
76//!     .sort("Created", airtable::SortDirection::Descending)
77//!     .formula("FIND(\"Harry Potter\", Source)")
78//!     .into_iter()
79//!     .take(200)
80//!     .collect();
81//!
82//! // Pop the first element by taking ownership of it and print it
83//! let mut word = results.remove(0);
84//! println!("{:?}", word);
85//!
86//! // Toggle the flag and update the record.
87//! word.next = !word.next;
88//! base.update(&word);
89//!
90//! // Create a new word!
91//! let mut new_word = Word {
92//!     word: "lurid".to_string(),
93//!     google: 6870000,
94//!     next: true,
95//!     // Set id to nil and other attributes we may not care about or not know yet.
96//!     .. Default::default()
97//! };
98//!
99//! println!("{:?}", base.create(&new_word));
100//! ```
101//! 
102//! License: MIT
103#![allow(dead_code)]
104extern crate failure;
105extern crate reqwest;
106extern crate serde;
107extern crate serde_json;
108
109#[cfg(test)]
110extern crate mockito;
111
112use serde::{Serialize, Deserialize};
113use failure::Error;
114use reqwest::header;
115use reqwest::Url;
116use std::marker::PhantomData;
117
118const URL: &str = "https://api.airtable.com/v0";
119
120#[derive(Debug)]
121pub struct Base<T: Record> {
122    http_client: reqwest::Client,
123
124    table: String,
125    api_key: String,
126    app_key: String,
127
128    phantom: PhantomData<T>,
129}
130
131pub fn new<T>(api_key: &str, app_key: &str, table: &str) -> Base<T>
132where
133    T: Record,
134{
135    let mut headers = header::HeaderMap::new();
136    headers.insert(
137        header::AUTHORIZATION,
138        header::HeaderValue::from_str(&format!("Bearer {}", &api_key)).expect("invalid api key"),
139    );
140
141    headers.insert(
142        reqwest::header::CONTENT_TYPE,
143        header::HeaderValue::from_str("application/json").expect("invalid content type"),
144    );
145
146    let http_client = reqwest::Client::builder()
147        .default_headers(headers)
148        .build()
149        .expect("unable to create client");
150
151    Base {
152        http_client,
153        api_key: api_key.to_owned(),
154        app_key: app_key.to_owned(),
155        table: table.to_owned(),
156        phantom: PhantomData,
157    }
158}
159
160#[derive(Serialize, Deserialize, Debug)]
161struct SRecord<T> {
162    #[serde(default, skip_serializing)]
163    id: String,
164    fields: T,
165}
166
167#[derive(Deserialize, Debug)]
168struct RecordPage<T> {
169    records: Vec<SRecord<T>>,
170
171    #[serde(default)]
172    offset: String,
173}
174
175pub struct Paginator<'base, T: Record> {
176    base: &'base Base<T>,
177    // TODO: Move the offset to query_builder
178    offset: Option<String>,
179    iterator: std::vec::IntoIter<T>,
180    query_builder: QueryBuilder<'base, T>,
181}
182
183impl<'base, T> Iterator for Paginator<'base, T>
184where
185    for<'de> T: Deserialize<'de>,
186    T: Record,
187{
188    type Item = T;
189    // This somewhat masks errors..
190    fn next(&mut self) -> Option<Self::Item> {
191        let next = self.iterator.next();
192        if next.is_some() {
193            return next;
194        }
195
196        if self.offset.is_none() {
197            return None;
198        }
199
200        let mut url = Url::parse(&format!(
201            "{}/{}/{}",
202            URL, self.base.app_key, self.base.table
203        ))
204        .unwrap();
205        url.query_pairs_mut()
206            .append_pair("offset", self.offset.as_ref().unwrap());
207
208        if self.query_builder.view.is_some() {
209            url.query_pairs_mut()
210                .append_pair("view", self.query_builder.view.as_ref().unwrap());
211        }
212
213        if self.query_builder.formula.is_some() {
214            url.query_pairs_mut().append_pair(
215                "filterByFormula",
216                self.query_builder.formula.as_ref().unwrap(),
217            );
218        }
219
220        if self.query_builder.sort.is_some() {
221            for (i, ref sort) in self.query_builder.sort.as_ref().unwrap().iter().enumerate() {
222                url.query_pairs_mut()
223                    .append_pair(&format!("sort[{}][field]", i), &sort.0);
224                url.query_pairs_mut()
225                    .append_pair(&format!("sort[{}][direction]", i), &sort.1.to_string());
226            }
227        }
228
229        // println!("{}", url);
230
231        let mut response = self
232            .base
233            .http_client
234            .get(url.as_str())
235            .send()
236            .ok()?;
237
238        let results: RecordPage<T> = response.json().ok()?;
239
240        if results.offset.is_empty() {
241            self.offset = None;
242        } else {
243            self.offset = Some(results.offset);
244        }
245
246        let window: Vec<T> = results
247            .records
248            .into_iter()
249            .map(|record| {
250                let mut record_t: T = record.fields;
251                record_t.set_id(record.id);
252                record_t
253            })
254            .collect();
255
256        self.iterator = window.into_iter();
257        self.iterator.next()
258    }
259}
260
261pub trait Record {
262    fn set_id(&mut self, String);
263    fn id(&self) -> &str;
264}
265
266pub enum SortDirection {
267    Descending,
268    Ascending,
269}
270
271impl ToString for SortDirection {
272    fn to_string(&self) -> String {
273        match self {
274            SortDirection::Descending => String::from("desc"),
275            SortDirection::Ascending => String::from("asc"),
276        }
277    }
278}
279
280pub struct QueryBuilder<'base, T: Record> {
281    base: &'base Base<T>,
282
283    fields: Option<Vec<String>>,
284    view: Option<String>,
285    formula: Option<String>,
286
287    // TODO: Second value here should be an enum.
288    sort: Option<Vec<(String, SortDirection)>>,
289}
290
291impl<'base, T> QueryBuilder<'base, T>
292where
293    for<'de> T: Deserialize<'de>,
294    T: Record,
295{
296    pub fn view(mut self, view: &str) -> Self {
297        self.view = Some(view.to_owned());
298        self
299    }
300
301    pub fn formula(mut self, formula: &str) -> Self {
302        self.formula = Some(formula.to_owned());
303        self
304    }
305
306    pub fn sort(mut self, field: &str, direction: SortDirection) -> Self {
307        match self.sort {
308            None => {
309                self.sort = Some(vec![(field.to_owned(), direction)]);
310            }
311            Some(ref mut sort) => {
312                let tuple = (field.to_owned(), direction);
313                sort.push(tuple);
314            }
315        };
316        self
317    }
318}
319
320impl<'base, T> IntoIterator for QueryBuilder<'base, T>
321where
322    for<'de> T: Deserialize<'de>,
323    T: Record,
324{
325    type Item = T;
326    type IntoIter = Paginator<'base, T>;
327
328    fn into_iter(self) -> Self::IntoIter {
329        Paginator {
330            base: &self.base,
331            offset: Some("".to_owned()),
332            iterator: vec![].into_iter(),
333            query_builder: self,
334        }
335    }
336}
337
338impl<T> Base<T>
339where
340    for<'de> T: Deserialize<'de>,
341    T: Record,
342{
343    pub fn query(&self) -> QueryBuilder<T> {
344        QueryBuilder {
345            base: self,
346            fields: None,
347            view: None,
348            formula: None,
349            sort: None,
350        }
351    }
352
353    pub fn create(&self, record: &T) -> Result<(), Error>
354    where
355        T: serde::Serialize,
356    {
357        let url = format!("{}/{}/{}", URL, self.app_key, self.table);
358
359        let serializing_record = SRecord {
360            id: String::new(),
361            fields: record,
362        };
363
364        let json = serde_json::to_string(&serializing_record)?;
365
366        self.http_client
367            .post(&url)
368            .body(json)
369            .send()?
370            .error_for_status()?;
371
372        Ok(())
373    }
374
375    // TODO: Perhaps pass a mutable reference to allow updating computed fields when someone does
376    // an update?
377    //
378    // TODO: Include the error body in the error.
379    pub fn update(&self, record: &T) -> Result<(), Error>
380    where
381        T: serde::Serialize,
382    {
383        let url = format!("{}/{}/{}/{}", URL, self.app_key, self.table, record.id());
384
385        let serializing_record = SRecord {
386            id: record.id().to_owned(),
387            fields: record,
388        };
389
390        let json = serde_json::to_string(&serializing_record)?;
391
392        self.http_client
393            .patch(&url)
394            .body(json)
395            .send()?
396            .error_for_status()?;
397
398        Ok(())
399    }
400}