diff --git a/.changeset/sdk-error-for-invalid-arguments.md b/.changeset/sdk-error-for-invalid-arguments.md new file mode 100644 index 00000000..75d1a777 --- /dev/null +++ b/.changeset/sdk-error-for-invalid-arguments.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/node-sdk': patch +--- + +Throw `SdkError` instead of `TypeError` or `Error` for invalid API client arguments. \ No newline at end of file diff --git a/readme.md b/readme.md index af7f5ef0..611709d9 100644 --- a/readme.md +++ b/readme.md @@ -138,11 +138,11 @@ try { } ``` -Other errors, all subclasses of `SdkError`: +Other `SdkError`s: - `TooManyRequestsError` — a `ServerApiError` thrown when the request is throttled (HTTP 429). - `RequestError` — the base class of `ServerApiError`, thrown when the response doesn't match the Server API error shape (e.g. a proxy error). Its `errorCode` is a free-form `string` placeholder from `statusText`. Since `ServerApiError` extends it, `error instanceof RequestError` catches both. -- `SdkError` — the base of all SDK errors; also thrown for network failures and malformed responses. +- `SdkError` — the base of Server API client errors. Thrown directly for invalid arguments, network failures, and malformed responses. Unseal failures are `UnsealAggregateError` or `Error`, not `SdkError`. ### Webhooks diff --git a/src/errors/apiErrors.ts b/src/errors/apiErrors.ts index fa6c503e..bbc15a62 100644 --- a/src/errors/apiErrors.ts +++ b/src/errors/apiErrors.ts @@ -1,7 +1,11 @@ import { ErrorCode, ErrorResponse } from '../types' /** - * Base class for all errors thrown by the SDK. + * Base class for {@link FingerprintServerApiClient} errors. + * + * Thrown directly for invalid arguments, network failures, and malformed + * successful responses. HTTP error responses use {@link RequestError} and its + * subclasses ({@link ServerApiError}, {@link TooManyRequestsError}). */ export class SdkError extends Error { constructor( @@ -77,7 +81,10 @@ export class ServerApiError extends RequestError { constructor(body: ErrorResponse, response: Response) { diff --git a/src/errors/unsealError.ts b/src/errors/unsealError.ts index 6f38bbd6..c631ed8f 100644 --- a/src/errors/unsealError.ts +++ b/src/errors/unsealError.ts @@ -1,5 +1,9 @@ import { DecryptionKey } from '../sealedResults' +/** + * Decryption failure for one key. Collected on {@link UnsealAggregateError}; + * not thrown from {@link unsealEventsResponse}. + */ export class UnsealError extends Error { constructor( readonly key: DecryptionKey, @@ -11,11 +15,15 @@ export class UnsealError extends Error { msg = msg.concat(`: ${error.message}`) } - super(msg) + super(msg, { cause: error }) this.name = 'UnsealError' } } +/** + * Every decryption key failed. Inspect {@link errors} for per-key + * {@link UnsealError} details. + */ export class UnsealAggregateError extends Error { constructor(readonly errors: UnsealError[]) { super('Unable to decrypt sealed data') diff --git a/src/sealedResults.ts b/src/sealedResults.ts index 2dbb4eaa..aea2d0b2 100644 --- a/src/sealedResults.ts +++ b/src/sealedResults.ts @@ -40,8 +40,8 @@ export function parseEventsResponse(unsealed: string): Event { * Decrypts the sealed response with the provided keys. * The SDK will try to decrypt the result with each key until it succeeds. * To learn more about sealed results visit: https://dev.fingerprint.com/docs/sealed-client-results - * @throws UnsealAggregateError - * @throws Error + * @throws {UnsealAggregateError} When every decryption key fails. + * @throws {Error} Invalid sealed header, unsupported algorithm, or decrypted JSON is not an event. */ export async function unsealEventsResponse(sealedData: Buffer, decryptionKeys: DecryptionKey[]): Promise { const unsealed = await unseal(sealedData, decryptionKeys) diff --git a/src/serverApiClient.ts b/src/serverApiClient.ts index 46c00a7f..e6fb0c92 100644 --- a/src/serverApiClient.ts +++ b/src/serverApiClient.ts @@ -34,8 +34,10 @@ export class FingerprintServerApiClient implements FingerprintApi { * @param {Options} options - Options for FingerprintJS server API client */ constructor(options: Readonly) { - if (!options.apiKey) { - throw Error('Api key is not set') + // Runtime guard for untyped callers even though TypeScript requires options. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime validation + if (!options?.apiKey) { + throw new SdkError('Api key is not set') } this.region = options.region ?? Region.Global @@ -91,7 +93,7 @@ export class FingerprintServerApiClient implements FingerprintApi { * */ public async getEvent(eventId: string, options?: GetEventOptions): Promise { if (!eventId) { - throw new TypeError('eventId is not set') + throw new SdkError('eventId is not set') } return this.callApi({ @@ -145,11 +147,11 @@ export class FingerprintServerApiClient implements FingerprintApi { // Runtime guard for untyped callers even though TypeScript treats body as required. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions -- runtime validation if (!body) { - throw new TypeError('body is not set') + throw new SdkError('body is not set') } if (!eventId) { - throw new TypeError('eventId is not set') + throw new SdkError('eventId is not set') } return this.callApi({ @@ -194,7 +196,7 @@ export class FingerprintServerApiClient implements FingerprintApi { */ public async deleteVisitorData(visitorId: string): Promise { if (!visitorId) { - throw new TypeError('visitorId is not set') + throw new SdkError('visitorId is not set') } return this.callApi({ diff --git a/src/urlUtils.ts b/src/urlUtils.ts index bad0810a..505a9f21 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -1,6 +1,8 @@ import { Region } from './types' import { version } from '../package.json' import { paths } from './generatedApiTypes' +import { SdkError } from './errors/apiErrors' +import { toError } from './errors/toError' const apiVersion = 'v4' @@ -43,6 +45,13 @@ function serializeQueryStringParams(params: QueryStringParameters): string { return urlSearchParams.toString() } +/** + * `event_id` in the URL template is the `eventId` argument on the client method. + */ +function argumentName(placeholder: string): string { + return placeholder.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) +} + /** * Confines a value to a single URL path segment. `.` is deliberately left unencoded because * the Server API does not decode path parameters, and valid parameter values can contain dots. @@ -51,10 +60,11 @@ function serializeQueryStringParams(params: QueryStringParameters): string { * encoded, so it cannot be expressed. See https://url.spec.whatwg.org/#double-dot-path-segment */ function encodePathParam(placeholder: string, value: unknown): string { + const name = argumentName(placeholder) // Coerce before comparing, because an untyped caller can pass something that is not a string // but stringifies to one. Both conversions throw on values only such a caller could pass: // `String` when the value has no primitive representation, `encodeURIComponent` on a lone - // surrogate. Neither should escape as its own error type. + // surrogate. Wrap either as `SdkError` so it does not escape as its own error type. let param: string let encoded: string try { @@ -62,15 +72,15 @@ function encodePathParam(placeholder: string, value: unknown): string { param = String(value ?? '') encoded = encodeURIComponent(param) } catch (cause) { - throw new TypeError(`Invalid path parameter for ${placeholder}`, { cause }) + throw new SdkError(`${name} is not valid`, undefined, toError(cause)) } if (param === '') { - throw new TypeError(`Missing path parameter for ${placeholder}`) + throw new SdkError(`${name} is not set`) } if (param === '.' || param === '..') { - throw new TypeError(`Invalid path parameter for ${placeholder}: ${param}`) + throw new SdkError(`${name} is not valid: ${param}`) } return encoded @@ -85,7 +95,7 @@ function getServerApiUrl(region: Region): string { case Region.Global: return globalRegionUrl default: - throw new Error('Unsupported region') + throw new SdkError('Unsupported region') } } @@ -142,7 +152,7 @@ export function getRequestPath({ url.search = serializeQueryStringParams(queryStringParameters) if (url.pathname !== `/${formattedPath}`) { - throw new TypeError('Invalid path: path changed during normalization') + throw new SdkError('Invalid path: path changed during normalization') } return url.toString() diff --git a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts index 25508ad4..9e6f5eef 100644 --- a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts +++ b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts @@ -1,4 +1,4 @@ -import { FingerprintServerApiClient, Region } from '../../src' +import { FingerprintServerApiClient, Region, SdkError } from '../../src' import { getIntegrationInfo } from '../../src/urlUtils' import { describe, expect, it } from 'vitest' import { mockFetch } from './mockFetch' @@ -19,27 +19,24 @@ describe('[Mocked response] Path parameter encoding', () => { { name: 'getEvent', prefix: 'v4/events', - placeholder: 'event_id', paramName: 'eventId', call: (param: string) => client.getEvent(param), }, { name: 'updateEvent', prefix: 'v4/events', - placeholder: 'event_id', paramName: 'eventId', call: (param: string) => client.updateEvent(param, { suspect: true }), }, { name: 'deleteVisitorData', prefix: 'v4/visitors', - placeholder: 'visitor_id', paramName: 'visitorId', call: (param: string) => client.deleteVisitorData(param), }, ] as const - describe.each(operations)('$name', ({ prefix, placeholder, paramName, call }) => { + describe.each(operations)('$name', ({ prefix, paramName, call }) => { // The full encoding table lives in the unit tests; these are the cases INTER-2499 asks to // be pinned at the wire level for every method. it.each([ @@ -58,13 +55,13 @@ describe('[Mocked response] Path parameter encoding', () => { }) it.each(['.', '..'])('does not send a request for %j', async (param) => { - await expect(call(param)).rejects.toThrow(new TypeError(`Invalid path parameter for ${placeholder}: ${param}`)) + await expect(call(param)).rejects.toThrow(new SdkError(`${paramName} is not valid: ${param}`)) expect(mockFetch).not.toHaveBeenCalled() }) it('does not send a request for an empty parameter', async () => { - await expect(call('')).rejects.toThrow(new TypeError(`${paramName} is not set`)) + await expect(call('')).rejects.toThrow(new SdkError(`${paramName} is not set`)) expect(mockFetch).not.toHaveBeenCalled() }) diff --git a/tests/unit-tests/serverApiClientTests.spec.ts b/tests/unit-tests/serverApiClientTests.spec.ts index 1df284b1..484e32f0 100644 --- a/tests/unit-tests/serverApiClientTests.spec.ts +++ b/tests/unit-tests/serverApiClientTests.spec.ts @@ -3,7 +3,6 @@ import { RequestError, FingerprintServerApiClient, Region, - Options, EventUpdate, SdkError, ErrorResponse, @@ -11,10 +10,14 @@ import { import { describe, expect, it, vi } from 'vitest' describe('ServerApiClient', () => { - it('should throw error if no token provided', () => { + it.each([ + { name: 'omitted', args: [] }, + { name: 'null', args: [null] }, + { name: 'missing an API key', args: [{}] }, + ])('should throw SdkError if options are $name', ({ args }) => { expect(() => { - new FingerprintServerApiClient({} as Readonly) - }).toThrow('Api key is not set') + Reflect.construct(FingerprintServerApiClient, args) + }).toThrow(new SdkError('Api key is not set')) }) it('should support passing custom fetch implementation', async () => { @@ -203,7 +206,7 @@ describe('ServerApiClient', () => { region: 'Global', }) - await expect(client.getEvent(undefined as unknown as string)).rejects.toThrow(new TypeError('eventId is not set')) + await expect(client.getEvent(undefined as unknown as string)).rejects.toThrow(new SdkError('eventId is not set')) }) it('should throw error when using updateEvent if body or eventId is empty', async () => { @@ -213,11 +216,11 @@ describe('ServerApiClient', () => { }) await expect(client.updateEvent('', undefined as unknown as EventUpdate)).rejects.toThrow( - new TypeError('body is not set') + new SdkError('body is not set') ) await expect(client.updateEvent(undefined as unknown as string, { linked_id: '' })).rejects.toThrow( - new TypeError('eventId is not set') + new SdkError('eventId is not set') ) }) @@ -228,7 +231,7 @@ describe('ServerApiClient', () => { }) await expect(client.deleteVisitorData(undefined as unknown as string)).rejects.toThrow( - new TypeError('visitorId is not set') + new SdkError('visitorId is not set') ) }) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index a26d8939..7a812fc5 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Region, SearchEventsFilter } from '../../src' +import { Region, SdkError, SearchEventsFilter } from '../../src' import { version } from '../../package.json' import type { paths } from '../../src/generatedApiTypes' import { getRequestPath } from '../../src/urlUtils' @@ -172,7 +172,7 @@ describe('getRequestPath', () => { method: 'get', region: 'unknown' as Region, }) - }).toThrow('Unsupported region') + }).toThrow(new SdkError('Unsupported region')) }) it('throws error when required path param is missing', () => { @@ -182,7 +182,7 @@ describe('getRequestPath', () => { method: 'get', pathParams: [], }) - }).toThrow('Missing path parameter for event_id') + }).toThrow(new SdkError('eventId is not set')) }) it('disallows normalized path segments', () => { @@ -192,7 +192,7 @@ describe('getRequestPath', () => { method: 'get', pathParams: [], }) - }).toThrow('Invalid path: path changed during normalization') + }).toThrow(new SdkError('Invalid path: path changed during normalization')) }) it('encodes special characters', () => { @@ -244,15 +244,15 @@ describe('path parameter encoding', () => { }) it.each([ - ['.', '.', 'Invalid path parameter for event_id: .'], - ['..', '..', 'Invalid path parameter for event_id: ..'], - ['a String object', new String('..'), 'Invalid path parameter for event_id: ..'], - ['an object with a toString', { toString: () => '..' }, 'Invalid path parameter for event_id: ..'], - ['an array', ['..'], 'Invalid path parameter for event_id: ..'], + ['.', '.', 'eventId is not valid: .'], + ['..', '..', 'eventId is not valid: ..'], + ['a String object', new String('..'), 'eventId is not valid: ..'], + ['an object with a toString', { toString: () => '..' }, 'eventId is not valid: ..'], + ['an array', ['..'], 'eventId is not valid: ..'], // A lone surrogate makes `encodeURIComponent` throw a `URIError` - ['a lone surrogate', '\ud800', 'Invalid path parameter for event_id'], + ['a lone surrogate', '\ud800', 'eventId is not valid'], // These have no primitive representation, so `String` itself throws - ['an object without a prototype', Object.create(null), 'Invalid path parameter for event_id'], + ['an object without a prototype', Object.create(null), 'eventId is not valid'], [ 'an object whose toString throws', { @@ -260,10 +260,10 @@ describe('path parameter encoding', () => { throw new Error('boom') }, }, - 'Invalid path parameter for event_id', + 'eventId is not valid', ], ])('rejects %s', (_, param, message) => { - expect(() => eventPath(param)).toThrow(new TypeError(message)) + expect(() => eventPath(param)).toThrow(new SdkError(message)) }) it('preserves the cause when string coercion fails', () => { @@ -276,7 +276,7 @@ describe('path parameter encoding', () => { expect(() => eventPath(param)).toThrow( expect.objectContaining({ - message: 'Invalid path parameter for event_id', + message: 'eventId is not valid', cause, }) ) @@ -288,6 +288,6 @@ describe('path parameter encoding', () => { ['null', null], ['undefined', undefined], ])('rejects %s as missing', (_, param) => { - expect(() => eventPath(param)).toThrow(new TypeError('Missing path parameter for event_id')) + expect(() => eventPath(param)).toThrow(new SdkError('eventId is not set')) }) })