How to use async functions with gapi, the Google Drive client, and the file picker
Use Promises and async functions instead of callbacks with Google libraries
Quite some time has passed since I last worked with Google APIs but now I had a project to use them. I needed a solution that involved choosing a folder inside Google Drive and listing the contents inside it. Simple enough, but when I looked at the reference implementations I felt like going back a few years.
Async/await is mainstream now, providing a code structure that is simpler and easier to read, but all official code examples used a convoluted mess of callbacks for the various async tasks such as initializing a library or waiting for a result.
Sure, the example codes work and that is their primary purpose. But it took me some time to clean up everything and come up with a solution I'm happy with. This article walks through a GDrive-based webapp and describes how each part can work with async functions and Promises to provide a modern application structure.
Functionality
What I needed was a simple GDrive-based workflow. First, the user needs to log in, granting access to read the files. Then the app shows a file picker to allow choosing a folder. Finally, it fetches a list of all files inside the selected folder.
This yields 3 distinct steps in regard to functionality, but there are several more async steps along the way, most of them for initializing or loading a component.
The full async workflow looks like this:
Load the gapi
The first step is to load the library script called gapi. This is the main entry point for all Google-related API calls and it is in a js file loaded
from https://apis.google.com/js/api.js
.
Loading scripts is usually a trivial thing, just add the <script>
tag and the file is loaded for scripts following it. But loading third-party scripts
should be done asynchronously not to block everything else. This is done by adding the async
property.
But how to make sure the gapi is loaded when it is needed?
Most examples use the onload
to call an init function and put the code inside that function, such as:
<script>
function init() {
// gapi is loaded here
}
</script>
<script async defer src="https://apis.google.com/js/api.js"
onload="init()"
>
This is a "push" model, as the script loading calls the function, which has some drawbacks. What if there are multiple scripts to load before running the code? In that case, it requires some additional code to make sure everything is loaded when the init script is run. Or what if some code can be run before the gapi is needed? In that case, the push model slows down execution. And what about error handling and graceful degradation?
Fortunately, it's not hard to make a Promise that resolves when the gapi is loaded. It follows the deferred pattern which was widely used before async/await was mainstream and it works by extracting the resolve/reject callbacks of the Promise constructor so that they can be called from the outside.
This is how to wrap a Promise around the gapi loading:
<script>
const gapiPromise = new Promise((res, rej) => {
window.gapiLoaded = res;
window.gapiLoadError = rej;
});
</script>
<script async defer src="https://apis.google.com/js/api.js"
onload="window.gapiLoaded()"
onerror="window.gapiLoadError(event)"
>
With this structure, a simple await gapiPromise
ensures that the gapi is loaded, properly providing errors where they can be handled, and does not
need to restructure the app around an init function.
Login
The login flow can go in one of two ways. Since the state is managed by Google, the user might be already logged in. In that case, the app only needs to check this.
But when the user is not logged in, the app needs to show a button the initiates the auth flow and waits until the signin happens.
Let's see how to write a Promise that handles all this and when it resolves the user is logged in!
Init the auth2 client
First, anything related to authentication needs the auth2
client loaded and initialized.
The first part uses gapi.load
that is strictly callback-based. The examples usually pass a single function that is called when the client is loaded.
But this function also
accepts an object with
a callback
and an onerror
handlers. Using that allows proper error propagation.
The second part is the gapi.client.init
that provides a then-able result, compatible with await
:
await new Promise((res, rej) => {
gapi.load("client:auth2", {callback: res, onerror: rej});
});
await gapi.client.init({
apiKey: API_KEY,
clientId: CLIENT_ID,
discoveryDocs: DISCOVERY_DOCS,
scope: SCOPES
});
Wait for login
The next step is to wait for the user to sign in. Since the auth flow has to be initiated by a user action, we need a button:
<button id="authorize_button" style="display: none;">Authorize</button>
Then there are two scenarios. First, if the user is already logged in, just move on. If not, then show the button, and wait for a change in the signed in status.
// do the auth when the button is clicked
authorizeButton.onclick = () => gapi.auth2.getAuthInstance().signIn();
await new Promise((res) => {
const initialSignedIn = gapi.auth2.getAuthInstance().isSignedIn.get();
if (initialSignedIn) {
// the user is already signed in
res();
}else {
// show the button
authorizeButton.style.display = "block";
// watch the event
gapi.auth2.getAuthInstance().isSignedIn.listen((signedIn) => {
if (signedIn) {
res();
}
});
}
});
// logged in, hide the button
authorizeButton.style.display = "none";
When this code finishes the user is signed in.
Show the file picker
Same as the authorization, the picker needs to be loaded first. It uses the same gapi.load
call with the callback object:
await new Promise((res, rej) => {
gapi.load("picker", {callback: res, onerror: rej});
});
The next step is to construct and show the picker. Most of it is configuration to allow selecting folders, add the auth token, and show the component:
const folder = await new Promise((res, rej) => {
const view = new google.picker.DocsView()
// allow selecting only folders
.setIncludeFolders(true)
.setMimeTypes("application/vnd.google-apps.folder")
.setSelectFolderEnabled(true);
const picker = new google.picker.PickerBuilder()
.enableFeature(google.picker.Feature.NAV_HIDDEN)
.setOAuthToken(gapi.auth2.getAuthInstance().currentUser.get().getAuthResponse().access_token)
.addView(view)
.setCallback((data) => {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
// a folder is selected
res(data[google.picker.Response.DOCUMENTS][0]);
}else if (data[google.picker.Response.ACTION] == google.picker.Action.CANCEL) {
// cancelled
rej();
}
})
.build();
picker.setVisible(true);
});
The interesting part is the setCallback
function. This is called when the user selects something or cancels the dialog. When that happens it resolves
the Promise with the selected item or rejects it.
When this function is finished, folder
holds the selected folder or an error indicating that the dialog was dismissed without selecting anything.
Get folder contents
The last part of the solution is to issue a list
call to get the objects from GDrive.
It supports the q
parameter that lets you define the search term. To list only files that are in the selected folder, use:
const res = await gapi.client.drive.files.list({
q: `'${folder.id}' in parents`,
// ...
});
But the list
does not necessarily return all the files that match the query. It is a paginated operation, which means it returns only a limited
number of results along with a nextPageToken
if there are more. Then a separate list
call with the token can continue the fetch, until no items
remain.
Because of this, multiple calls might be needed to reliably get all the matched items.
Fortunately, it is the same pattern as other paginated operations use, such as the AWS API, so the same solution using an async generator works here also:
const getPaginatedResults = async (fn) => {
const EMPTY = Symbol("empty");
const res = [];
for await (const lf of (async function*() {
let NextMarker = EMPTY;
while (NextMarker || NextMarker === EMPTY) {
const {marker, results} = await fn(NextMarker !== EMPTY ? NextMarker : undefined);
yield* results;
NextMarker = marker;
}
})()) {
res.push(lf);
}
return res;
};
And use it to get all the files:
const files = await getPaginatedResults(async (NextMarker) => {
const res = await gapi.client.drive.files.list({
pageToken: NextMarker,
q: `'${folder.id}' in parents`,
pageSize: 20,
fields: "nextPageToken, files(id, name)",
});
return {
marker: res.result.nextPageToken,
results: res.result.files,
};
});
The fields
argument specifies what properties will be included in the response. A file object is quite large with lots of values, but it's likely your
app only needs a few of them. Use this argument to remove what is not needed and that cuts the response to a fraction of its original size. But make sure
to include the nextPageToken
as that is needed for the pagination.
Conclusion
With a few Promises, the Google libraries can be fit into a modern async/await workflow. And it well-worth the effort as the code becomes a lot cleaner and easier to understand and maintain.