UNPKG

@noloco/google-spreadsheet

Version:

Google Sheets API -- simple interface to read/write data and manage sheets

763 lines (660 loc) 26.2 kB
import Axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig, } from 'axios'; import { Stream } from 'stream'; import * as _ from './lodash'; import { GoogleSpreadsheetWorksheet } from './GoogleSpreadsheetWorksheet'; import { axiosParamsSerializer, getFieldMask } from './utils'; import { A1Range, DataFilter, DeveloperMetadataDataFilter, DeveloperMetadataId, DeveloperMetadataKey, DeveloperMetadataLocation, DeveloperMetadataValue, DeveloperMetadataVisibility, DimensionRange, GridRange, NamedRangeId, SpreadsheetId, SpreadsheetProperties, WorksheetId, WorksheetProperties, } from './types/sheets-types'; import { PermissionRoles, PermissionsList, PublicPermissionRoles } from './types/drive-types'; import { RecursivePartial } from './types/util-types'; import { AUTH_MODES, GoogleApiAuth } from './types/auth-types'; const SHEETS_API_BASE_URL = 'https://sheets.googleapis.com/v4/spreadsheets'; const DRIVE_API_BASE_URL = 'https://www.googleapis.com/drive/v3/files'; const EXPORT_CONFIG: Record<string, { singleWorksheet?: boolean }> = { html: {}, zip: {}, xlsx: {}, ods: {}, csv: { singleWorksheet: true }, tsv: { singleWorksheet: true }, pdf: { singleWorksheet: true }, }; type ExportFileTypes = keyof typeof EXPORT_CONFIG; function getAuthMode(auth: GoogleApiAuth) { if ('getRequestHeaders' in auth) return AUTH_MODES.GOOGLE_AUTH_CLIENT; if ('token' in auth && auth.token) return AUTH_MODES.RAW_ACCESS_TOKEN; // google-auth-library now has an empty `apiKey` property if ('apiKey' in auth && auth.apiKey) return AUTH_MODES.API_KEY; throw new Error('Invalid auth'); } async function getRequestAuthConfig(auth: GoogleApiAuth) { // google-auth-libary methods all can call this method to get the right headers // JWT | OAuth2Client | GoogleAuth | Impersonate | AuthClient if ('getRequestHeaders' in auth) { const headers = await auth.getRequestHeaders(); return { headers }; } // API key only access passes through the api key as a query param // (note this can only provide read-only access) if ('apiKey' in auth && auth.apiKey) { return { params: { key: auth.apiKey } }; } // RAW ACCESS TOKEN if ('token' in auth && auth.token) { return { headers: { Authorization: `Bearer ${auth.token}` } }; } throw new Error('Invalid auth'); } /** * Google Sheets document * * @description * **This class represents an entire google spreadsheet document** * Provides methods to interact with document metadata/settings, formatting, manage sheets, and acts as the main gateway to interacting with sheets and data that the document contains.q * */ export class GoogleSpreadsheet { readonly spreadsheetId: string; public auth: GoogleApiAuth; get authMode() { return getAuthMode(this.auth); } private _rawSheets: any; private _rawProperties = null as SpreadsheetProperties | null; private _spreadsheetUrl = null as string | null; private _deleted = false; private _rateLimitedRetries = 0; private _initialRateLimitedRetryDelay = 3000; /** * Sheets API [axios](https://axios-http.com) instance * authentication is automatically attached * can be used if unsupported sheets calls need to be made * @see https://developers.google.com/sheets/api/reference/rest * */ readonly sheetsApi: AxiosInstance; /** * Drive API [axios](https://axios-http.com) instance * authentication automatically attached * can be used if unsupported drive calls need to be made * @topic permissions * @see https://developers.google.com/drive/api/v3/reference * */ readonly driveApi: AxiosInstance; /** * initialize new GoogleSpreadsheet * @category Initialization * */ constructor( /** id of google spreadsheet doc */ spreadsheetId: SpreadsheetId, /** authentication to use with Google Sheets API */ auth: GoogleApiAuth ) { this.spreadsheetId = spreadsheetId; this.auth = auth; this._rawSheets = {}; this._spreadsheetUrl = null; // create an axios instance with sheet root URL and interceptors to handle auth this.sheetsApi = Axios.create({ baseURL: `${SHEETS_API_BASE_URL}/${spreadsheetId}`, paramsSerializer: axiosParamsSerializer, // removing limits in axios for large requests // https://stackoverflow.com/questions/56868023/error-request-body-larger-than-maxbodylength-limit-when-sending-base64-post-req maxContentLength: Infinity, maxBodyLength: Infinity, }); this.driveApi = Axios.create({ baseURL: `${DRIVE_API_BASE_URL}/${spreadsheetId}`, paramsSerializer: axiosParamsSerializer, }); // have to use bind here or the functions dont have access to `this` :( this.sheetsApi.interceptors.request.use(this._setAxiosRequestAuth.bind(this)); this.sheetsApi.interceptors.response.use( this._handleAxiosResponse.bind(this), this._handleAxiosErrors(this.sheetsApi).bind(this) ); this.driveApi.interceptors.request.use(this._setAxiosRequestAuth.bind(this)); this.driveApi.interceptors.response.use( this._handleAxiosResponse.bind(this), this._handleAxiosErrors(this.driveApi).bind(this) ); } setRetryOptions(retries:number, retryDelay:number) { this._rateLimitedRetries = retries; this._initialRateLimitedRetryDelay = retryDelay; } // AUTH RELATED FUNCTIONS //////////////////////////////////////////////////////////////////////// // INTERNAL UTILITY FUNCTIONS //////////////////////////////////////////////////////////////////// /** @internal */ async _setAxiosRequestAuth(config: InternalAxiosRequestConfig) { const authConfig = await getRequestAuthConfig(this.auth); _.each(authConfig.headers, (val, key) => { config.headers.set(key, val); }); config.params = { ...config.params, ...authConfig.params }; return config; } /** @internal */ async _handleAxiosResponse(response: AxiosResponse) { return response; } /** @internal */ _handleAxiosErrors(axiosInstance: AxiosInstance) { return async (error: AxiosError) => { if (_.get(error, 'response.status') === 429) { const config = error.config as InternalAxiosRequestConfig & { retryCount?: number }; const retryCount = config?.retryCount ?? 0; if (this._rateLimitedRetries > 0 && retryCount < this._rateLimitedRetries) { config.retryCount = retryCount + 1; return new Promise((resolve) => { setTimeout(() => { resolve(axiosInstance(config)); }, this._initialRateLimitedRetryDelay * (retryCount + 1)); }); } } // console.log(error); const errorData = error.response?.data as any; if (errorData) { // usually the error has a code and message, but occasionally not if (!errorData.error) throw error; const { code, message } = errorData.error; error.message = `Google API error - [${code}] ${message}`; throw error; } if (_.get(error, 'response.status') === 403) { if ('apiKey' in this.auth) { 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; }; } /** @internal */ async _makeSingleUpdateRequest(requestType: string, requestParams: any) { const response = await this.sheetsApi.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]; } // TODO: review these types // currently only used in batching cell updates /** @internal */ async _makeBatchUpdateRequest(requests: any[], responseRanges?: string | string[]) { // this is used for updating batches of cells const response = await this.sheetsApi.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)); } /** @internal */ _ensureInfoLoaded() { if (!this._rawProperties) throw new Error('You must call `doc.loadInfo()` before accessing this property'); } /** @internal */ _updateRawProperties(newProperties: SpreadsheetProperties) { this._rawProperties = newProperties; } /** @internal */ _updateOrCreateSheet(sheetInfo: { properties: WorksheetProperties, data: any }) { const { properties, data } = sheetInfo; const { sheetId } = properties; if (!this._rawSheets[sheetId]) { this._rawSheets[sheetId] = new GoogleSpreadsheetWorksheet(this, properties, data); } else { this._rawSheets[sheetId].updateRawData(properties, data); } } // BASIC PROPS ////////////////////////////////////////////////////////////////////////////// _getProp(param: keyof SpreadsheetProperties) { this._ensureInfoLoaded(); // ideally ensureInfoLoaded would assert that _rawProperties is in fact loaded // but this is not currently possible in TS - see https://github.com/microsoft/TypeScript/issues/49709 return this._rawProperties![param]; } get title(): SpreadsheetProperties['title'] { return this._getProp('title'); } get locale(): SpreadsheetProperties['locale'] { return this._getProp('locale'); } get timeZone(): SpreadsheetProperties['timeZone'] { return this._getProp('timeZone'); } get autoRecalc(): SpreadsheetProperties['autoRecalc'] { return this._getProp('autoRecalc'); } get defaultFormat(): SpreadsheetProperties['defaultFormat'] { return this._getProp('defaultFormat'); } get spreadsheetTheme(): SpreadsheetProperties['spreadsheetTheme'] { return this._getProp('spreadsheetTheme'); } get iterativeCalculationSettings(): SpreadsheetProperties['iterativeCalculationSettings'] { return this._getProp('iterativeCalculationSettings'); } /** * update spreadsheet properties * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties * */ async updateProperties(properties: Partial<SpreadsheetProperties>) { await this._makeSingleUpdateRequest('updateSpreadsheetProperties', { properties, fields: getFieldMask(properties), }); } // BASIC INFO //////////////////////////////////////////////////////////////////////////////////// async loadInfo(includeCells = false) { const response = await this.sheetsApi.get('/', { params: { ...includeCells && { includeGridData: true }, }, }); if (!response.data?.spreadsheetUrl) { throw new Error(`Failed to load document info. Status: ${response.status}, Status Text: ${response.statusText}, Data: ${JSON.stringify(response.data)}`); } this._spreadsheetUrl = response.data.spreadsheetUrl; this._rawProperties = response.data.properties; _.each(response.data.sheets, (s) => this._updateOrCreateSheet(s)); } resetLocalCache() { this._rawProperties = null; this._rawSheets = {}; } // WORKSHEETS //////////////////////////////////////////////////////////////////////////////////// get sheetCount() { this._ensureInfoLoaded(); return _.values(this._rawSheets).length; } get sheetsById(): Record<WorksheetId, GoogleSpreadsheetWorksheet> { this._ensureInfoLoaded(); return this._rawSheets; } get sheetsByIndex(): GoogleSpreadsheetWorksheet[] { this._ensureInfoLoaded(); return _.sortBy(this._rawSheets, 'index'); } get sheetsByTitle(): Record<string, GoogleSpreadsheetWorksheet> { this._ensureInfoLoaded(); return _.keyBy(this._rawSheets, 'title'); } /** * Add new worksheet to document * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest * */ async addSheet( properties: Partial< RecursivePartial<WorksheetProperties> & { headerValues: string[], headerRowIndex: number } > = {} ) { const response = await this._makeSingleUpdateRequest('addSheet', { properties: _.omit(properties, 'headerValues', 'headerRowIndex'), }); // _makeSingleUpdateRequest already adds the sheet const newSheetId = response.properties.sheetId; const newSheet = this.sheetsById[newSheetId]; if (properties.headerValues) { await newSheet.setHeaderRow(properties.headerValues, properties.headerRowIndex); } return newSheet; } /** * delete a worksheet * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteSheetRequest * */ async deleteSheet(sheetId: WorksheetId) { await this._makeSingleUpdateRequest('deleteSheet', { sheetId }); delete this._rawSheets[sheetId]; } // NAMED RANGES ////////////////////////////////////////////////////////////////////////////////// /** * create a new named range * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddNamedRangeRequest */ async addNamedRange( /** name of new named range */ name: string, /** GridRange object describing range */ range: GridRange, /** id for named range (optional) */ namedRangeId?: string ) { // TODO: add named range to local cache return this._makeSingleUpdateRequest('addNamedRange', { name, namedRangeId, range, }); } /** * delete a named range * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteNamedRangeRequest * */ async deleteNamedRange( /** id of named range to delete */ namedRangeId: NamedRangeId ) { // TODO: remove named range from local cache return this._makeSingleUpdateRequest('deleteNamedRange', { namedRangeId }); } // LOADING CELLS ///////////////////////////////////////////////////////////////////////////////// /** fetch cell data into local cache */ async loadCells( /** * single filter or array of filters * strings are treated as A1 ranges, objects are treated as GridRange objects * pass nothing to fetch all cells * */ filters?: DataFilter | DataFilter[] ) { // 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.sheetsApi.get('/', { params: { includeGridData: true, ranges: dataFilters, }, }); // otherwise we use the getByDataFilter endpoint because it is more flexible } else { result = await this.sheetsApi.post(':getByDataFilter', { includeGridData: true, dataFilters, }); } const { sheets } = result.data; _.each(sheets, (sheet) => { this._updateOrCreateSheet(sheet); }); } // EXPORTING ///////////////////////////////////////////////////////////// /** * export/download helper, not meant to be called directly (use downloadAsX methods on spreadsheet and worksheet instead) * @internal */ async _downloadAs( fileType: ExportFileTypes, worksheetId: WorksheetId | undefined, returnStreamInsteadOfBuffer?: boolean ) { // see https://stackoverflow.com/questions/11619805/using-the-google-drive-api-to-download-a-spreadsheet-in-csv-format/51235960#51235960 if (!EXPORT_CONFIG[fileType]) throw new Error(`unsupported export fileType - ${fileType}`); if (EXPORT_CONFIG[fileType].singleWorksheet) { if (worksheetId === undefined) throw new Error(`Must specify worksheetId when exporting as ${fileType}`); } else if (worksheetId) throw new Error(`Cannot specify worksheetId when exporting as ${fileType}`); // google UI shows "html" but passes through "zip" if (fileType === 'html') fileType = 'zip'; if (!this._spreadsheetUrl) throw new Error('Cannot export sheet that is not fully loaded'); const exportUrl = this._spreadsheetUrl.replace('/edit', '/export'); const response = await this.sheetsApi.get(exportUrl, { baseURL: '', // unset baseUrl since we're not hitting the normal sheets API params: { id: this.spreadsheetId, format: fileType, ...worksheetId && { gid: worksheetId }, }, responseType: returnStreamInsteadOfBuffer ? 'stream' : 'arraybuffer', }); return response.data; } /** * exports entire document as html file (zipped) * @topic export * */ async downloadAsZippedHTML(): Promise<ArrayBuffer>; async downloadAsZippedHTML(returnStreamInsteadOfBuffer: false): Promise<ArrayBuffer>; async downloadAsZippedHTML(returnStreamInsteadOfBuffer: true): Promise<Stream>; async downloadAsZippedHTML(returnStreamInsteadOfBuffer?: boolean) { return this._downloadAs('html', undefined, returnStreamInsteadOfBuffer); } /** * @deprecated * use `doc.downloadAsZippedHTML()` instead * */ async downloadAsHTML(returnStreamInsteadOfBuffer?: boolean) { return this._downloadAs('html', undefined, returnStreamInsteadOfBuffer); } /** * exports entire document as xlsx spreadsheet (Microsoft Office Excel) * @topic export * */ async downloadAsXLSX(): Promise<ArrayBuffer>; async downloadAsXLSX(returnStreamInsteadOfBuffer: false): Promise<ArrayBuffer>; async downloadAsXLSX(returnStreamInsteadOfBuffer: true): Promise<Stream>; async downloadAsXLSX(returnStreamInsteadOfBuffer = false) { return this._downloadAs('xlsx', undefined, returnStreamInsteadOfBuffer); } /** * exports entire document as ods spreadsheet (Open Office) * @topic export */ async downloadAsODS(): Promise<ArrayBuffer>; async downloadAsODS(returnStreamInsteadOfBuffer: false): Promise<ArrayBuffer>; async downloadAsODS(returnStreamInsteadOfBuffer: true): Promise<Stream>; async downloadAsODS(returnStreamInsteadOfBuffer = false) { return this._downloadAs('ods', undefined, returnStreamInsteadOfBuffer); } async delete() { const response = await this.driveApi.delete(''); this._deleted = true; return response.data; } // PERMISSIONS /////////////////////////////////////////////////////////////////////////////////// /** * list all permissions entries for doc */ async listPermissions(): Promise<PermissionsList> { const listReq = await this.driveApi.request({ method: 'GET', url: '/permissions', params: { fields: 'permissions(id,type,emailAddress,domain,role,displayName,photoLink,deleted)', }, }); return listReq.data.permissions as PermissionsList; } async setPublicAccessLevel(role: PublicPermissionRoles | false) { const permissions = await this.listPermissions(); const existingPublicPermission = _.find(permissions, (p) => p.type === 'anyone'); if (role === false) { if (!existingPublicPermission) { // doc is already not public... could throw an error or just do nothing return; } await this.driveApi.request({ method: 'DELETE', url: `/permissions/${existingPublicPermission.id}`, }); } else { const _shareReq = await this.driveApi.request({ method: 'POST', url: '/permissions', params: { }, data: { role: role || 'viewer', type: 'anyone', }, }); } } /** share document to email or domain */ async share(emailAddressOrDomain: string, opts?: { /** set role level, defaults to owner */ role?: PermissionRoles, /** set to true if email is for a group */ isGroup?: boolean, /** set to string to include a custom message, set to false to skip sending a notification altogether */ emailMessage?: string | false, // moveToNewOwnersRoot?: string, // /** send a notification email (default = true) */ // sendNotificationEmail?: boolean, // /** support My Drives and shared drives (default = false) */ // supportsAllDrives?: boolean, // /** Issue the request as a domain administrator */ // useDomainAdminAccess?: boolean, }) { let emailAddress: string | undefined; let domain: string | undefined; if (emailAddressOrDomain.includes('@')) { emailAddress = emailAddressOrDomain; } else { domain = emailAddressOrDomain; } const shareReq = await this.driveApi.request({ method: 'POST', url: '/permissions', params: { ...opts?.emailMessage === false && { sendNotificationEmail: false }, ..._.isString(opts?.emailMessage) && { emailMessage: opts?.emailMessage }, ...opts?.role === 'owner' && { transferOwnership: true }, }, data: { role: opts?.role || 'writer', ...emailAddress && { type: opts?.isGroup ? 'group' : 'user', emailAddress, }, ...domain && { type: 'domain', domain, }, }, }); return shareReq.data; } // // CREATE NEW DOC //////////////////////////////////////////////////////////////////////////////// static async createNewSpreadsheetDocument(auth: GoogleApiAuth, properties?: Partial<SpreadsheetProperties>) { // see updateProperties for more info about available properties if (getAuthMode(auth) === AUTH_MODES.API_KEY) { throw new Error('Cannot use api key only to create a new spreadsheet - it is only usable for read-only access of public docs'); } // TODO: handle injecting default credentials if running on google infra const authConfig = await getRequestAuthConfig(auth); const response = await Axios.request({ method: 'POST', url: SHEETS_API_BASE_URL, paramsSerializer: axiosParamsSerializer, ...authConfig, // has the auth header data: { properties, }, }); const newSpreadsheet = new GoogleSpreadsheet(response.data.spreadsheetId, auth); // TODO ideally these things aren't public, might want to refactor anyway newSpreadsheet._spreadsheetUrl = response.data.spreadsheetUrl; newSpreadsheet._rawProperties = response.data.properties; _.each(response.data.sheets, (s) => newSpreadsheet._updateOrCreateSheet(s)); return newSpreadsheet; } // // DEVELOPER METADATA //////////////////////////////////////////////////////////////////////////////// async _createDeveloperMetadata( metadataKey: DeveloperMetadataKey, metadataValue: DeveloperMetadataValue, location: Partial<DeveloperMetadataLocation>, visibility?: DeveloperMetadataVisibility, metadataId?: DeveloperMetadataId ) { // Request type = `createDeveloperMetadata` // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata#DeveloperMetadata return this._makeSingleUpdateRequest('createDeveloperMetadata', { developerMetadata: { metadataKey, metadataValue, location, visibility: visibility || 'PROJECT', metadataId, }, }).then((data) => data.developerMetadata); } async _getDeveloperMetadata(dataFilter: DeveloperMetadataDataFilter) { // Request type = `developerMetadata:search` // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata/search return this.sheetsApi .post('/developerMetadata:search', { dataFilters: [dataFilter], }) .then((response) => response.data.matchedDeveloperMetadata); } async getMetadataById(metadataId: DeveloperMetadataId) { // Request type = `developerMetadata` // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata/get return this.sheetsApi .get(`/developerMetadata/${metadataId}`) .then((response) => response.data); } async createSheetDeveloperMetadata( metadataKey: DeveloperMetadataKey, metadataValue: DeveloperMetadataValue, sheetId: WorksheetId, visibility?: DeveloperMetadataVisibility, metadataId?: DeveloperMetadataId ) { return this._createDeveloperMetadata( metadataKey, metadataValue, { sheetId: sheetId ?? 0, }, visibility, metadataId ); } async createRangeDeveloperMetadata( metadataKey: DeveloperMetadataKey, metadataValue: DeveloperMetadataValue, range: DimensionRange, visibility?: DeveloperMetadataVisibility, metadataId?: DeveloperMetadataId ) { return this._createDeveloperMetadata( metadataKey, metadataValue, { dimensionRange: range, }, visibility, metadataId ); } async getDeveloperMetadataByA1Range(a1Range: A1Range) { return this._getDeveloperMetadata({ a1Range }); } async getDeveloperMetadataByGridRange(gridRange: GridRange) { return this._getDeveloperMetadata({ gridRange }); } }