Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/update-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
google-spreadsheet: patch
---

bump deps
2 changes: 1 addition & 1 deletion .env.schema
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ CI=

# Delay in ms to use when running tests
# @type=number
TEST_DELAY=200
TEST_DELAY=if($CI, 300, 200)
12 changes: 6 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@
"test:ci": "vitest run"
},
"dependencies": {
"es-toolkit": "^1.44.0",
"ky": "^1.14.3"
"es-toolkit": "^1.46.1",
"ky": "^2.0.2"
},
"devDependencies": {
"@types/node": "^25.2.3",
"@typescript-eslint/eslint-plugin": "^5.59.7",
"@typescript-eslint/parser": "^5.59.7",
"@varlock/bumpy": "^1.5.1",
"@varlock/bumpy": "^1.6.0",
"docsify-cli": "^4.4.4",
"eslint": "^8.41.0",
"eslint-config-airbnb-base": "^15.0.0",
Expand Down
39 changes: 19 additions & 20 deletions src/lib/GoogleSpreadsheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,19 +142,23 @@

// create a ky instance with sheet root URL and hooks to handle auth
this.sheetsApi = ky.create({
prefixUrl: `${SHEETS_API_BASE_URL}/${spreadsheetId}`,
prefix: `${SHEETS_API_BASE_URL}/${spreadsheetId}`,
timeout: 180_000,
hooks: {
beforeRequest: [(r) => this._setAuthRequestHook(r)],
beforeError: [(e) => this._errorHook(e)],
beforeRequest: [
({ request }) => this._setAuthRequestHook(request),
],
beforeError: [
({ error }) => this._errorHook(error),
],
},
retry: retryConfig,
});
this.driveApi = ky.create({
prefixUrl: `${DRIVE_API_BASE_URL}/${spreadsheetId}`,
prefix: `${DRIVE_API_BASE_URL}/${spreadsheetId}`,
hooks: {
beforeRequest: [(r) => this._setAuthRequestHook(r)],
beforeError: [(e) => this._errorHook(e)],
beforeRequest: [({ request }) => this._setAuthRequestHook(request)],
beforeError: [({ error }) => this._errorHook(error)],
},
retry: retryConfig,
});
Expand Down Expand Up @@ -185,26 +189,21 @@
}

/** @internal */
async _errorHook(error: HTTPError) {
const { response } = error;
const errorDataText = await response?.text();
let errorData;
try {
errorData = JSON.parse(errorDataText);
} catch (e) {
// console.log('parsing json failed', errorDataText);
}
async _errorHook(error: Error) {
if (!(error instanceof HTTPError)) return error;

if (errorData) {
// usually the error has a code and message, but occasionally not
if (!errorData.error) return error;
// ky pre-parses the response body into error.data (the response body is already consumed)
const errorData = typeof error.data === 'string' ? (() => {
try { return JSON.parse(error.data as string); } catch { return undefined; }
})() : error.data;

if (errorData?.error) {
const { code, message } = errorData.error;
error.message = `Google API error - [${code}] ${message}`;
return error;
}

if (_.get(error, 'response.status') === 403) {
if (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)');
}
Expand Down Expand Up @@ -493,8 +492,8 @@
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, {

Check failure on line 495 in src/lib/GoogleSpreadsheet.ts

View workflow job for this annotation

GitHub Actions / ci

src/test/exports.test.ts > Export/download methods > worksheet-level exports > can download as TSV

HTTPError: Request failed with status code 429 Too Many Requests: GET https://docs.google.com/spreadsheets/d/148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y/export?id=148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y&format=tsv&gid=196686025 ❯ function_ node_modules/ky/distribution/core/Ky.js:141:39 ❯ node_modules/ky/distribution/core/Ky.js:179:24 ❯ GoogleSpreadsheet._downloadAs src/lib/GoogleSpreadsheet.ts:495:22 ❯ src/test/exports.test.ts:91:22 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { response: { constructor: 'Function<Response>', type: 'cors', url: 'https://doc-00-04-sheets.googleusercontent.com/export/vru05g76sn68mufsttnm362tdo/khkmvnqlngoff7jjqvj7bhje70/1777529900000/104864818384919679719/108310251886789223200/148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y?id=148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y&format=tsv&gid=196686025&dat=ABP4XCGjHdy_5fCRVOLUnp1lsBftpVPzg_WFZ1MCOyM2oiJXD6AKO7tN1TOYVUqFpcBxCA_R2agJWtxLYAOeKCV2ctyTh1vrZGioFORorZ0N-cko2ydxU2VD07WAlF4URzI9R3qRqT-2UVPIdw0it8V2Ld4HJUDrykteTyB6voyZ9B0l955evZe4NkLsP1tUrAz9Tqf7YT_i__aic_qu17_LA1Qm7JcBCifhlY4AALXp-gmFf0-tapFaNXgeRaahUvpbbGavYYeD-hbO08Dwrb38pa-M0evMjpmf7YI6JnUUq0ukZPhkrNuhv5OLjEMd6AqzFPttI5tFDF40kxBiJOjBHFIIcOLpOa7HRMq2B-MWb8Jlw0ks-ZNcCva5ooV9pWkTAkHqdLTif6EdUGeuQseVmWaJram2nxh8jjOb479fhZKMHKrQGS6le7tkbyq2pKcpJNmfDG91vYsT7JkWbjaJ9PQjat4qFQm1KQqM_HCnMVYkZ4K6x9deLUdZQlTVEXD9C-Kkdt5TdZLK5DQxtYvwmsLzaDiiwCpkObviie3_BdIEItsWu8b5zfrARU6q3RujLixvqG7XsYa1IBaX0Pf4-I0wZLHxQpNBnlx3odUSHtydKoRlzZQ5rgygE2thxYsfIV-CbzkdGn0E35JcoFTzOwQx8isdG3Ye8AkW1DO4_essizwkyF6TENCbXiLjRXykKdva0aQT6fx9HE7vSEIi38y3nlc-nbfBCo66t5TqctXBW5ukOkgXxLAycf1Y8kywTHjfuBfI-8QOH2wZdL7I43G5563rChBd9Sz4BhbcXNsnJlHJmSNpqWcilbl93FtqpGJ-mnTIpifyxLRSljxxHF2YPzcuMgE9_llkKbERJJwnU99OB_FuqxLWoe66OwtBgGb2uw6b6i66yFxvSBtSrla4gDS7Q7UkaxRugPV6UqeaEujKR_v-WjOuW-NRJ-nxwAze2ESZf6vIgomcRZyRmb6jBFC7zCIDA4kLd0p8RxTlVIX81tCB_kYmT1R6mfLDCHjrq2ew3ha4pwEk-7gov-qzE6cQQEtC1HU76gA', redirected: true, status: 429, ok: false, statusText: 'Too Many Requests', headers: { constructor: 'Function<Headers>', append: 'Function<append>', delete: 'Function<delete>', get: 'Function<get>', has: 'Function<has>', set: 'Function<set>', getSetCookie: 'Function<getSetCookie>', keys: 'Function<keys>', values: 'Function<values>', entries: 'Function<entries>', forEach: 'Function<forEach>' }, body: { constructor: 'Function<ReadableStream>', locked: true, cancel: 'Function<cancel>', getReader: 'Function<getReader>', pipeThrough: 'Function<pipeThrough>', pipeTo: 'Function<pipeTo>', tee: 'Function<tee>', values: 'Function<values>' }, bodyUsed: true, clone: 'Function<clone>', blob: 'Function<blob>', arrayBuffer: 'Function<arrayBuffer>', text: 'Function<text>', json: 'Function<json>', formData: 'Function<formData>', bytes: 'Function<bytes>' }, request: { constructor: 'Function<Request>', method: 'GET', url: 'https://docs.google.com/spreadsheets/d/148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y/export?id=148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y&format=tsv&gid=196686025', headers: { constructor: 'Function<Headers>', append: 'Function<append>', delete: 'Function<delete>', get: 'Function<get>', has: 'Function<has>', set: 'Function<set>', getSetCookie: 'Function<getSetCookie>', keys: 'Function<keys>', values: 'Function<values>', entries: 'Function<entries>', forEach: 'Function<forEach>' }, destination: '', referrer: 'about:client', referrerPolicy: '', mode: 'cors', credentials: 'same-origin', cache: 'default', redirect: 'follow', integrity: '', keepalive: false, isReloadNavigation: false, isHistoryNavigation: false, signal: { constructor: 'Function<AbortSignal>', aborted: false, reason: undefined, throwIfAborted: 'Function<throwIfAborted>', onabort: null, addEventListener: 'Function<addEventListener>', removeEventListener: 'Function<removeEventListener>', dispatchEvent: 'Function<dispatchEvent>' }, body: null, bodyUsed: false, duplex: 'half', clone: 'Function<clone>', blob: 'Function<blob>', arrayBuffer: 'Function<arrayBuffer>', text: 'Function<text>', json: 'Function<json>',

Check failure on line 495 in src/lib/GoogleSpreadsheet.ts

View workflow job for this annotation

GitHub Actions / ci

src/test/exports.test.ts > Export/download methods > worksheet-level exports > can download as CSV stream

HTTPError: Request failed with status code 429 Too Many Requests: GET https://docs.google.com/spreadsheets/d/148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y/export?id=148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y&format=csv&gid=196686025 ❯ function_ node_modules/ky/distribution/core/Ky.js:141:39 ❯ node_modules/ky/distribution/core/Ky.js:179:24 ❯ GoogleSpreadsheet._downloadAs src/lib/GoogleSpreadsheet.ts:495:22 ❯ src/test/exports.test.ts:85:22 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { response: { constructor: 'Function<Response>', type: 'cors', url: 'https://doc-00-04-sheets.googleusercontent.com/export/vru05g76sn68mufsttnm362tdo/v2s0eilo2db0m63dd5midj95us/1777529895000/104864818384919679719/108310251886789223200/148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y?id=148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y&format=csv&gid=196686025&dat=ABP4XCGzDhYX05A-QytTfcc3WXO7m5t6m8VgqJbWuvbC6fc_XQwQPrnZtquZSbSeHiduUm6dfPLLbwjHdOT4MKgRD0DchCDACFnrY9HTEQrCCFJycIsxbTkTp8D_FjIizqC581Y8fa2jsiAtAylLzcJO_kz44GKz0SqE0Aa9N9Lfu-sHZxz8p8Dm5u-lQW4Q4NmA9zNPl-Zl8Eg9UhmVxlUYhXJoDbII3EkEkHApKo-VR_FbTirNyIOf2aXJgvbAX6Keu6cRFX_sPodXQT1LbUNKSNIP0e-yKLLjr0cAVIcYGf1U1RpeTxsaxqykVvUREZeyvStOTMGWT5I00Pbp3Ai28UqG7xueIiSbFdHq7YpttfETxSYAIYFWs_EoVXdhrSN8RV8hlYL8byGJzKhGI96JxKqtDgiQxiThIfvLsq_yPwMuksdrsobz74SAGhqEmFuTMXxBDvEuiVCY2GZSyz9IinhxZxnZLUJHGnW2OKEQoYz0Zp5YLvGVPbRTsPHJ7kfSfdrLHlzXWFVgXUWrSRP2-GHkKP6scCUjU8ba2brAKhEFp6GffFyZdyGe4Ts9n9YfM-KFvflLexdJ3Ngv8RVTKvkqipsBVyo7r0v2f80fC0HUNx4Ki0qwPED3JCYNlaIAsuSVqDGnN21wCa-itS8KfRhXXF89so3E57XVNl6Ml0fBoOO0wS2CczZXLbeIWhF_nJjMpEaSX-4P_wPQqsIo-lo4RD_31pZVGFdEUQFKKRgCKVCU1cGML4PI18drnscmkb0igvLFIoHnTAhWVfPhVOoDjWmDUBZf07nKJ4kZk2R46kR1xoOB5sZWbROkguEXTFOrJjGCLzunbZpo8zYcWIUGIknzcWwX7Roe2DmohI4H8DEnuJMeN3F8sTLdKR-rFsE3-v4asL-tjzks1yMQ7Ei6cJen6ZIubXJqMuL-FEmgzu6ompvIemXKbpTy70XeCxGxxF5lERbJUKsi7bjaoQlHf7I_8T-ktuyZe9piRtvm2OBMLlRnqNb00_Kuvqyb4ilxftlkMFE8kATOuz5-sSVXDVPijywNDgLeuOk', redirected: true, status: 429, ok: false, statusText: 'Too Many Requests', headers: { constructor: 'Function<Headers>', append: 'Function<append>', delete: 'Function<delete>', get: 'Function<get>', has: 'Function<has>', set: 'Function<set>', getSetCookie: 'Function<getSetCookie>', keys: 'Function<keys>', values: 'Function<values>', entries: 'Function<entries>', forEach: 'Function<forEach>' }, body: { constructor: 'Function<ReadableStream>', locked: true, cancel: 'Function<cancel>', getReader: 'Function<getReader>', pipeThrough: 'Function<pipeThrough>', pipeTo: 'Function<pipeTo>', tee: 'Function<tee>', values: 'Function<values>' }, bodyUsed: true, clone: 'Function<clone>', blob: 'Function<blob>', arrayBuffer: 'Function<arrayBuffer>', text: 'Function<text>', json: 'Function<json>', formData: 'Function<formData>', bytes: 'Function<bytes>' }, request: { constructor: 'Function<Request>', method: 'GET', url: 'https://docs.google.com/spreadsheets/d/148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y/export?id=148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y&format=csv&gid=196686025', headers: { constructor: 'Function<Headers>', append: 'Function<append>', delete: 'Function<delete>', get: 'Function<get>', has: 'Function<has>', set: 'Function<set>', getSetCookie: 'Function<getSetCookie>', keys: 'Function<keys>', values: 'Function<values>', entries: 'Function<entries>', forEach: 'Function<forEach>' }, destination: '', referrer: 'about:client', referrerPolicy: '', mode: 'cors', credentials: 'same-origin', cache: 'default', redirect: 'follow', integrity: '', keepalive: false, isReloadNavigation: false, isHistoryNavigation: false, signal: { constructor: 'Function<AbortSignal>', aborted: false, reason: undefined, throwIfAborted: 'Function<throwIfAborted>', onabort: null, addEventListener: 'Function<addEventListener>', removeEventListener: 'Function<removeEventListener>', dispatchEvent: 'Function<dispatchEvent>' }, body: null, bodyUsed: false, duplex: 'half', clone: 'Function<clone>', blob: 'Function<blob>', arrayBuffer: 'Function<arrayBuffer>', text: 'Function<text>', json: 'Function<json>',
prefixUrl: '', // unset baseUrl since we're not hitting the normal sheets API
prefix: '', // unset baseUrl since we're not hitting the normal sheets API
searchParams: {
id: this.spreadsheetId,
format: fileType,
Expand Down
107 changes: 107 additions & 0 deletions src/test/exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
describe, expect, it, beforeAll, afterAll, afterEach,
} from 'vitest';
import { setTimeout as delay } from 'timers/promises';
import { ENV } from 'varlock/env';

import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from '..';

import { DOC_IDS, testServiceAccountAuth } from './auth/docs-and-auth';

const doc = new GoogleSpreadsheet(DOC_IDS.private, testServiceAccountAuth);
let sheet: GoogleSpreadsheetWorksheet;

describe('Export/download methods', () => {
beforeAll(async () => {
await doc.loadInfo();
sheet = await doc.addSheet({
title: `Export test ${+new Date()}`,
headerValues: ['name', 'value'],
});
await sheet.addRows([
{ name: 'Alice', value: '100' },
{ name: 'Bob', value: '200' },
{ name: 'Charlie', value: '300' },
]);
});

afterAll(async () => {
await sheet.delete();
});

// hitting rate limits when running tests on ci - so we add a short delay
if (ENV.TEST_DELAY) afterEach(async () => delay(ENV.TEST_DELAY));

describe('document-level exports', () => {
it('can download as XLSX', async () => {
const buffer = await doc.downloadAsXLSX();
expect(buffer).toBeInstanceOf(ArrayBuffer);
expect(buffer.byteLength).toBeGreaterThan(0);
});

it('can download as XLSX stream', async () => {
const stream = await doc.downloadAsXLSX(true);
expect(stream).toBeTruthy();
// ReadableStream should have a getReader method
expect(typeof (stream as ReadableStream).getReader).toBe('function');
});

it('can download as ODS', async () => {
const buffer = await doc.downloadAsODS();
expect(buffer).toBeInstanceOf(ArrayBuffer);
expect(buffer.byteLength).toBeGreaterThan(0);
});

it('can download as zipped HTML', async () => {
const buffer = await doc.downloadAsZippedHTML();
expect(buffer).toBeInstanceOf(ArrayBuffer);
expect(buffer.byteLength).toBeGreaterThan(0);
});
});

describe('worksheet-level exports', () => {
it('can download as CSV and verify content', async () => {
const buffer = await sheet.downloadAsCSV();
expect(buffer).toBeInstanceOf(ArrayBuffer);
expect(buffer.byteLength).toBeGreaterThan(0);

const csvText = new TextDecoder().decode(buffer);
const lines = csvText.trim().split('\n');

// header row
expect(lines[0]).toContain('name');
expect(lines[0]).toContain('value');

// data rows
expect(lines[1]).toContain('Alice');
expect(lines[1]).toContain('100');
expect(lines[2]).toContain('Bob');
expect(lines[2]).toContain('200');
expect(lines[3]).toContain('Charlie');
expect(lines[3]).toContain('300');
});

it('can download as CSV stream', async () => {
const stream = await sheet.downloadAsCSV(true);
expect(stream).toBeTruthy();
expect(typeof (stream as ReadableStream).getReader).toBe('function');
});

it('can download as TSV', async () => {
const buffer = await sheet.downloadAsTSV();
expect(buffer).toBeInstanceOf(ArrayBuffer);
expect(buffer.byteLength).toBeGreaterThan(0);

const tsvText = new TextDecoder().decode(buffer);
// TSV uses tabs
expect(tsvText).toContain('\t');
expect(tsvText).toContain('Alice');
});

it('can download as PDF', async () => {
const buffer = await sheet.downloadAsPDF();
expect(buffer).toBeInstanceOf(ArrayBuffer);
expect(buffer.byteLength).toBeGreaterThan(0);
});
});
});
Loading