Save data in user Google Drive

The goal of this post is to explain how to save data in user's google drive storage.

Prerequisites

It's probably better to know how OAuth2 and HTTP works.

  • OAuth2 is an Authorization framework. So it manages the keys to access a protected area.
  • The most important thing about HTTP in our situation is how to upload data, using POST form and multipart.

Scenario

  1. Setup a Google Cloud project. This projects holds the authorizations of the application.
  2. Authenticate and obtain credentials to access your resources (Google drive).
  3. Obtain the authorization to use an API
  4. Read/Write your data using the API

Project setup

The application publisher need to setup an application key which includes the permission (use google drive API) it needs to run, and the conditions to use the APIs (like cors) This allows publisher and user to make sure the app or someone else won't use the APIs or any other API on behalf of the user.

Go to your Google console account: https://console.cloud.google.com/ and setup these credentials.

NB: during the test phase, don't forget to add test users, otherwise it won't work with 403 errors.

Authentication

Google GSI script allows to authenticate a user. Several option are available, including some kind of automatic authentication.

 1this.$loadScript("https://accounts.google.com/gsi/client?hl=fr").then(args => {
 2	google.accounts.id.initialize({
 3		client_id: CLIENT_ID,
 4		callback: this.handleCredentialResponse
 5	});
 6	this.tokenClient = google.accounts.oauth2.initTokenClient({
 7		client_id: CLIENT_ID,
 8		scope: SCOPES,
 9		callback: () => {
10			this.googleInitialized = true
11		}, 
12	});
13	// load authorization here, looks to me more logical but you can load authorization in parallel
14});

Authorization

Once the user is authenticated you can request the application authorization to use the API "on behalf of the application"

 1this.$loadScript("https://apis.google.com/js/api.js").then(args => {
 2    gapi.load('client', () => {
 3        gapi.client.init({
 4            apiKey: API_KEY,
 5            discoveryDocs: [DISCOVERY_DOC]
 6        }).then(response => console.log(response));
 7        if (gapi.client.getToken() === null) {
 8            // Prompt the user to select a Google Account and ask for consent to share their data
 9            // when establishing a new session.
10            this.tokenClient.requestAccessToken({prompt: 'consent'});
11        } else {
12            // Skip display of account chooser and consent dialog for an existing session.
13            this.tokenClient.requestAccessToken({prompt: ''});
14        }
15    });
16});

Browse the drive

The gapi.client.drive.files is the client api to work with the drive. Here is how to find or create a folder.

 1function findOrCreateFolder(folderName) {
 2    if (this.folderId) {
 3        return new Promise((resolve, reject) => {
 4            resolve(this.folderId);
 5        });
 6    }
 7    // Rechercher le répertoire par nom
 8    return new Promise((resolve, reject) => {
 9        gapi.client.drive.files.list({
10            q: `mimeType='application/vnd.google-apps.folder' and name='${folderName}'`,
11            fields: 'files(id)',
12        }).then(searchResults => {
13            const folders = searchResults.result.files;
14            if (folders.length > 0) {
15                // Le répertoire existe, renvoyer son ID
16                this.folderId = folders[0].id;
17            } else {
18                // Le répertoire n'existe pas, le créer
19                const folderMetadata = {
20                    name: folderName,
21                    mimeType: 'application/vnd.google-apps.folder',
22                };
23                gapi.client.drive.files.create({
24                    resource: folderMetadata,
25                    fields: 'id',
26                }).then(response => {
27                    this.folderId = response.result.id;
28                });
29            }
30            resolve(this.folderId);
31        }).catch(err => {
32            reject(err)
33        });
34    });
35}

Create a file

To create a file, according to the documentation, it's very easy using The gapi.client.drive.files API. This is in a way true. You can create a file with all the metadata, but the upload of the content does not work.

The following code creates a file descriptor, so the file exists but it's empty.

1files.create({
2	name: name,
3	parents: [folderName],
4	upload_protocol: 'raw',
5	uploadType: 'media',
6	fields: 'id',
7	mimeType: 'application/json',

To work around this situation, we'll POST a form containing the file content and it's metadatas. The POST also have the authorization token in its headers.

 1const form = new FormData();
 2form.append('metadata', new Blob([JSON.stringify(metadata)], {type: 'application/json'}));
 3form.append('file', new Blob([JSON.stringify(data)], {type: 'application/json'}));
 4fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id", {
 5    method: 'POST',
 6    headers: new Headers({
 7        'Authorization': 'Bearer ' + gapi.auth.getToken().access_token
 8    }),
 9    body: form
10}).then(res => console.log(res));

Full Vuejs example

This example uses Element-plus components.

  1
  2<template>
  3	<div class="g_id_signin" data-type="standard" data-shape="rectangular" data-theme="outline" data-text="signin_with" data-size="large" data-logo_alignment="left"></div>
  4	{{ user.given_name }}
  5	<el-button @click="login" v-if="!googleInitialized">Login</el-button>
  6	<el-button @click="logout" v-if="googleInitialized">Logout</el-button>
  7	<el-button @click="listFiles" :disabled="!googleInitialized">Refresh</el-button>
  8	<el-button @click="saveJson" :disabled="!googleInitialized">Create</el-button>
  9	<el-table :data="files">
 10		<el-table-column fixed prop="name" label="Name" width="150"/>
 11		<el-table-column fixed prop="modifiedTime" label="Date" width="150"/>
 12		<el-table-column fixed="right" label="Operations" width="120">
 13			<template #default="scope">
 14				<el-button link type="primary" size="small" @click.prevent="open(scope.row)">
 15					Open
 16				</el-button>
 17			</template>
 18		</el-table-column>
 19	</el-table>
 20</template>
 21<script>
 22	import VueJwtDecode from 'vue-jwt-decode';
 23
 24	const CLIENT_ID = '....apps.googleusercontent.com';
 25	const API_KEY = '...';
 26	const DISCOVERY_DOC = 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest';
 27	const REDIRECT_URI = 'https://developers.google.com/oauthplayground';
 28	const SCOPES = 'https://www.googleapis.com/auth/drive';
 29	export default {
 30		name: "UserDrive",
 31		props: {
 32			folder: String,
 33		},
 34		components: {},
 35		data() {
 36			return {
 37				user: {},
 38				googleInitialized: false,
 39				tokenClient: null,
 40				files: [],
 41				folderId: null,
 42			}
 43		},
 44		mounted() {
 45		},
 46		methods: {
 47			open(file) {
 48				console.log(file);
 49				this.$emit("open", file);
 50			},
 51			logout() {
 52				gapi.client.setToken(null);
 53			},
 54			login() {
 55				if (!this.googleInitialized) {
 56					this.$loadScript("https://accounts.google.com/gsi/client?hl=fr").then(args => {
 57						google.accounts.id.initialize({
 58							client_id: CLIENT_ID,
 59							callback: this.handleCredentialResponse
 60						});
 61						/*google.accounts.id.prompt((resp) => {
 62                            console.log(resp);
 63                        });*/
 64						this.tokenClient = google.accounts.oauth2.initTokenClient({
 65							client_id: CLIENT_ID,
 66							scope: SCOPES,
 67							callback: () => {
 68								this.googleInitialized = true
 69							}, // defined later
 70						});
 71						this.$loadScript("https://apis.google.com/js/api.js").then(args => {
 72							gapi.load('client', () => {
 73								gapi.client.init({
 74									apiKey: API_KEY,
 75									discoveryDocs: [DISCOVERY_DOC]
 76								}).then(response => console.log(response));
 77								if (gapi.client.getToken() === null) {
 78									// Prompt the user to select a Google Account and ask for consent to share their data
 79									// when establishing a new session.
 80									this.tokenClient.requestAccessToken({prompt: 'consent'});
 81								} else {
 82									// Skip display of account chooser and consent dialog for an existing session.
 83									this.tokenClient.requestAccessToken({prompt: ''});
 84								}
 85							});
 86						});
 87					});
 88				}
 89			},
 90			handleCredentialResponse(response) {
 91				this.user = VueJwtDecode.decode(response.credential);
 92			},
 93			findOrCreateFolder(folderName) {
 94				if (this.folderId) {
 95					return new Promise((resolve, reject) => {
 96						resolve(this.folderId);
 97					});
 98				}
 99				// Rechercher le répertoire par nom
100				return new Promise((resolve, reject) => {
101					gapi.client.drive.files.list({
102						q: `mimeType='application/vnd.google-apps.folder' and name='${folderName}'`,
103						fields: 'files(id)',
104					}).then(searchResults => {
105						const folders = searchResults.result.files;
106						if (folders.length > 0) {
107							// Le répertoire existe, renvoyer son ID
108							this.folderId = folders[0].id;
109						} else {
110							// Le répertoire n'existe pas, le créer
111							const folderMetadata = {
112								name: folderName,
113								mimeType: 'application/vnd.google-apps.folder',
114							};
115							gapi.client.drive.files.create({
116								resource: folderMetadata,
117								fields: 'id',
118							}).then(response => {
119								this.folderId = response.result.id;
120							});
121						}
122						resolve(this.folderId);
123					}).catch(err => {
124						reject(err)
125					});
126				});
127			},
128			saveJson() {
129				this._saveJson({name: 'stephane'}, "stephane.json")
130			},
131			_saveJson(data, name) {
132				this.findOrCreateFolder(this.folderName)
133					.then(folder => {
134						let metadata = {
135							name: name,
136							parents: [folder],
137						};
138						let files = gapi.client.drive.files;
139						let media = {
140							mimeType: 'application/json',
141							body: JSON.stringify(data)
142						};
143						const form = new FormData();
144						form.append('metadata', new Blob([JSON.stringify(metadata)], {type: 'application/json'}));
145						form.append('file', new Blob([JSON.stringify(data)], {type: 'application/json'}));
146						fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id", {
147							method: 'POST',
148							headers: new Headers({
149								'Authorization': 'Bearer ' + gapi.auth.getToken().access_token
150							}),
151							body: form
152						}).then(res => console.log(res));
153						this.listFiles();
154					}).catch(err => console.log(err))
155			},
156			listFiles() {
157				this.findOrCreateFolder(this.folderName)
158					.then((folder) => gapi.client.drive.files.list({
159						'q': `'${folder}' in parents`,
160						'pageSize': 10,
161						'fields': 'files(id, name,modifiedTime)',
162					}).then((response) => {
163						this.files.splice(0, this.files.length);
164						response.result.files.forEach(file => {
165							this.files.push(file);
166						})
167					}).catch(err => console.log(err)));
168			}
169		}
170	}
171</script>
172<style></style>
173

Translations: