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

Author's image
Tamás Sallai
7 mins

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.

July 28, 2020

Free PDF guide

Sign up to our newsletter and download the "Git Tips and Tricks" guide.


In this article