Skip to content
Merged
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 .changeset/sdk-error-for-invalid-arguments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fingerprint/node-sdk': patch
Comment thread
JuroUhlar marked this conversation as resolved.
---

Throw `SdkError` instead of `TypeError` or `Error` for invalid API client arguments.
4 changes: 2 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions src/errors/apiErrors.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -77,7 +81,10 @@ export class ServerApiError<Code extends number = number> extends RequestError<C
}

/**
* Error that indicates that the request was throttled.
* Structured Server API 429 (rate limit) response.
*
* Other 429 responses (for example from a proxy) are thrown as
* {@link RequestError} via {@link RequestError.unknown}.
*/
export class TooManyRequestsError extends ServerApiError<429> {
constructor(body: ErrorResponse, response: Response) {
Expand Down
10 changes: 9 additions & 1 deletion src/errors/unsealError.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions src/sealedResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event> {
const unsealed = await unseal(sealedData, decryptionKeys)
Expand Down
14 changes: 8 additions & 6 deletions src/serverApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export class FingerprintServerApiClient implements FingerprintApi {
* @param {Options} options - Options for FingerprintJS server API client
*/
constructor(options: Readonly<Options>) {
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
Expand Down Expand Up @@ -91,7 +93,7 @@ export class FingerprintServerApiClient implements FingerprintApi {
* */
public async getEvent(eventId: string, options?: GetEventOptions): Promise<Event> {
if (!eventId) {
throw new TypeError('eventId is not set')
throw new SdkError('eventId is not set')
}

return this.callApi({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -194,7 +196,7 @@ export class FingerprintServerApiClient implements FingerprintApi {
*/
public async deleteVisitorData(visitorId: string): Promise<void> {
if (!visitorId) {
throw new TypeError('visitorId is not set')
throw new SdkError('visitorId is not set')
}

return this.callApi({
Expand Down
22 changes: 16 additions & 6 deletions src/urlUtils.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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.
Expand All @@ -51,26 +60,27 @@ 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 {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- runtime validation
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
Expand All @@ -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')
}
}

Expand Down Expand Up @@ -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()
Expand Down
11 changes: 4 additions & 7 deletions tests/mocked-responses-tests/pathParamEncodingTests.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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([
Expand All @@ -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}`))
Comment thread
JuroUhlar marked this conversation as resolved.

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()
})
Expand Down
19 changes: 11 additions & 8 deletions tests/unit-tests/serverApiClientTests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import {
RequestError,
FingerprintServerApiClient,
Region,
Options,
EventUpdate,
SdkError,
ErrorResponse,
} from '../../src'
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<Options>)
}).toThrow('Api key is not set')
Reflect.construct(FingerprintServerApiClient, args)
}).toThrow(new SdkError('Api key is not set'))
Comment thread
JuroUhlar marked this conversation as resolved.
})

it('should support passing custom fetch implementation', async () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -213,11 +216,11 @@ describe('ServerApiClient', () => {
})

await expect(client.updateEvent('<eventId>', 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: '<linkedId>' })).rejects.toThrow(
new TypeError('eventId is not set')
new SdkError('eventId is not set')
)
})

Expand All @@ -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')
)
})

Expand Down
30 changes: 15 additions & 15 deletions tests/unit-tests/urlUtilsTests.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -244,26 +244,26 @@ 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',
{
toString: () => {
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', () => {
Expand All @@ -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,
})
)
Expand All @@ -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'))
})
})
Loading