Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save peterhoward42/f648bae7af5a7e7a601a84e968e97794 to your computer and use it in GitHub Desktop.

Select an option

Save peterhoward42/f648bae7af5a7e7a601a84e968e97794 to your computer and use it in GitHub Desktop.
How I integrated Google Drive in a Web Application

I integrated a Web App with Google Drive

Mission

I'm developing a Web Application that is integrated with Google Drive.

The App helps you to create drawings and save them in your own Google Drive - just like Google Docs.

This gist shows you the code that does the integration with Google Drive, and a little about my experience in learning about it and getting it to work.

The App is purely client side code. There is no backend server - which is highly relevant to the integration approach, and for user authentication in particular.

Context

The App is a Single Page Web Application (SPA) that helps you to make drawings. I'm talking about technical drawings a bit like CAD drawings or complex diagrams where you want to position everything exactly.

All the complex business logic and heavy lifting for the App is implemented as a WASM module. (Go code)

But Google integration for user authentication and authorisation, and for interacting with Google Drive is javascript code.

It's still in development. But the integration works - and I've decided to share what I've learned about the integration in its raw state, and while the integration journey is still fresh in my mind.

Contents

In what follows:

  • I'll provide some reference links
  • Provide a bit more orientation narrative
  • Signpost the roles of code files I've included in the gist

Links

A Bit More Narrative

Securing Access Credentials

A prerequisite is setting up a Google Cloud (GCP) project. The quickstart guide referenced guide covers that. When you setup your cloud project it will give you some access credentials - like an API key, and a ClientID. Your javascript will need to use those credentials, but you must not put them directly into your code to preserve their secrecy when your code is running in the browser. You'll see in my code files - that I pull their values from environment variables. You might think environment variables is an odd concept when we're talking about code running in the browser, and you'll need some config in your final deployment config to provide the values in a secure way. I use Vite as my javascript project build tool and bundler, and Vite supports environment variables injected at the bundle build stage. Then, later, if you use Vercel to deploy your App, it can pull secrets you've configured in your Vercel project when it performs your Vite build. That process is document here .

Bootstrapping Google APIs

There is a parent, or root Google API - that the quickstart shows how to setup into the javascript global namespace as gapi. I have nothing to add to those instructions. It is a bit convoluted though, because it is a multi-step process and involves the code having a chat with Google to "discover" the APIs you've requested to use in your GCP project, then it downloads the corresponding code. Then you have to put in a line of code to initialise it etc. Part of that is to auto-generate the client javascript SDK for you. It really is "for you" the SDK generated covers only the APIs you've asked for in your project. Then you have to make sure you don't try to call any of the integration code until it is all ready.

You are actually initialising two separate Google services: 1) the Identity Service, 2) the Drive API.

The quickstart guide shows you how to achieve all this in a single body of javascript code - and it probably is the easiest way to get off the ground. But I've found for my real-world project that I needed to split the functionality into separate places to seperate concerns respectably.

Use the Client SDK or REST API?

Once the APIs are both ready, Google offer two ways to interact with the Drive APIs: 1) using the SDK client, 2) using the REST API just like you would any other rest API.

What they don't mention is that the SDK client doesn't cover all the things available in the REST API. The things missing are very difficult to work out - and I wasted a lot of time trying to work out how to do something with the SDK client that ultimately simply wasn't available. I discovered that when I wanted to create a new file on Google Drive - prepopulated with some content. That forced me to learn how to use the REST API (which you'll see in my code files). In my opinion, using the REST API is much cleaner and easier, and certainly makes debugging very much easier. I went back and changed all my Drive API calls over to the REST pattern.

Signposting the Code Files

drivecrudops.js

This is a module that Creates, Reads, Updates, and Deletes (i.e. CRUD operations) files and folders in Google Drive. You'll see that each function expects an access token as its first argument, which allows the module to be concerned ONLY with the CRUD logic, and not have to think about access permissions itself. You'll see how to CALL these functions in drivewrapper.ts below.

drivewrapper.ts

You can't just call the functions in drivecrudops.js.

Instead you have to orchestrate them being called in a way that first obtains a Google Drive access token and only then calls the function - passing in the access token as the first argument.

I won't say more about here - because it is richly commented in the file itself.

// This module isolates all the functions for Google Drive CRUD operations in one place.
//
const drawExactFolderName = "drawexact-drawings";
//---------------------------------------------------------------------------------
// Exported
//---------------------------------------------------------------------------------
// This uses Google Identity Service to provide you with information about the
// signed-in user. For example their email address, display name and "permissionsID" etc.
//
// It returns a User object.
export async function getUserInfo(access_token) {
try {
const url = new URL("https://www.googleapis.com/drive/v3/about");
url.searchParams.append("fields", "user");
const res = await fetch(url, {
method: 'GET',
headers: { 'Authorization': 'Bearer ' + access_token },
});
if (!res.ok) {
const err = new Error(`Response status: ${res.status}`);
console.log(err);
throw err;
}
const resJSON = await res.json();
return (resJSON.user);
}
catch (err) {
console.log(err);
throw err;
}
}
// This gives you a list of all the signed-in user's Google Drive files that are
// 1) Tagged internally as being created by DrawExact.
// 2) Have not been moved to the "bin" folder.
//
// It returns a list of File objects.
export async function listAllDrawings(access_token) {
// TODO DRY up paths
// TODO - currently if somehow the drive contains more than one drawexact file of the same
// name (which Drive does not preclude), then both will be found - but they'll have
// differing IDs.
// TODO - currently always get max 100 results - will need to consume ALL pages interatively for production
//
// We construct a search URL that filters for only files that have a (private) property
// declaring them to be draw exact files. That way we needn't exclude folders object from
// the search - because only files we create can have the tag.
//
const url = new URL("https://www.googleapis.com/drive/v3/files");
url.searchParams.set('q', "appProperties has { key='isDrawExactFile' and value='true' } and trashed = false");
try {
const res = await fetch(url, {
method: 'GET',
headers: new Headers({ 'Authorization': 'Bearer ' + access_token }),
});
if (!res.ok) {
const err = new Error(`Response status: ${res.status}`);
console.log(err);
throw err;
}
const resJSON = await res.json();
return resJSON.files;
}
catch (err) {
console.log(err);
throw err;
}
}
// This fetches the contents of the file having the given Drive File ID.
export async function getDrawing(access_token, drawingID) {
const url = new URL(`https://www.googleapis.com/drive/v3/files/${drawingID}`);
// Providing this url query parameter it to return the byte/string contents
// of the file as the body of the response. I.e. the response is not JSON.
url.searchParams.set('alt', "media");
try {
const res = await fetch(url, {
method: 'GET',
headers: new Headers({ 'Authorization': 'Bearer ' + access_token }),
});
if (!res.ok) {
const err = new Error(`Response status: ${res.status}`);
console.log(err);
throw err;
}
// Note calling the text() getter on the response not the JSON getter.
const resText = await res.text();
return resText;
}
catch (err) {
console.log(err);
throw err;
}
}
// A wrapper that uploads a NEW drawing file that does not yet exist on Drive.
// It makes a root drawexact folder as a side effect if it doesn't
// exist already.
//
// The implementation delegates to two dedicated functions.
//
// It returns a File object - from which the newly created FileID can be retrieved.
export async function uploadNewDrawing(access_token, args) {
const [serialisedDrawing, drawingName] = args;
try {
const drawexactFolderID = await createFolderIfNotExists(access_token, drawExactFolderName);
const file = await uploadNewDrawingFile(access_token, drawexactFolderID, drawingName, serialisedDrawing);
return file;
} catch (err) {
console.log(err);
throw err;
}
}
// This creates a folder in Drive of the given name (at the root level).
export async function createFolder(access_token, folderName) {
// TODO DRY up paths once we see duplication.
// TODO - if you call this more than once, it creates duplicate folders
// of the same name.
try {
const res = await fetch("https://www.googleapis.com/drive/v3/files", {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + access_token },
// Note it's using the generic "files" end point, but the mimetype
// specified in the request body - tells it to create a folder not a regular file.
body: JSON.stringify({
"name": folderName,
"mimeType": 'application/vnd.google-apps.folder',
})
});
if (!res.ok) {
const err = new Error(`Response status: ${res.status}`);
console.log(err);
throw err;
}
const resJSON = await res.json();
const folderID = resJSON.id;
return folderID;
}
catch (err) {
console.log(err);
throw err;
}
}
// A utility that provides the given file name having appended ".dxt" if it is not
// already present.
export function makeSureHasDrawExactFileExtension(name) {
if (!name.endsWith(".dxt")) {
name += ".dxt";
}
return name;
}
// This creates a NEW drawing file of the given name in the given folder. (folder ID, not name!)
// It appends the ".dxt" extension if it is not already present.
//
// It also tags the file as being a drawexact file using a custom file property.
//
// It does NOT at the moment check for a file already existing with the same name.
// The App drawing identity paradigm demands that you don't create drawings with
// duplicated drawing names, and enforces that rule at drawing creation time.
// But to be properly robust - it should be detected as an error here.
//
// It returns a File object - from which the newly created FileID can be retrieved.
export async function uploadNewDrawingFile(access_token, drawexactFolderID, drawingName, serialisedDrawing) {
let file = new Blob([serialisedDrawing], { type: "text/plain" });
// Because this POST is creating both metadata and a file, we have to use
// the uploadType=multipart query parameter on the URL.
var metadata = {
"name": drawingName,
"mimeType": "text/plain", // TODO might need to change this to app/octect doo dah
"parents": [drawexactFolderID],
"appProperties": {
"isDrawExactFile": "true",
}
};
// This is a recipe for constructing the mime multipart request.
var form = new FormData();
form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
form.append('file', file);
try {
const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
method: 'POST',
headers: new Headers({ 'Authorization': 'Bearer ' + access_token }),
body: form,
});
if (!res.ok) {
const err = new Error(`Response status: ${res.status}`);
console.log(err);
throw err;
}
const resJSON = await res.json();
console.log(`XXXX uploadDrawingFile() is about to return drawing id: ${resJSON}`);
return resJSON;
}
catch (err) {
console.log(err);
throw err;
}
}
// Overwrite the contents of a file that already exists on Drive.
//
// We have to use the PATCH HTTP method.
export async function uploadChangesToDrawing(access_token, args) {
const [serialisedDrawing, drawingID] = args;
try {
try {
// Using uploadType=media tells it that the request body IS the new file contents. (I.e. not JSON)
const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${drawingID}?uploadType=media`, {
method: 'PATCH',
headers: new Headers({ 'Authorization': 'Bearer ' + access_token }),
body: serialisedDrawing,
});
if (!res.ok) {
const err = new Error(`Response status: ${res.status}`);
console.log(err);
throw err;
}
const resJSON = await res.json();
console.log(`uploadChangesToDrawing() response is: ${resJSON}`);
return null;
}
catch (err) {
console.log(err);
throw err;
}
return null;
} catch (err) {
console.log(err);
throw err;
}
}
//---------------------------------------------------------------------------------
// Internal
//---------------------------------------------------------------------------------
// This creates a folder of the given name (at the root level), if it does not
// already exist.
async function createFolderIfNotExists(access_token, folderName) {
try {
let folderID = await folderExists(access_token, folderName);
if (folderID) {
return folderID;
}
folderID = await createFolder(access_token, folderName)
return folderID;
} catch (err) {
console.log(err);
throw err;
}
}
// This tests to see if the given folder exists.
// TODO with what I now know - this is needlessly expensive.
// It could be done with the List API and an appropriate "q" query.
async function folderExists(access_token, folderName) {
try {
const rootLevelFolders = await listRootLevelFolders(access_token);
const index = rootLevelFolders.findIndex((file) => { return file.name == folderName })
if (index == -1) {
return null;
}
const folder = rootLevelFolders[index];
return folder.id;
} catch (err) {
console.log(err);
throw err;
}
}
// Gives you a list of all the folder at the root level.
// It returns File objects (which is how Drive models both Files and Folders).
async function listRootLevelFolders(access_token) {
try {
const url = new URL("https://www.googleapis.com/drive/v3/files");
// todo - this way of writing a non-trivial q-search string is horribly fragile because of having to
// remember all the "and" keywords, and the need to single quote the string literals.
const qString = "mimeType = 'application/vnd.google-apps.folder' and trashed = false and 'root' in parents";
url.searchParams.set('q', qString);
const res = await fetch(url, {
method: 'GET',
headers: new Headers({ 'Authorization': 'Bearer ' + access_token }),
});
if (!res.ok) {
const err = new Error(`Response status: ${res.status}`);
console.log(err);
throw err;
}
const resJSON = await res.json();
return resJSON.files;
} catch (err) {
console.log(err);
throw err;
}
}
import { tokenClient } from "./googleapis.js";
// You use this class as a way to call functions that require a
// google api access token. Referred to below as <driveOperationFn>.
//
// It acquires the access token internally - which will sometimes require
// some Google Popup Dialogues to spring up asking the user to authentication
// and grant permissions.
//
// It expects <driveOperationFn> to:
// 1) be async
// 2) receive the access token as the first argument
//
// Your function can additionally take an arbitrary additional argument (scalar, list, object whatever.),
// which is passed-through to <driveOperationFn> from the value you provide to the constructor in <arg>.
//
// Usage:
//
// First prepare callback to tell you when it's done, and to provide a result.
//
// function doneCallbackToReceiveResult(someResult) {
// 1) ... consume the result if you need it - like user email, or list of Files etc.
// 2) resume the onDone logical flow from where you called your DriveFunctionCaller.run() method.
// }
//
// Construct the caller.
//
// const caller = new DriveFunctionCaller(driveOperationFn, doneCallbackToReceiveResult, argsToPassThrough);
//
// Run the caller asynchronously - which will eventually report done/results to the callback you
// you defined above.
//
// caller.run(); // Async - eventually calling doneCallbackToReceiveResult to receive the userEmail.
//
export class DriveFunctionCaller {
driveOperationFn: any;
args: any;
doneCallback: (result: any) => void
constructor(driveOperationFn: any, doneCallback: (result: any) => void, args: any) {
this.driveOperationFn = driveOperationFn;
this.args = args;
this.doneCallback = doneCallback;
// Note the bind(this).
// It means that when the callback gets called from the Google Token Client code, any references made
// within it to "this" will point to this DriveFunctionCaller instance being
// constructed now.
this.receiveTokenCallback = this.receiveTokenCallback.bind(this);
}
// This defines a callback that Google's token client will call back to deliver the
// freshly obtained access token.
//
// Note crucially, that it is a closure that consumes the function and arguments you
// provided at construction time, and THIS is where <driveOperationFn> gets called.
async receiveTokenCallback(resp) {
try {
// Make sure it's giving us a valid token.
const err = resp.error;
if (err !== undefined) {
console.log(err);
throw (err);
}
const token = resp.access_token;
// Call the function provided at construction time, passing in the new token,
// and then adding-in the function arguments provided at construction time.
const result = await this.driveOperationFn(token, this.args);
// Call the "done" callback given at construction time, to:
// 1) signal that it has completed.
// 2) provide the results
this.doneCallback(result);
} catch (err) {
console.log(err);
throw (err);
}
}
// This method triggers the call-flow. It is unfortunately asyncronous -
// but DOES NOT return a promise. We are stuck with this because of the way the Google API token client
// insists on working with a callback. The calling code should orchestrate its continued execution that
// depends on the Drive function from having been completed, and/or needs results from the drive function,
// from INSIDE the <doneCallback> they passed to the constructor.
//
// It asks the Google API token client to
// deliver a token back to the callback defined above, having first asked the user
// to authenticate and grant permission when necessary. As you will have seen
// earlier, it is in the callback that your drive function gets called.
//
// It awaits on your driveOperation function, and when that is complete, it calls your <doneCallback>.
run() {
try {
getToken(this.receiveTokenCallback);
} catch (err) {
console.log(err);
throw (err);
}
}
}
// This is a helper function that ostensibly just calls the Google API
// token client to trigger auth/permission and provide a token.
//
// The conditional logic is to streamline the process for the second and subsequent times it runs
// in any given session. I.e. if the Google API library says it already has a token,
// It advises the token client that it may skip asking for consent again.
// Nb. you still see the consent popup coming up, but only fleetingly, and the user doesn't
// have to interact with it. A bit clunky frankly, but this is Google functionality, not mine.
// Maybe it is a cludge to satisfy the Oauth2 requirement that the implicit flow is started
// by a UI user-gesture - which this kinda fakes.
function getToken(receiveTokenCallback) {
tokenClient.callback = receiveTokenCallback;
const chosenPrompt = globalThis.gapi.client.getToken() ? "" : "consent";
try {
tokenClient.requestAccessToken({ prompt: chosenPrompt });
} catch (err) {
console.log(err);
throw (err);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment