diff --git a/.bumpy/update-deps.md b/.bumpy/update-deps.md new file mode 100644 index 0000000..bf399bb --- /dev/null +++ b/.bumpy/update-deps.md @@ -0,0 +1,7 @@ +--- +google-spreadsheet: minor +--- + +upgrade ky from v1 to v2 + +If you use `doc.sheetsApi` or `doc.driveApi` directly, note that ky v2 changed its hook signatures (hooks now receive state objects instead of direct Request/Error params) and renamed `prefixUrl` to `prefix`. See the [ky v2 migration guide](https://github.com/sindresorhus/ky/releases/tag/v2.0.0) for details. diff --git a/.env.schema b/.env.schema index 4f321a7..5a94bdb 100644 --- a/.env.schema +++ b/.env.schema @@ -24,4 +24,4 @@ CI= # Delay in ms to use when running tests # @type=number -TEST_DELAY=200 \ No newline at end of file +TEST_DELAY=if($CI, 300, 200) \ No newline at end of file diff --git a/bun.lock b/bun.lock index e4d9e15..f0c34b9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "google-spreadsheet", "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.8.1", "docsify-cli": "^4.4.4", "eslint": "^8.41.0", "eslint-config-airbnb-base": "^15.0.0", @@ -23,7 +23,7 @@ "nock": "^14.0.11", "tsdown": "^0.20.3", "typescript": "^5.9.3", - "varlock": "^1.0.0", + "varlock": "^1.1.0", "vitest": "^4.0.18", }, "peerDependencies": { @@ -271,7 +271,7 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@varlock/bumpy": ["@varlock/bumpy@1.5.1", "", { "bin": { "bumpy": "dist/cli.mjs" } }, "sha512-oSGpOR7OIDJ4UXyX8ZosUAQYYgvBR3BOdNj+WHYXY0DSEWyC4Lzh09iVC9pGflo3vpZ+OtyemGSyL2nWhz/Qdw=="], + "@varlock/bumpy": ["@varlock/bumpy@1.8.1", "", { "bin": { "bumpy": "dist/cli.mjs" } }, "sha512-HhJ4UYeCPAMtSWYKO9lc5RABjpHjLZdu1lEzCJbgEWo+QOz2JCRcxpDLeH6XQJ1oQddrxUQeSiqcuuElJoCVEw=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], @@ -481,7 +481,7 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], + "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], @@ -767,7 +767,7 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "ky": ["ky@1.14.3", "", {}, "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw=="], + "ky": ["ky@2.0.2", "", {}, "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg=="], "latest-version": ["latest-version@5.1.0", "", { "dependencies": { "package-json": "^6.3.0" } }, "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA=="], @@ -1121,7 +1121,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "varlock": ["varlock@1.0.0", "", { "bin": { "varlock": "bin/cli.js" } }, "sha512-0KwOc6x+daO4BOG2kuLVDsbo3+m2avCsEH1ZAccRh/dvr9UliejleD49LHhxLXiVv7kyBgCxARWI89F/EjBwIQ=="], + "varlock": ["varlock@1.1.0", "", { "bin": { "varlock": "bin/cli.js" } }, "sha512-S6wE06TzoGBtVM0CKHN1Z4D9lW6eA3fQ8RmsPx6gZ/nkQtye19fel0gvpPMk37YOCEtHY/vaxkMIVziX6NRFCg=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], diff --git a/package.json b/package.json index 4d81ece..da71c89 100644 --- a/package.json +++ b/package.json @@ -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.8.1", "docsify-cli": "^4.4.4", "eslint": "^8.41.0", "eslint-config-airbnb-base": "^15.0.0", @@ -69,7 +69,7 @@ "nock": "^14.0.11", "tsdown": "^0.20.3", "typescript": "^5.9.3", - "varlock": "^1.0.0", + "varlock": "^1.1.0", "vitest": "^4.0.18" }, "peerDependencies": { diff --git a/src/lib/GoogleSpreadsheet.ts b/src/lib/GoogleSpreadsheet.ts index 3bf942b..a8f8d55 100644 --- a/src/lib/GoogleSpreadsheet.ts +++ b/src/lib/GoogleSpreadsheet.ts @@ -142,19 +142,23 @@ export class GoogleSpreadsheet { // 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, }); @@ -185,26 +189,21 @@ export class GoogleSpreadsheet { } /** @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)'); } @@ -494,7 +493,7 @@ export class GoogleSpreadsheet { const exportUrl = this._spreadsheetUrl.replace('edit', 'export'); const response = await this.sheetsApi.get(exportUrl, { - 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, diff --git a/src/test/exports.test.ts b/src/test/exports.test.ts new file mode 100644 index 0000000..070f67c --- /dev/null +++ b/src/test/exports.test.ts @@ -0,0 +1,70 @@ +import { + describe, expect, it, beforeAll, afterAll, afterEach, +} from 'vitest'; +import { setTimeout as delay } from 'timers/promises'; + +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(); + }); + + // export endpoint has tight rate limits + afterEach(async () => delay(3000)); + + it('can download document as XLSX', async () => { + const buffer = await doc.downloadAsXLSX(); + expect(buffer).toBeInstanceOf(ArrayBuffer); + expect(buffer.byteLength).toBeGreaterThan(0); + }); + + it('can download worksheet 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'); + + expect(lines[0]).toContain('name'); + expect(lines[0]).toContain('value'); + expect(lines[1]).toContain('Alice'); + expect(lines[2]).toContain('Bob'); + expect(lines[3]).toContain('Charlie'); + }); + + it('can download worksheet as TSV', async () => { + const buffer = await sheet.downloadAsTSV(); + expect(buffer).toBeInstanceOf(ArrayBuffer); + expect(buffer.byteLength).toBeGreaterThan(0); + + const tsvText = new TextDecoder().decode(buffer); + expect(tsvText).toContain('\t'); + expect(tsvText).toContain('Alice'); + }); + + it('can download worksheet as PDF', async () => { + const buffer = await sheet.downloadAsPDF(); + expect(buffer).toBeInstanceOf(ArrayBuffer); + expect(buffer.byteLength).toBeGreaterThan(0); + }); +});