Apollo data source for Airtable, heavily inspired by apollo-datasource-mongodb
npm i apollo-datasource-airtable
This package uses DataLoader for batching and per-request memoization caching. It also optionally (if you provide a ttl
) does shared application-level caching (using either the default Apollo InMemoryLRUCache
or the cache you provide to ApolloServer()). It does this for the following methods:
The basic setup is subclassing AirtableDataSource
and using the API methods:
data-sources/Users.js
const { AirtableDataSource } = require('apollo-datasource-airtable');
const services = require('../../services');
module.exports.Users = class extends AirtableDataSource {
constructor() {
super(services.Airtable.base('users'));
}
}
and:
import Airtable from 'airtable';
import Users from './data-sources/Users.js';
const base = new Airtable({ apiKey: AIRTABLE_API_KEY }).base(AIRTABLE_BASE);
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
users: new Users(),
}),
});
Inside the data source, the table is available at this.table
(e.g. this.table.select({filterByFormula: ""})
). The request's context is available at this.context
. For example, if you put the logged-in user's ID on context as context.currentUserId
:
module.exports.Users = class extends AirtableDataSource {
...
async getPrivateUserData(userId) {
const isAuthorized = this.context.currentUserId === userId
if (isAuthorized) {
const user = await this.findOneById(userId)
return user && user.privateData
}
}
}
If you want to implement an initialize method, it must call the parent method:
module.exports.Users = class extends AirtableDataSource {
initialize(config) {
super.initialize(config);
...
}
}
This is the main feature, and is always enabled. Here's a full example:
module.exports.Users = class extends AirtableDataSource {
...
getUser(userId) {
return this.findOneById(userId);
}
}
module.exports.Posts = class extends AirtableDataSource {
...
getPosts(postIds) {
return this.findManyByIds(postIds);
}
}
const resolvers = {
Post: {
author: (post, _, { dataSources: { users } }) =>
users.getUser(post.authorId),
},
User: {
posts: (user, _, { dataSources: { posts } }) =>
posts.getPosts(user.postIds),
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
users: new Users(),
posts: new Posts(),
}),
});
To enable shared application-level caching, you do everything from the above section, and you add the ttl
(in seconds) option to findOneById()
:
const MINUTE = 60;
module.exports.Users = class extends AirtableDataSource {
...
getUser(userId) {
return this.findOneById(userId, { ttl: MINUTE });
}
updateUserName(userId, newName) {
this.deleteFromCacheById(userId);
return this.table.update([
{
"id": "userId",
"fields": {
"name": newName
}
}
]);
}
}
const resolvers = {
Post: {
author: (post, _, { users }) => users.getUser(post.authorId),
},
Mutation: {
changeName: (_, { userId, newName }, { users, currentUserId }) =>
currentUserId === userId && users.updateUserName(userId, newName),
},
};
Here we also call deleteFromCacheById()
to remove the user from the cache when the user's data changes. If we're okay with people receiving out-of-date data for the duration of our ttl
—in this case, for as long as a minute—then we don't need to bother adding calls to deleteFromCacheById()
.
The type of the id
argument must match the type used in Airtable, which is a string.
this.findOneById(id, { ttl })
Resolves to the found record. Uses DataLoader to load id
. DataLoader uses table.select({ filterByFormula: "OR(FIND("targetValue", LOWER(ARRAYJOIN({actualValues}))))>0)
. Optionally caches the record if ttl
is set (in whole positive seconds).
this.findManyByIds(ids, { ttl })
Calls findOneById()
for each id. Resolves to an array of records.
this.findByFields(fields, { ttl })
Resolves to an array of records matching the passed fields.
fields
has this type:
interface Fields {
[fieldName: string]:
| string
| number
| boolean
| string
| (string | number | boolean | string)[];
}
// get user by username
// `table.select({ filterByFormula: "OR(FIND("testUser", LOWER(ARRAYJOIN({username}))))>0)`
this.findByFields({
username: 'testUser',
});
// get all users with either the "gaming" OR "games" interest
// `table.select({ filterByFormula: "OR(FIND("gaming", LOWER(ARRAYJOIN({interests}))))>0, FIND("games", LOWER(ARRAYJOIN({interests})))>0)`
this.findByFields({
interests: ['gaming', 'games'],
});
// get user by username AND with either the "gaming" OR "games" interest
// `table.select({ filterByFormula: "OR(FIND("testUser", LOWER(ARRAYJOIN({username}))))>0, FIND("gaming", LOWER(ARRAYJOIN({interests}))))>0, FIND("games", LOWER(ARRAYJOIN({interests})))>0)`
this.findByFields({
username: 'testUser',
interests: ['gaming', 'games'],
});
this.deleteFromCacheById(id)
Deletes a record from the cache that was fetched with findOneById
or findManyByIds
.
this.deleteFromCacheByFields(fields)
Deletes a record from the cache that was fetched with findByFields
. Fields should be passed in exactly the same way they were used to find with.