diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c053540..cdceb918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ CHANGELOG ========= -7.0.0 +7.0.0 (unreleased) ------------------ * **Breaking** Dropped support for Node.js 18 and 20. Node.js 22 or greater is now @@ -14,6 +14,20 @@ CHANGELOG property (for example, the network error behind a `FETCH_ERROR`). The `WebServiceError` class and the `WebServiceClientError` type are now exported from the package. +* The `code` property on `WebServiceError` and the `WebServiceClientError` + interface is now typed as `WebServiceErrorCode` + (`ClientErrorCode | (string & {})`) instead of `string`, providing + autocompletion for the client-generated codes while still accepting any + code returned by the web service. The `ClientErrorCode` and + `WebServiceErrorCode` types are exported from the package. +* The `AddressNotFoundError`, `BadMethodCallError`, `InvalidDbBufferError`, + and `ValueError` classes now accept an optional `cause` and forward it to + `Error`. `Reader.openBuffer()` now preserves the underlying parsing error as + the `cause` of the `InvalidDbBufferError` it throws. +* Added a `fetcher` option to the `WebServiceClient` options, allowing a + custom `fetch` implementation to be supplied (for example, to route requests + through a custom dispatcher or proxy, or for testing). It defaults to the + global `fetch`. 6.3.4 (2025-11-25) ------------------ diff --git a/README.md b/README.md index 7578821c..d834ecdd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ your MaxMind `accountID` and `licenseKey` as parameters. The third argument is an object holding additional option. The `timeout` option defaults to `3000`. The `host` option defaults to `geoip.maxmind.com`. Set `host` to `geolite.info` to use the GeoLite web service instead of GeoIP. Set `host` to -`sandbox.maxmind.com` to use the Sandbox environment. +`sandbox.maxmind.com` to use the Sandbox environment. The `fetcher` option lets +you supply a custom `fetch` implementation (for example, to route requests +through a proxy or custom dispatcher); it defaults to the global `fetch`. You may then call the function corresponding to a specific end point, passing it the IP address you want to lookup. diff --git a/package-lock.json b/package-lock.json index 193b240a..672cab69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "eslint-config-prettier": "^10.0.1", "globals": "^17.0.0", "jest": "^30.0.0", - "nock": "^14.0.0-beta.15", "prettier": "^3.0.0", "ts-jest": "^29.4.0", "typedoc": "^0.28.1", @@ -1309,24 +1308,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -1346,31 +1327,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3598,13 +3554,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4405,13 +4354,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4718,21 +4660,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nock": { - "version": "14.0.15", - "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.15.tgz", - "integrity": "sha512-S0a47C9pLvcYx/Ugf0H30BVBEcUgMMBDk9VJIDlJ8XGrfH2QDUD4Tgdp45qDIiHttokBG+IbsOtsvIjGR/j3bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mswjs/interceptors": "^0.41.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">=18.20.0 <20 || >=20.12.1" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4814,13 +4741,6 @@ "node": ">= 0.8.0" } }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5097,16 +5017,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5300,13 +5210,6 @@ "node": ">=8" } }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index 22c6fefd..01730e89 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "eslint-config-prettier": "^10.0.1", "globals": "^17.0.0", "jest": "^30.0.0", - "nock": "^14.0.0-beta.15", "prettier": "^3.0.0", "ts-jest": "^29.4.0", "typedoc": "^0.28.1", diff --git a/src/errors.spec.ts b/src/errors.spec.ts index 183dc97d..c8d432f8 100644 --- a/src/errors.spec.ts +++ b/src/errors.spec.ts @@ -1,4 +1,39 @@ -import { WebServiceError } from './errors.js'; +import { + AddressNotFoundError, + BadMethodCallError, + InvalidDbBufferError, + ValueError, + WebServiceError, +} from './errors.js'; + +describe.each([ + ['AddressNotFoundError', AddressNotFoundError], + ['BadMethodCallError', BadMethodCallError], + ['InvalidDbBufferError', InvalidDbBufferError], + ['ValueError', ValueError], +] as const)('%s', (name, ErrorClass) => { + it('uses the message and sets the name', () => { + const err = new ErrorClass('boom'); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ErrorClass); + expect(err.message).toBe('boom'); + expect(err.name).toBe(name); + }); + + it('preserves the underlying cause when provided', () => { + const cause = new TypeError('underlying'); + const err = new ErrorClass('boom', { cause }); + + expect(err.cause).toBe(cause); + }); + + it('leaves cause undefined when not provided', () => { + const err = new ErrorClass('boom'); + + expect(err.cause).toBeUndefined(); + }); +}); describe('WebServiceError', () => { it('is an Error instance', () => { diff --git a/src/errors.ts b/src/errors.ts index 5dd50b39..c5dcdb3f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import { WebServiceClientError } from './types.js'; +import { WebServiceClientError, WebServiceErrorCode } from './types.js'; /* tslint:disable:max-classes-per-file */ @@ -15,7 +15,7 @@ export class WebServiceError extends Error implements WebServiceClientError { /** * The error code returned by the web service or generated by this client. */ - public readonly code: string; + public readonly code: WebServiceErrorCode; /** * A human-readable description of the error. This is an alias of `message`, * retained for backward compatibility. @@ -36,7 +36,7 @@ export class WebServiceError extends Error implements WebServiceClientError { constructor( properties: { - code: string; + code: WebServiceErrorCode; error: string; status?: number; url: string; @@ -66,8 +66,8 @@ WebServiceError.prototype.name = 'WebServiceError'; * This generally means that the address was a private or reserved address. */ export class AddressNotFoundError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } @@ -77,8 +77,8 @@ export class AddressNotFoundError extends Error { * e.g. `reader.city` is used with a Country database */ export class BadMethodCallError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } @@ -87,8 +87,8 @@ export class BadMethodCallError extends Error { * This error is thrown if a database buffer is not a valid database */ export class InvalidDbBufferError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } @@ -97,8 +97,8 @@ export class InvalidDbBufferError extends Error { * This error is thrown if the IP address provided is not valid. */ export class ValueError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } diff --git a/src/index.ts b/src/index.ts index 174d1196..dd0ecbe0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,9 @@ export { Reader, ReaderModel, WebServiceClient }; export * from './records.js'; export * from './models/index.js'; export * from './errors.js'; -export type { WebServiceClientError } from './types.js'; +export type { + ClientErrorCode, + WebServiceClientError, + WebServiceErrorCode, +} from './types.js'; +export type { WebServiceClientOptions } from './webServiceClient.js'; diff --git a/src/reader.spec.ts b/src/reader.spec.ts index b5eeaddb..2a59e4c2 100644 --- a/src/reader.spec.ts +++ b/src/reader.spec.ts @@ -47,5 +47,18 @@ describe('Reader', () => { InvalidDbBufferError ); }); + + it('preserves the underlying error as the cause', () => { + expect.assertions(3); + + try { + Reader.openBuffer(Buffer.from('foo')); + } catch (e) { + expect(e).toBeInstanceOf(InvalidDbBufferError); + const err = e as InvalidDbBufferError; + expect(typeof err.message).toBe('string'); + expect(err.cause).toBeInstanceOf(Error); + } + }); }); }); diff --git a/src/reader.ts b/src/reader.ts index 997a11ba..8c68691e 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -34,9 +34,9 @@ export default class Reader { let reader; try { reader = new mmdb.Reader(buffer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - throw new InvalidDbBufferError(e); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + throw new InvalidDbBufferError(error.message, { cause: error }); } return new ReaderModel(reader); diff --git a/src/readerModel.spec.ts b/src/readerModel.spec.ts index a6fe58cf..4277b12f 100644 --- a/src/readerModel.spec.ts +++ b/src/readerModel.spec.ts @@ -3,6 +3,7 @@ import { BadMethodCallError, ValueError, } from './errors.js'; +import * as models from './models/index.js'; import Reader from './reader.js'; describe('ReaderModel', () => { @@ -600,7 +601,8 @@ describe('ReaderModel', () => { './test/data/test-data/GeoIP2-Enterprise-Test.mmdb' ); - const model = reader.enterprise('2.125.160.216'); + // enterprise() should be typed as Enterprise, not the wider City. + const model: models.Enterprise = reader.enterprise('2.125.160.216'); const expected = { city: { diff --git a/src/readerModel.ts b/src/readerModel.ts index 134f3519..287faf3d 100644 --- a/src/readerModel.ts +++ b/src/readerModel.ts @@ -150,7 +150,7 @@ export default class ReaderModel { * @throws {AddressNotFoundError} Throws an error when the IP address isn't found in the database * @throws {ValueError} Throws an error when the IP address isn't valid */ - public enterprise(ipAddress: string): models.City { + public enterprise(ipAddress: string): models.Enterprise { return this.modelFor( models.Enterprise, 'Enterprise', diff --git a/src/records.ts b/src/records.ts index 28f6e084..8cfdeadb 100644 --- a/src/records.ts +++ b/src/records.ts @@ -1,9 +1,8 @@ import { ConnectionType } from './types.js'; /** - * The name of the place based on the locales list passed to the - * `WebServiceClient` constructor. Don't use any of these names as a database or - * dictionary key. Use the ID or relevant code instead. + * The name of the place in each available locale. Don't use any of these names + * as a database or dictionary key. Use the ID or relevant code instead. */ export interface Names { readonly de?: string; diff --git a/src/types.ts b/src/types.ts index e28740cc..8d0d6524 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,10 +9,44 @@ export interface CityResponse extends mmdb.CityResponse { maxmind?: MaxMindRecord; } +/** + * The error codes that this client generates itself, as opposed to those + * returned by the web service. + */ +export type ClientErrorCode = + | 'FETCH_ERROR' + | 'HTTP_STATUS_CODE_ERROR' + | 'INVALID_RESPONSE_BODY' + | 'IP_ADDRESS_INVALID' + | 'NETWORK_TIMEOUT' + | 'SERVER_ERROR'; + +/** + * The `code` exposed on a {@link WebServiceError}. This is one of the + * client-generated {@link ClientErrorCode} values or any other string returned + * by the web service. The `& {}` keeps the union open to arbitrary strings + * while still offering autocompletion for the known client codes. + */ +export type WebServiceErrorCode = ClientErrorCode | (string & {}); + export interface WebServiceClientError { - code: string; + /** + * The error code returned by the web service or generated by this client. + */ + code: WebServiceErrorCode; + /** + * A human-readable description of the error. This is an alias of the standard + * `Error` `message`. + */ error: string; + /** + * The HTTP status code, when the error originated from an HTTP response. + * Absent for network-level errors. + */ status?: number; + /** + * The URL that was being requested when the error occurred. + */ url: string; /** * The underlying error that caused this one, when available (for example, diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 9f19f04a..c9334d6a 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -119,6 +119,14 @@ describe('src/Utils', () => { }); }); describe('camelcaseKeys()', () => { + it('returns non-object input unchanged', () => { + expect(camelcaseKeys('foo')).toBe('foo'); + expect(camelcaseKeys(42)).toBe(42); + expect(camelcaseKeys(true)).toBe(true); + expect(camelcaseKeys(null)).toBeNull(); + expect(camelcaseKeys(undefined)).toBeUndefined(); + }); + it("converts an object's keys from snake_case to camelCase", () => { const cases = [ { input: { snake_case: 1 }, expected: { snakeCase: 1 } }, diff --git a/src/utils.ts b/src/utils.ts index bc0527a7..9fe370f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -194,16 +194,18 @@ const processArray = (arr: Array): unknown[] => * @param input - object with some snake_case keys * @returns - object with camelCase keys */ -export function camelcaseKeys( - input: Record | unknown[] -): Record | unknown[] { +export function camelcaseKeys(input: unknown): unknown { if (Array.isArray(input)) { return processArray(input); } + // Leave primitives (and null/undefined) untouched. + if (!isObject(input)) { + return input; + } const output: Record = {}; - for (const [key, value] of Object.entries(input)) { + for (const [key, value] of Object.entries(input as Record)) { if (Array.isArray(value)) { output[snakeToCamelCase(key)] = processArray(value); } else if (isObject(value)) { diff --git a/src/webServiceClient.spec.ts b/src/webServiceClient.spec.ts index c7d4369c..f3393c04 100644 --- a/src/webServiceClient.spec.ts +++ b/src/webServiceClient.spec.ts @@ -1,11 +1,9 @@ -import nock from 'nock'; import geoip2Fixture from '../fixtures/geoip2.json' with { type: 'json' }; import { WebServiceError } from './errors.js'; import Client from './webServiceClient.js'; import * as models from './models/index.js'; const baseUrl = 'https://geoip.maxmind.com'; -const nockInstance = nock(baseUrl); const fullPath = (path: string, ipAddress: string) => `/geoip/v2.1/${path}/${ipAddress}`; const auth = { @@ -13,6 +11,35 @@ const auth = { user: '123', }; +interface CapturedRequest { + init?: RequestInit; + url: RequestInfo | URL; +} + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + status, + }); + +// Builds a client backed by an injected fetcher driven by `handler`, and +// captures the requests the client makes so they can be asserted on. This +// replaces HTTP-level mocking: the handler returns the `Response` (or rejects) +// for each request. +const clientWith = ( + handler: (request: CapturedRequest) => Response | Promise, + options: { host?: string; timeout?: number } = {} +) => { + const requests: CapturedRequest[] = []; + const fetcher = (async (url: RequestInfo | URL, init?: RequestInit) => { + const request = { init, url }; + requests.push(request); + return handler(request); + }) as typeof fetch; + const client = new Client(auth.user, auth.pass, { fetcher, ...options }); + return { client, requests }; +}; + const expectError = async ( promise: Promise, expected: { @@ -49,12 +76,26 @@ const expectError = async ( }; describe('WebServiceClient', () => { - afterEach(() => { - nock.cleanAll(); - nock.abortPendingRequests(); - }); + describe('request', () => { + it('uses the injected fetcher with the correct method, path, and auth', async () => { + const ip = '8.8.8.8'; + const { client, requests } = clientWith(() => + jsonResponse(200, geoip2Fixture) + ); - const client = new Client(auth.user, auth.pass); + const got = await client.city(ip); + + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('city', ip)}`); + expect(requests[0].init!.method).toBe('GET'); + const headers = requests[0].init!.headers as Record; + expect(headers.Authorization).toBe( + 'Basic ' + btoa(`${auth.user}:${auth.pass}`) + ); + expect(headers['User-Agent']).toMatch(/^GeoIP2-node\//); + expect(got.country!.isoCode).toEqual('US'); + }); + }); describe('city()', () => { const testFixture = { @@ -72,15 +113,16 @@ describe('WebServiceClient', () => { it('returns a city class', async () => { const ip = '8.8.8.8'; - expect.assertions(96); + expect.assertions(97); - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(200, testFixture); + const { client, requests } = clientWith(() => + jsonResponse(200, testFixture) + ); const got: models.City = await client.city(ip); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('city', ip)}`); + expect(got.city!.confidence).toEqual(25); expect(got.city!.geonameId).toEqual(54321); expect(got.city!.names.de).toEqual('Los Angeles'); @@ -203,15 +245,16 @@ describe('WebServiceClient', () => { it('returns a country class', async () => { const ip = '8.8.8.8'; - expect.assertions(64); + expect.assertions(65); - nockInstance - .get(fullPath('country', ip)) - .basicAuth(auth) - .reply(200, testFixture); + const { client, requests } = clientWith(() => + jsonResponse(200, testFixture) + ); const got: models.Country = await client.country(ip); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('country', ip)}`); + expect(got.continent!.code).toEqual('NA'); expect(got.continent!.geonameId).toEqual(123456); expect(got.continent!.names.de).toEqual('Nordamerika'); @@ -303,15 +346,16 @@ describe('WebServiceClient', () => { it('returns an insight class', async () => { const ip = '8.8.8.8'; - expect.assertions(106); + expect.assertions(107); - nockInstance - .get(fullPath('insights', ip)) - .basicAuth(auth) - .reply(200, testFixture); + const { client, requests } = clientWith(() => + jsonResponse(200, testFixture) + ); const got: models.Insights = await client.insights(ip); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('insights', ip)}`); + expect(got.city!.confidence).toEqual(25); expect(got.city!.geonameId).toEqual(54321); expect(got.city!.names.de).toEqual('Los Angeles'); @@ -437,20 +481,25 @@ describe('WebServiceClient', () => { it('should time out if the request takes too long', async () => { const ip = '8.8.8.8'; - const client = new Client(auth.user, auth.pass, { - timeout: 10, - }); - - nock('https://geoip.maxmind.com') - .get(`/geoip/v2.1/city/${ip}`) - .delay(200) // Delay the response to trigger the timeout - .reply(200, geoip2Fixture); + // The handler never resolves on its own; it rejects only when the + // request signal aborts, which the client's timeout triggers. This + // exercises the real timeout signal and the NETWORK_TIMEOUT mapping + // without depending on wall-clock response delays. + const { client } = clientWith( + (request) => + new Promise((_resolve, reject) => { + request.init?.signal?.addEventListener('abort', () => + reject((request.init!.signal as AbortSignal).reason) + ); + }), + { timeout: 10 } + ); // The underlying abort/timeout error is preserved as the cause. await expectError(client.city(ip), { code: 'NETWORK_TIMEOUT', error: 'The request timed out', - url: `https://geoip.maxmind.com/geoip/v2.1/city/${ip}`, + url: `${baseUrl}${fullPath('city', ip)}`, cause: 'defined', }); }); @@ -459,6 +508,9 @@ describe('WebServiceClient', () => { describe('error handling', () => { it('rejects if the IP address is invalid', async () => { const ip = 'foo'; + const { client, requests } = clientWith(() => + jsonResponse(200, geoip2Fixture) + ); await expectError(client.city(ip), { code: 'IP_ADDRESS_INVALID', @@ -466,12 +518,13 @@ describe('WebServiceClient', () => { url: baseUrl + fullPath('city', ip), cause: 'undefined', }); + // The request is rejected before any fetch is attempted. + expect(requests).toHaveLength(0); }); it('handles 5xx level errors', async () => { const ip = '8.8.8.8'; - - nockInstance.get(fullPath('city', ip)).basicAuth(auth).reply(500); + const { client } = clientWith(() => new Response(null, { status: 500 })); await expectError(client.city(ip), { code: 'SERVER_ERROR', @@ -484,8 +537,7 @@ describe('WebServiceClient', () => { it('handles 3xx level errors', async () => { const ip = '8.8.8.8'; - - nockInstance.get(fullPath('city', ip)).basicAuth(auth).reply(300); + const { client } = clientWith(() => new Response(null, { status: 300 })); await expectError(client.city(ip), { code: 'HTTP_STATUS_CODE_ERROR', @@ -498,11 +550,7 @@ describe('WebServiceClient', () => { it('handles errors with unknown payload', async () => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(401, { foo: 'bar' }); + const { client } = clientWith(() => jsonResponse(401, { foo: 'bar' })); await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -522,11 +570,7 @@ describe('WebServiceClient', () => { 'treats $description as an invalid response body', async ({ payload }) => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(400, payload); + const { client } = clientWith(() => jsonResponse(400, payload)); await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -540,8 +584,7 @@ describe('WebServiceClient', () => { it('handles 200s with bad json', async () => { const ip = '8.8.8.8'; - - nockInstance.get(fullPath('city', ip)).basicAuth(auth).reply(200, 'foo'); + const { client } = clientWith(() => new Response('foo', { status: 200 })); const err = await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -557,11 +600,9 @@ describe('WebServiceClient', () => { it('preserves the cause when an error response body is not JSON', async () => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(401, 'this is not json'); + const { client } = clientWith( + () => new Response('this is not json', { status: 401 }) + ); const err = await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -577,11 +618,7 @@ describe('WebServiceClient', () => { it('handles general network errors', async () => { const ip = '8.8.8.8'; const error = 'Network Error'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .replyWithError(error); + const { client } = clientWith(() => Promise.reject(new Error(error))); const err = await expectError(client.city(ip), { code: 'FETCH_ERROR', @@ -593,16 +630,27 @@ describe('WebServiceClient', () => { expect((err.cause as Error).message).toBe(error); }); + it('wraps a non-Error fetcher rejection', async () => { + const ip = '8.8.8.8'; + // A custom fetcher (e.g. a proxy/dispatcher) may reject with a non-Error + // value; it should still be normalized into a FETCH_ERROR. + const { client } = clientWith(() => Promise.reject('boom')); + + const err = await expectError(client.city(ip), { + code: 'FETCH_ERROR', + error: 'Error - boom', + url: baseUrl + fullPath('city', ip), + cause: 'defined', + }); + expect((err.cause as Error).message).toBe('boom'); + }); + it('includes the underlying cause in the FETCH_ERROR message', async () => { const ip = '8.8.8.8'; const fetchError = Object.assign(new TypeError('fetch failed'), { cause: new Error('connect ECONNREFUSED 1.2.3.4:443'), }); - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .replyWithError(fetchError); + const { client } = clientWith(() => Promise.reject(fetchError)); const err = await expectError(client.city(ip), { code: 'FETCH_ERROR', @@ -627,11 +675,9 @@ describe('WebServiceClient', () => { ${403} | ${'PERMISSION_REQUIRED'} | ${'permission required'} `('handles $code error', async ({ code, error, status }) => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(status, { code, error }); + const { client } = clientWith(() => + jsonResponse(status, { code, error }) + ); await expectError(client.city(ip), { code, @@ -681,6 +727,25 @@ describe('WebServiceClient with empty options', () => { }); }); +describe('WebServiceClient with null options', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let client: any; + + beforeAll(() => { + // A JS caller may pass an explicit null; typeof null === 'object', so the + // constructor must not treat it as an options object. + client = new Client(auth.user, auth.pass, null as unknown as undefined); + }); + + it('sets host', () => { + expect(client.host).toEqual('geoip.maxmind.com'); + }); + + it('sets timeout', () => { + expect(client.timeout).toEqual(3000); + }); +}); + describe('WebServiceClient with timeout', () => { it('sets timeout', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index 139f3e5f..efec80d6 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -2,9 +2,14 @@ import * as mmdb from 'maxmind'; import packageInfo from '../package.json' with { type: 'json' }; import { WebServiceError } from './errors.js'; import * as models from './models/index.js'; +import { ClientErrorCode } from './types.js'; -/** Option for the WebServiceClient constructor */ -interface Options { +/** Options for the WebServiceClient constructor */ +export interface WebServiceClientOptions { + /** A custom `fetch` implementation to use for requests. Defaults to the + * global `fetch`. This is primarily useful for testing or for routing + * requests through a custom dispatcher or proxy. */ + fetcher?: typeof fetch; /** The host to use when connecting to the web service. The default is * "geoip.maxmind.com". To call the GeoLite web service instead of the * GeoIP web service, set this to "geolite.info". To call the Sandbox @@ -21,7 +26,22 @@ type servicePath = 'city' | 'country' | 'insights'; const invalidResponseBody = { code: 'INVALID_RESPONSE_BODY', error: 'Received an invalid or unparseable response body', -}; +} satisfies { code: ClientErrorCode; error: string }; + +// Builds a WebServiceError for a client-generated failure. Typing `code` as the +// closed `ClientErrorCode` (rather than the open `WebServiceErrorCode` the +// WebServiceError constructor accepts) makes a typo at a throw site a compile +// error and keeps the `ClientErrorCode` union in sync with what the client +// actually emits. +const clientError = ( + properties: { + code: ClientErrorCode; + error: string; + status?: number; + url: string; + }, + options?: { cause?: unknown } +): WebServiceError => new WebServiceError(properties, options); const isErrorBody = (data: unknown): data is { code: string; error: string } => typeof data === 'object' && @@ -37,6 +57,7 @@ export default class WebServiceClient { private licenseKey: string; private timeout = 3000; private host = 'geoip.maxmind.com'; + private fetcher: typeof fetch = fetch; /** * Instantiates a WebServiceClient @@ -54,15 +75,21 @@ export default class WebServiceClient { licenseKey: string, // We support a number, which will be treated as the timeout for historical // reasons. - options?: Options | number + options?: WebServiceClientOptions | number ) { this.accountID = accountID; this.licenseKey = licenseKey; - if (options === undefined) { + // `typeof null === 'object'`, so guard null alongside undefined to avoid + // dereferencing it in the options branch below. + if (options === undefined || options === null) { return; } if (typeof options === 'object') { + if (options.fetcher !== undefined) { + this.fetcher = options.fetcher; + } + if (options.host !== undefined) { this.host = options.host; } @@ -125,7 +152,7 @@ export default class WebServiceClient { const url = `https://${this.host}${parsedPath}`; if (!mmdb.validate(ipAddress)) { - throw new WebServiceError({ + throw clientError({ code: 'IP_ADDRESS_INVALID', error: 'The IP address provided is invalid', url, @@ -144,14 +171,14 @@ export default class WebServiceClient { let response; try { - response = await fetch(url, options); + response = await this.fetcher(url, options); } catch (err) { const error = err instanceof Error || err instanceof DOMException ? err : new Error(String(err)); if (error.name === 'TimeoutError') { - throw new WebServiceError( + throw clientError( { code: 'NETWORK_TIMEOUT', error: 'The request timed out', @@ -165,7 +192,7 @@ export default class WebServiceClient { // only log `code`/`error`, not just available via `cause`. const causeDetail = error.cause instanceof Error ? `: ${error.cause.message}` : ''; - throw new WebServiceError( + throw clientError( { code: 'FETCH_ERROR', error: `${error.name} - ${error.message}${causeDetail}`, @@ -183,10 +210,7 @@ export default class WebServiceClient { try { data = await response.json(); } catch (err) { - throw new WebServiceError( - { ...invalidResponseBody, url }, - { cause: err } - ); + throw clientError({ ...invalidResponseBody, url }, { cause: err }); } return new modelClass(data); @@ -199,7 +223,7 @@ export default class WebServiceClient { const status = response.status; if (status && status >= 500 && status < 600) { - return new WebServiceError({ + return clientError({ code: 'SERVER_ERROR', error: `Received a server error with HTTP status code: ${status}`, status, @@ -208,7 +232,7 @@ export default class WebServiceClient { } if (status && (status < 400 || status >= 600)) { - return new WebServiceError({ + return clientError({ code: 'HTTP_STATUS_CODE_ERROR', error: `Received an unexpected HTTP status code: ${status}`, status, @@ -220,14 +244,14 @@ export default class WebServiceClient { try { data = await response.json(); } catch (err) { - return new WebServiceError( + return clientError( { ...invalidResponseBody, status, url }, { cause: err } ); } if (!isErrorBody(data)) { - return new WebServiceError({ ...invalidResponseBody, status, url }); + return clientError({ ...invalidResponseBody, status, url }); } return new WebServiceError({