diff --git a/package.json b/package.json index 554c1dc..7a0d608 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "dependencies": { "async-retry": "^1.2.1", "lru-cache": "^10.0.0", - "node-fetch": "^2.6.7", "zod": "^3.20.2", "zod-validation-error": "^1.0.1" }, @@ -40,7 +39,7 @@ "eslint-plugin-security": "^3.0.0", "graphql": "^16.10.0", "jest": "^29.3.1", - "nock": "^13.2.9", + "nock": "^14.0.0", "prettier": "^3.0.0", "supertest": "^7.0.0" }, @@ -69,7 +68,7 @@ "README.md" ], "engines": { - "node": ">=14" + "node": ">=18" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/client.js b/src/client.js index 0e3fa6f..8d558a4 100644 --- a/src/client.js +++ b/src/client.js @@ -4,11 +4,10 @@ const { gzip } = require('zlib') const { randomUUID } = require('crypto') const os = require('os') const retry = require('async-retry') -const fetch = require('node-fetch') const plugin = require('../package.json') -const compress = promisify(gzip) +const compress = /** @type {(data: string) => Promise>} */ (promisify(gzip)) const userAgent = `logql-apollo-plugin; node ${process.version}; ${os.platform()} ${os.release()}` @@ -54,10 +53,9 @@ async function sendWithRetry(path, data, config, logger) { await retry( async (bail, attempt) => { - // @ts-ignore - const res = await fetch(url, { + const res = await config.fetchFn(url, { method: 'POST', - agent: config.agent || false, + signal: AbortSignal.timeout(timeout), headers: { 'user-agent': userAgent, 'content-encoding': 'gzip', @@ -70,7 +68,6 @@ async function sendWithRetry(path, data, config, logger) { 'x-plugin-version': plugin.version, }, body: compressed, - timeout, }) if (res.status === 401 || res.status === 403) { diff --git a/src/config.js b/src/config.js index 4d207dc..0181b77 100644 --- a/src/config.js +++ b/src/config.js @@ -1,6 +1,4 @@ // @ts-check -const http = require('http') -const https = require('https') const { z } = require('zod') const { fromZodError } = require('zod-validation-error') @@ -21,12 +19,15 @@ const ConfigSchema = z.object({ sampling: z.number().min(0).max(1).default(1.0), - agent: z.instanceof(http.Agent).or(z.instanceof(https.Agent)).nullable().default(null), + fetchFn: /** @type {z.ZodDefault>} */ (z.function()).default(() => globalThis.fetch), userId: z.function().nullable().default(null), }) -/** @typedef {z.infer} Config */ +/** + * @typedef {(url: string, init?: RequestInit) => Promise>} FetchFn + * @typedef {z.infer} Config + */ /** @param {string} msg */ function logInitError(msg) { diff --git a/src/index.d.ts b/src/index.d.ts index 7dd22c4..57c8c91 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,6 +1,7 @@ import type { ApolloServerPlugin, BaseContext } from '@apollo/server' -import type * as http from 'http' -import type * as https from 'https' + +/** A fetch-compatible function. Defaults to `globalThis.fetch`. */ +export type FetchFunction = (url: string, init?: RequestInit) => Promise> export interface LogqlOptions> { apiKey: string @@ -19,7 +20,7 @@ export interface LogqlOptions> { sampling?: number - agent?: http.Agent | https.Agent | null + fetchFn?: FetchFunction userId?: ((context: TContext, headers: unknown, requestContext: unknown) => unknown) | null } diff --git a/tests/config.test.js b/tests/config.test.js index 67e11c5..acdae96 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -89,7 +89,8 @@ describe('Config Validation', () => { sampling: 0.9, - agent: null, + fetchFn: expect.any(Function), + userId: null, }) }) diff --git a/tests/index.unit.test.js b/tests/index.unit.test.js index af64fed..2162602 100644 --- a/tests/index.unit.test.js +++ b/tests/index.unit.test.js @@ -67,6 +67,7 @@ describe('handleError on sendError rejection', () => { ) await waitFor(() => sendWithRetry.mock.calls.length > 0) + expect(sendWithRetry).toHaveBeenCalled() }) }) @@ -80,6 +81,7 @@ describe('handleError on sendOperation rejection', () => { await requestHooks.willSendResponse(makeRequestContext({ errors: undefined })) await waitFor(() => sendWithRetry.mock.calls.length > 0) + expect(sendWithRetry).toHaveBeenCalled() }) }) diff --git a/tests/plugin.test.js b/tests/plugin.test.js index d8c48c7..b6ca5fe 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -12,7 +12,6 @@ const { ApolloGateway } = require('@apollo/gateway') const { createHash, randomUUID } = require('crypto') const { readFileSync } = require('fs') const { gunzipSync } = require('zlib') -const https = require('https') const request = require('supertest') const nock = require('nock') @@ -83,7 +82,6 @@ function logqlMock(endpoint = 'https://ingress.logql.io') { 'x-api-key': 'logql:FAKE_API_KEY', 'x-environment': '', 'x-plugin-name': '@logql/apollo-plugin', - 'accept-encoding': 'gzip,deflate', }, }) } @@ -210,20 +208,17 @@ describe('Schema reporting with Apollo Server', () => { expect(schemaRegistry.pendingMocks()).toHaveLength(0) }) - it('Accept a custom agent', async () => { + it('Accept a custom fetchFn', async () => { const schemaRegistry = logqlMock() .post(`/schemas/${schemaHash}`, (data) => decompress(data) === schema) .reply(204) - const agent = new https.Agent({ - keepAlive: true, - keepAliveMsec: 100, - maxSockets: 5, - }) - graphqlServer = getRegularServer(schema, resolvers, { agent }) + const fetchFn = jest.fn(globalThis.fetch) + graphqlServer = getRegularServer(schema, resolvers, { fetchFn }) await startStandaloneServer(graphqlServer, { listen: { port: 0 } }) await waitFor(() => schemaRegistry.pendingMocks().length === 0, 20, 1000) expect(schemaRegistry.pendingMocks()).toHaveLength(0) + expect(fetchFn).toHaveBeenCalled() }) }) @@ -510,7 +505,7 @@ describe('Request handling with Apollo Federation', () => { it('Send error when subgraph failed to resolve (ENETUNREACH)', async () => { let payload - const products = nock('http://products:4000').post('/graphql').replyWithError({ code: 'ENETUNREACH' }) + const products = nock('http://products:4000').post('/graphql').replyWithError('ENETUNREACH') logql .post('/errors', (res) => { payload = JSON.parse(decompress(res)) @@ -587,11 +582,11 @@ describe('Request handling with Apollo Federation', () => { }, errors: [ { - code: 'ENETUNREACH', - errno: 'ENETUNREACH', + code: expect.any(String), + errno: expect.any(String), type: 'system', - message: 'request to http://products:4000/graphql failed, reason: undefined', - stackTrace: expect.stringMatching('FetchError: request to http://products:4000/graphql failed, reason: undefined'), + message: expect.stringContaining('http://products:4000/graphql failed'), + stackTrace: expect.stringContaining('http://products:4000/graphql failed'), }, ], }) @@ -1124,7 +1119,7 @@ describe('Request handling with Apollo Federation', () => { it('when request ingestion fail, send operation source again', async () => { let payload = [] - const pandas = nock('http://pandas:4000').post('/graphql').twice().replyWithError({ code: 'ENETUNREACH' }) + const pandas = nock('http://pandas:4000').post('/graphql').twice().replyWithError('ENETUNREACH') logql .post('/errors', (res) => { @@ -1148,7 +1143,7 @@ describe('Request handling with Apollo Federation', () => { expect(res1.body.errors).toEqual([ { extensions: { code: 'INTERNAL_SERVER_ERROR' }, - message: 'request to http://pandas:4000/graphql failed, reason: undefined', + message: expect.stringContaining('http://pandas:4000/graphql failed'), }, ]) @@ -1168,7 +1163,7 @@ describe('Request handling with Apollo Federation', () => { expect(res2.body.errors).toEqual([ { extensions: { code: 'INTERNAL_SERVER_ERROR' }, - message: 'request to http://pandas:4000/graphql failed, reason: undefined', + message: expect.stringContaining('http://pandas:4000/graphql failed'), }, ]) @@ -1203,7 +1198,7 @@ describe('Request handling with Apollo Federation', () => { it('when request ingestion works, do not send operation source again', async () => { let payload = [] - const pandas = nock('http://pandas:4000').post('/graphql').twice().replyWithError({ code: 'ENETUNREACH' }) + const pandas = nock('http://pandas:4000').post('/graphql').twice().replyWithError('ENETUNREACH') logql .post('/errors', (res) => { @@ -1227,7 +1222,7 @@ describe('Request handling with Apollo Federation', () => { expect(res1.body.errors).toEqual([ { extensions: { code: 'INTERNAL_SERVER_ERROR' }, - message: 'request to http://pandas:4000/graphql failed, reason: undefined', + message: expect.stringContaining('http://pandas:4000/graphql failed'), }, ]) @@ -1247,7 +1242,7 @@ describe('Request handling with Apollo Federation', () => { expect(res2.body.errors).toEqual([ { extensions: { code: 'INTERNAL_SERVER_ERROR' }, - message: 'request to http://pandas:4000/graphql failed, reason: undefined', + message: expect.stringContaining('http://pandas:4000/graphql failed'), }, ]) diff --git a/yarn.lock b/yarn.lock index ef3bb19..d3a4cf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -865,6 +865,18 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@mswjs/interceptors@^0.41.0": + version "0.41.3" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.3.tgz#d766dc1a168aa315a6a0b2d0f2e0cf1b74f23c82" + integrity sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA== + dependencies: + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/logger" "^0.3.0" + "@open-draft/until" "^2.0.0" + is-node-process "^1.2.0" + outvariant "^1.4.3" + strict-event-emitter "^0.5.1" + "@noble/hashes@^1.1.5": version "1.8.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" @@ -898,6 +910,24 @@ dependencies: semver "^7.3.5" +"@open-draft/deferred-promise@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" + integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== + +"@open-draft/logger@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@open-draft/logger/-/logger-0.3.0.tgz#2b3ab1242b360aa0adb28b85f5d7da1c133a0954" + integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== + dependencies: + is-node-process "^1.2.0" + outvariant "^1.4.0" + +"@open-draft/until@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" + integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== + "@opentelemetry/api@^1.0.1": version "1.8.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.8.0.tgz#5aa7abb48f23f693068ed2999ae627d2f7d902ec" @@ -2759,6 +2789,11 @@ is-map@^2.0.2, is-map@^2.0.3: resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== +is-node-process@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" + integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== + is-number-object@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" @@ -3624,12 +3659,12 @@ negotiator@0.6.3, negotiator@^0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -nock@^13.2.9: - version "13.5.4" - resolved "https://registry.yarnpkg.com/nock/-/nock-13.5.4.tgz#8918f0addc70a63736170fef7106a9721e0dc479" - integrity sha512-yAyTfdeNJGGBFxWdzSKCBYxs5FxLbCg5X5Q4ets974hcQzG1+qCxvIyOo4j2Ry6MUlhWVMX4OoYDefAIIwupjw== +nock@^14.0.0: + version "14.0.11" + resolved "https://registry.yarnpkg.com/nock/-/nock-14.0.11.tgz#4ed20d50433b0479ddabd9f03c5886054608aeae" + integrity sha512-u5xUnYE+UOOBA6SpELJheMCtj2Laqx15Vl70QxKo43Wz/6nMHXS7PrEioXLjXAwhmawdEMNImwKCcPhBJWbKVw== dependencies: - debug "^4.1.0" + "@mswjs/interceptors" "^0.41.0" json-stringify-safe "^5.0.1" propagate "^2.0.0" @@ -3738,6 +3773,11 @@ optionator@^0.9.3: prelude-ls "^1.2.1" type-check "^0.4.0" +outvariant@^1.4.0, outvariant@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" + integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -4280,6 +4320,11 @@ stop-iteration-iterator@^1.0.0: dependencies: internal-slot "^1.0.4" +strict-event-emitter@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" + integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"