You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

387 lines
14 KiB

const _ = require('lodash');
const { JWT } = require('google-auth-library');
const Axios = require('axios');
const GoogleSpreadsheetWorksheet = require('./GoogleSpreadsheetWorksheet');
const { getFieldMask } = require('./utils');
const GOOGLE_AUTH_SCOPES = [
'https://www.googleapis.com/auth/spreadsheets',
// the list from the sheets v4 auth for spreadsheets.get
// 'https://www.googleapis.com/auth/drive',
// 'https://www.googleapis.com/auth/drive.readonly',
// 'https://www.googleapis.com/auth/drive.file',
// 'https://www.googleapis.com/auth/spreadsheets',
// 'https://www.googleapis.com/auth/spreadsheets.readonly',
];
const AUTH_MODES = {
JWT: 'JWT',
API_KEY: 'API_KEY',
RAW_ACCESS_TOKEN: 'RAW_ACCESS_TOKEN',
OAUTH: 'OAUTH',
};
class GoogleSpreadsheet {
constructor(sheetId) {
this.spreadsheetId = sheetId;
this.authMode = null;
this._rawSheets = {};
this._rawProperties = null;
// create an axios instance with sheet root URL and interceptors to handle auth
this.axios = Axios.create({
baseURL: `https://sheets.googleapis.com/v4/spreadsheets/${sheetId || ''}`,
// send arrays in params with duplicate keys - ie `?thing=1&thing=2` vs `?thing[]=1...`
// solution taken from https://github.com/axios/axios/issues/604
paramsSerializer(params) {
let options = '';
_.keys(params).forEach((key) => {
const isParamTypeObject = typeof params[key] === 'object';
const isParamTypeArray = isParamTypeObject && (params[key].length >= 0);
if (!isParamTypeObject) options += `${key}=${encodeURIComponent(params[key])}&`;
if (isParamTypeObject && isParamTypeArray) {
_.each(params[key], (val) => {
options += `${key}=${encodeURIComponent(val)}&`;
});
}
});
return options ? options.slice(0, -1) : options;
},
});
// have to use bind here or the functions dont have access to `this` :(
this.axios.interceptors.request.use(this._setAxiosRequestAuth.bind(this));
this.axios.interceptors.response.use(
this._handleAxiosResponse.bind(this),
this._handleAxiosErrors.bind(this)
);
return this;
}
// CREATE NEW DOC ////////////////////////////////////////////////////////////////////////////////
async createNewSpreadsheetDocument(properties) {
// see updateProperties for more info about available properties
if (this.spreadsheetId) {
throw new Error('Only call `createNewSpreadsheetDocument()` on a GoogleSpreadsheet object that has no spreadsheetId set');
}
const response = await this.axios.post(this.url, {
properties,
});
this.spreadsheetId = response.data.spreadsheetId;
this.axios.defaults.baseURL += this.spreadsheetId;
this._rawProperties = response.data.properties;
_.each(response.data.sheets, (s) => this._updateOrCreateSheet(s));
}
// AUTH RELATED FUNCTIONS ////////////////////////////////////////////////////////////////////////
async useApiKey(key) {
this.authMode = AUTH_MODES.API_KEY;
this.apiKey = key;
}
// token must be created and managed (refreshed) elsewhere
async useRawAccessToken(token) {
this.authMode = AUTH_MODES.RAW_ACCESS_TOKEN;
this.accessToken = token;
}
async useOAuth2Client(oAuth2Client) {
this.authMode = AUTH_MODES.OAUTH;
this.oAuth2Client = oAuth2Client;
}
// creds should be an object obtained by loading the json file google gives you
// impersonateAs is an email of any user in the G Suite domain
// (only works if service account has domain-wide delegation enabled)
async useServiceAccountAuth(creds, impersonateAs = null) {
this.jwtClient = new JWT({
email: creds.client_email,
key: creds.private_key,
scopes: GOOGLE_AUTH_SCOPES,
subject: impersonateAs,
});
await this.renewJwtAuth();
}
async renewJwtAuth() {
this.authMode = AUTH_MODES.JWT;
await this.jwtClient.authorize();
/*
returned token looks like
{
access_token: 'secret-token...',
token_type: 'Bearer',
expiry_date: 1576005020000,
id_token: undefined,
refresh_token: 'jwt-placeholder'
}
*/
}
// TODO: provide mechanism to share single JWT auth between docs?
// INTERNAL UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////
async _setAxiosRequestAuth(config) {
// TODO: check auth mode, if valid, renew if expired, etc
if (this.authMode === AUTH_MODES.JWT) {
if (!this.jwtClient) throw new Error('JWT auth is not set up properly');
// this seems to do the right thing and only renew the token if expired
await this.jwtClient.authorize();
config.headers.Authorization = `Bearer ${this.jwtClient.credentials.access_token}`;
} else if (this.authMode === AUTH_MODES.RAW_ACCESS_TOKEN) {
if (!this.accessToken) throw new Error('Invalid access token');
config.headers.Authorization = `Bearer ${this.accessToken}`;
} else if (this.authMode === AUTH_MODES.API_KEY) {
if (!this.apiKey) throw new Error('Please set API key');
config.params = config.params || {};
config.params.key = this.apiKey;
} else if (this.authMode === AUTH_MODES.OAUTH) {
const credentials = await this.oAuth2Client.getAccessToken();
config.headers.Authorization = `Bearer ${credentials.token}`;
} else {
throw new Error('You must initialize some kind of auth before making any requests');
}
return config;
}
async _handleAxiosResponse(response) { return response; }
async _handleAxiosErrors(error) {
// console.log(error);
if (error.response && error.response.data) {
// usually the error has a code and message, but occasionally not
if (!error.response.data.error) throw error;
const { code, message } = error.response.data.error;
error.message = `Google API error - [${code}] ${message}`;
throw error;
}
if (_.get(error, 'response.status') === 403) {
if (this.authMode === AUTH_MODES.API_KEY) {
throw new Error('Sheet is private. Use authentication or make public. (see https://github.com/theoephraim/node-google-spreadsheet#a-note-on-authentication for details)');
}
}
throw error;
}
async _makeSingleUpdateRequest(requestType, requestParams) {
const response = await this.axios.post(':batchUpdate', {
requests: [{ [requestType]: requestParams }],
includeSpreadsheetInResponse: true,
// responseRanges: [string]
// responseIncludeGridData: true
});
this._updateRawProperties(response.data.updatedSpreadsheet.properties);
_.each(response.data.updatedSpreadsheet.sheets, (s) => this._updateOrCreateSheet(s));
// console.log('API RESPONSE', response.data.replies[0][requestType]);
return response.data.replies[0][requestType];
}
async _makeBatchUpdateRequest(requests, responseRanges) {
// this is used for updating batches of cells
const response = await this.axios.post(':batchUpdate', {
requests,
includeSpreadsheetInResponse: true,
...responseRanges && {
responseIncludeGridData: true,
...responseRanges !== '*' && { responseRanges },
},
});
this._updateRawProperties(response.data.updatedSpreadsheet.properties);
_.each(response.data.updatedSpreadsheet.sheets, (s) => this._updateOrCreateSheet(s));
}
_ensureInfoLoaded() {
if (!this._rawProperties) throw new Error('You must call `doc.loadInfo()` before accessing this property');
}
_updateRawProperties(newProperties) { this._rawProperties = newProperties; }
_updateOrCreateSheet({ properties, data }) {
const { sheetId } = properties;
if (!this._rawSheets[sheetId]) {
this._rawSheets[sheetId] = new GoogleSpreadsheetWorksheet(this, { properties, data });
} else {
this._rawSheets[sheetId]._rawProperties = properties;
this._rawSheets[sheetId]._fillCellData(data);
}
}
// BASIC PROPS //////////////////////////////////////////////////////////////////////////////
_getProp(param) {
this._ensureInfoLoaded();
return this._rawProperties[param];
}
_setProp(param, newVal) { // eslint-disable-line no-unused-vars
throw new Error('Do not update directly - use `updateProperties()`');
}
get title() { return this._getProp('title'); }
get locale() { return this._getProp('locale'); }
get timeZone() { return this._getProp('timeZone'); }
get autoRecalc() { return this._getProp('autoRecalc'); }
get defaultFormat() { return this._getProp('defaultFormat'); }
get spreadsheetTheme() { return this._getProp('spreadsheetTheme'); }
get iterativeCalculationSettings() { return this._getProp('iterativeCalculationSettings'); }
set title(newVal) { this._setProp('title', newVal); }
set locale(newVal) { this._setProp('locale', newVal); }
set timeZone(newVal) { this._setProp('timeZone', newVal); }
set autoRecalc(newVal) { this._setProp('autoRecalc', newVal); }
set defaultFormat(newVal) { this._setProp('defaultFormat', newVal); }
set spreadsheetTheme(newVal) { this._setProp('spreadsheetTheme', newVal); }
set iterativeCalculationSettings(newVal) { this._setProp('iterativeCalculationSettings', newVal); }
async updateProperties(properties) {
// updateSpreadsheetProperties
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties
/*
title (string) - title of the spreadsheet
locale (string) - ISO code
autoRecalc (enum) - ON_CHANGE|MINUTE|HOUR
timeZone (string) - timezone code
iterativeCalculationSettings (object) - see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#IterativeCalculationSettings
*/
await this._makeSingleUpdateRequest('updateSpreadsheetProperties', {
properties,
fields: getFieldMask(properties),
});
}
// BASIC INFO ////////////////////////////////////////////////////////////////////////////////////
async loadInfo(includeCells) {
const response = await this.axios.get('/', {
params: {
...includeCells && { includeGridData: true },
},
});
this._rawProperties = response.data.properties;
_.each(response.data.sheets, (s) => this._updateOrCreateSheet(s));
}
async getInfo() { return this.loadInfo(); } // alias to mimic old version
resetLocalCache() {
this._rawProperties = null;
this._rawSheets = {};
}
// WORKSHEETS ////////////////////////////////////////////////////////////////////////////////////
get sheetCount() {
this._ensureInfoLoaded();
return _.values(this._rawSheets).length;
}
get sheetsById() {
this._ensureInfoLoaded();
return this._rawSheets;
}
get sheetsByIndex() {
this._ensureInfoLoaded();
return _.sortBy(this._rawSheets, 'index');
}
get sheetsByTitle() {
this._ensureInfoLoaded();
return _.keyBy(this._rawSheets, 'title');
}
async addSheet(properties = {}) {
// Request type = `addSheet`
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest
const response = await this._makeSingleUpdateRequest('addSheet', {
properties: _.omit(properties, 'headers', 'headerValues', 'headerRowIndex'),
});
// _makeSingleUpdateRequest already adds the sheet
const newSheetId = response.properties.sheetId;
const newSheet = this.sheetsById[newSheetId];
// allow it to work with `.headers` but `.headerValues` is the real prop
const headers = properties.headerValues || properties.headers;
if (headers) {
await newSheet.setHeaderRow(headers, properties.headerRowIndex);
}
return newSheet;
}
async addWorksheet(properties) { return this.addSheet(properties); } // alias to mimic old version
async deleteSheet(sheetId) {
// Request type = `deleteSheet`
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteSheetRequest
await this._makeSingleUpdateRequest('deleteSheet', { sheetId });
delete this._rawSheets[sheetId];
}
// NAMED RANGES //////////////////////////////////////////////////////////////////////////////////
async addNamedRange(name, range, namedRangeId) {
// namedRangeId is optional
return this._makeSingleUpdateRequest('addNamedRange', {
name,
range,
namedRangeId,
});
}
async deleteNamedRange(namedRangeId) {
return this._makeSingleUpdateRequest('deleteNamedRange', { namedRangeId });
}
// LOADING CELLS /////////////////////////////////////////////////////////////////////////////////
async loadCells(filters) {
// you can pass in a single filter or an array of filters
// strings are treated as a1 ranges
// objects are treated as GridRange objects
// TODO: make it support DeveloperMetadataLookup objects
// TODO: switch to this mode if using a read-only auth token?
const readOnlyMode = this.authMode === AUTH_MODES.API_KEY;
const filtersArray = _.isArray(filters) ? filters : [filters];
const dataFilters = _.map(filtersArray, (filter) => {
if (_.isString(filter)) {
return readOnlyMode ? filter : { a1Range: filter };
}
if (_.isObject(filter)) {
if (readOnlyMode) {
throw new Error('Only A1 ranges are supported when fetching cells with read-only access (using only an API key)');
}
// TODO: make this support Developer Metadata filters
return { gridRange: filter };
}
throw new Error('Each filter must be an A1 range string or a gridrange object');
});
let result;
// when using an API key only, we must use the regular get endpoint
// because :getByDataFilter requires higher access
if (this.authMode === AUTH_MODES.API_KEY) {
result = await this.axios.get('/', {
params: {
includeGridData: true,
ranges: dataFilters,
},
});
// otherwise we use the getByDataFilter endpoint because it is more flexible
} else {
result = await this.axios.post(':getByDataFilter', {
includeGridData: true,
dataFilters,
});
}
const { sheets } = result.data;
_.each(sheets, (sheet) => { this._updateOrCreateSheet(sheet); });
}
}
module.exports = GoogleSpreadsheet;