diff --git a/.changeset/cyan-lobsters-look.md b/.changeset/cyan-lobsters-look.md new file mode 100644 index 00000000..6860fdea --- /dev/null +++ b/.changeset/cyan-lobsters-look.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/node-sdk': patch +--- + +**perf**: Avoid the overhead of cloning and double-buffering large payloads on success case. diff --git a/.changeset/ten-chicken-cough.md b/.changeset/ten-chicken-cough.md new file mode 100644 index 00000000..dbc11b2e --- /dev/null +++ b/.changeset/ten-chicken-cough.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/node-sdk': major +--- + +**BREAKING**: `updateEvent` now takes `eventId` as the first parameter and `body` as the second. diff --git a/example/updateEvent.mjs b/example/updateEvent.mjs index 930c8c0c..ed1fcb0b 100644 --- a/example/updateEvent.mjs +++ b/example/updateEvent.mjs @@ -27,16 +27,13 @@ if (envRegion === 'eu') { const client = new FingerprintServerApiClient({ region, apiKey }) try { - await client.updateEvent( - { + await client.updateEvent(eventId, { tags: { key: 'value', }, linked_id: 'new_linked_id', suspect: false, - }, - eventId - ) + }) console.log('Event updated') } catch (error) { diff --git a/src/serverApiClient.ts b/src/serverApiClient.ts index 5799c742..26f0fd51 100644 --- a/src/serverApiClient.ts +++ b/src/serverApiClient.ts @@ -1,4 +1,4 @@ -import { AllowedMethod, getRequestPath, GetRequestPathOptions, SuccessJsonOrVoid } from './urlUtils' +import { getRequestPath, GetRequestPathOptions } from './urlUtils' import { Event, EventUpdate, @@ -9,11 +9,16 @@ import { SearchEventsFilter, SearchEventsResponse, } from './types' -import { paths } from './generatedApiTypes' import { RequestError, SdkError, TooManyRequestsError } from './errors/apiErrors' import { isErrorResponse } from './errors/handleErrorResponse' import { toError } from './errors/toError' +type CallApiOptions = GetRequestPathOptions & { + headers?: Record + body?: BodyInit + expect: 'json' | 'void' +} + export class FingerprintServerApiClient implements FingerprintApi { public readonly region: Region @@ -97,6 +102,7 @@ export class FingerprintServerApiClient implements FingerprintApi { pathParams: [eventId], method: 'get', queryParams: options, + expect: 'json', }) } @@ -108,8 +114,8 @@ export class FingerprintServerApiClient implements FingerprintApi { * * **Warning** It's not possible to update events older than one month. * - * @param body - Data to update the event with. * @param eventId The unique event [identifier](https://docs.fingerprint.com/reference/js-agent-v4-get-function#event_id). + * @param body - Data to update the event with. * * @return {Promise} * @@ -121,7 +127,7 @@ export class FingerprintServerApiClient implements FingerprintApi { * } * * client - * .updateEvent(body, '') + * .updateEvent('', body) * .then(() => { * // Event was successfully updated * }) @@ -138,7 +144,7 @@ export class FingerprintServerApiClient implements FingerprintApi { * }) * ``` */ - public async updateEvent(body: EventUpdate, eventId: string): Promise { + public async updateEvent(eventId: string, body: EventUpdate): Promise { if (!body) { throw new TypeError('body is not set') } @@ -152,6 +158,7 @@ export class FingerprintServerApiClient implements FingerprintApi { pathParams: [eventId], method: 'patch', body: JSON.stringify(body), + expect: 'void', }) } @@ -190,6 +197,7 @@ export class FingerprintServerApiClient implements FingerprintApi { path: '/visitors/{visitor_id}', pathParams: [visitorId], method: 'delete', + expect: 'void', }) } @@ -256,66 +264,78 @@ export class FingerprintServerApiClient implements FingerprintApi { path: '/events', method: 'get', queryParams: filter, + expect: 'json', }) } - private async callApi>( - options: GetRequestPathOptions & { headers?: Record; body?: BodyInit } - ): Promise> { + private async callApi(options: CallApiOptions & { expect: 'json' }): Promise + private async callApi(options: CallApiOptions & { expect: 'void' }): Promise + private async callApi(options: CallApiOptions): Promise { const url = getRequestPath({ ...options, region: this.region, }) + const requestInit: RequestInit = { + method: options.method.toUpperCase(), + headers: { + ...this.defaultHeaders, + ...options.headers, + }, + } + + if (options.body !== undefined) { + requestInit.body = options.body + } + let response: Response try { - response = await this.fetch(url, { - method: options.method.toUpperCase(), - headers: { - ...this.defaultHeaders, - ...options.headers, - }, - body: options.body, - }) + response = await this.fetch(url, requestInit) } catch (e) { - throw new SdkError('Network or fetch error', undefined, e as Error) + throw new SdkError('Network or fetch error', undefined, toError(e)) } - const contentType = response.headers.get('content-type') ?? '' - const isJson = contentType.includes('application/json') - if (response.ok) { - const hasNoBody = response.status === 204 || response.headers.get('content-length') === '0' + if (options.expect === 'void') { + return + } + const hasNoBody = response.status === 204 || response.headers.get('content-length') === '0' if (hasNoBody) { - return undefined as SuccessJsonOrVoid + throw new SdkError('Expected JSON response but response body is empty', response) } - if (!isJson) { + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { throw new SdkError('Expected JSON response but received non-JSON content type', response) } - let data - try { - data = await response.clone().json() - } catch (e) { - throw new SdkError('Failed to parse JSON response', response, toError(e)) - } - return data as SuccessJsonOrVoid + return this.parseJson(response) + } + + const errorPayload = await this.parseJson(response.clone()) + + if (response.status === 429 && isErrorResponse(errorPayload)) { + throw new TooManyRequestsError(errorPayload, response) + } + if (isErrorResponse(errorPayload)) { + throw new RequestError( + errorPayload.error.message, + errorPayload, + response.status, + errorPayload.error.code, + response + ) } - let errPayload + throw RequestError.unknown(response) + } + + private async parseJson(response: Response): Promise { try { - errPayload = await response.clone().json() + return (await response.json()) as T } catch (e) { throw new SdkError('Failed to parse JSON response', response, toError(e)) } - if (isErrorResponse(errPayload)) { - if (response.status === 429) { - throw new TooManyRequestsError(errPayload, response) - } - throw new RequestError(errPayload.error.message, errPayload, response.status, errPayload.error.code, response) - } - throw RequestError.unknown(response) } } diff --git a/src/types.ts b/src/types.ts index 79a04f80..86f823da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { components, operations, paths } from './generatedApiTypes' +import { components, paths } from './generatedApiTypes' export enum Region { EU = 'EU', @@ -49,42 +49,9 @@ export type EventUpdate = components['schemas']['EventUpdate'] export type EventRuleAction = components['schemas']['EventRuleAction'] -// Extract just the `path` parameters as a tuple of strings -type ExtractPathParamStrings = Path extends { parameters: { path: infer P } } - ? P extends Record - ? [P[keyof P]] // We extract the path parameter values as a tuple of strings - : [] - : [] - -// Utility type to extract query parameters from an operation and differentiate required/optional -export type ExtractQueryParams = Path extends { parameters: { query?: infer Q } } - ? undefined extends Q // Check if Q can be undefined (meaning it's optional) - ? Q | undefined // If so, it's optional - : Q // Otherwise, it's required - : never // If no query parameters, return never - -// Utility type to extract request body from an operation (for POST, PUT, etc.) -type ExtractRequestBody = Path extends { requestBody: { content: { 'application/json': infer B } } } ? B : never - -// Utility type to extract the response type for 200 status code -type ExtractResponse = Path extends { responses: { 200: { content: { 'application/json': infer R } } } } - ? R - : void - -// Extracts args to given API method -type ApiMethodArgs = [ - // If method has body, extract it as first parameter - ...(ExtractRequestBody extends never ? [] : [body: ExtractRequestBody]), - // Next are path params, e.g. for path "/events/{event_id}" it will be one string parameter, - ...ExtractPathParamStrings, - // Last parameter will be the query params, if any - ...(ExtractQueryParams extends never ? [] : [params: ExtractQueryParams]), -] - -type ApiMethod = ( - ...args: ApiMethodArgs -) => Promise> - -export type FingerprintApi = { - [Operation in keyof operations]: ApiMethod +export interface FingerprintApi { + getEvent(eventId: string, options?: GetEventOptions): Promise + updateEvent(eventId: string, body: EventUpdate): Promise + searchEvents(filter: SearchEventsFilter): Promise + deleteVisitorData(visitorId: string): Promise } diff --git a/src/urlUtils.ts b/src/urlUtils.ts index 6cb3848e..61996a42 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -1,4 +1,4 @@ -import { ExtractQueryParams, Region } from './types' +import { Region } from './types' import { version } from '../package.json' import { paths } from './generatedApiTypes' @@ -10,8 +10,7 @@ const globalRegionUrl = 'https://api.fpjs.io/' type QueryStringScalar = string | number | boolean | null | undefined -type QueryStringParameters = Record & { - api_key?: string +type QueryStringParameters = Record & { ii: string } @@ -57,67 +56,15 @@ function getServerApiUrl(region: Region): string { } } -/** - * Extracts parameter placeholders into a literal union type. - * For example `extractPathParams<'/users/{userId}/posts/{postId}'>` resolves to `"userId" | "postId" - */ -type ExtractPathParams = T extends `${string}{${infer Param}}${infer Rest}` - ? Param | ExtractPathParams - : never - -type PathParams = - ExtractPathParams extends never - ? { pathParams?: never } - : { - pathParams: ExtractPathParams extends never ? never : string[] - } - -type QueryParams = - ExtractQueryParams extends never - ? { queryParams?: any } // No query params - : { - queryParams?: ExtractQueryParams // Optional query params - } - -type IsNever = [Exclude] extends [never] ? true : false -export type NonNeverKeys = { - [Key in keyof Type]-?: IsNever extends true ? never : Key -}[keyof Type] -export type AllowedMethod = Extract, 'parameters'>, string> - -type JsonContentOf = Response extends { content: { 'application/json': infer T } } ? T : never - -type UnionJsonFromResponses = { - [StatusCode in keyof Response]: JsonContentOf -}[keyof Response] +export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace' -type StartingWithSuccessCode = { - [StatusCode in keyof Response]: `${StatusCode & number}` extends `2${number}${number}` ? StatusCode : never -}[keyof Response] - -type SuccessResponses = Pick, keyof Response>> - -type OperationOf> = paths[Path][Method] - -type ResponsesOf> = - OperationOf extends { responses: infer Response } ? Response : never - -type SuccessJson> = UnionJsonFromResponses< - SuccessResponses> -> - -export type SuccessJsonOrVoid> = [ - SuccessJson, -] extends [never] - ? void - : SuccessJson - -export type GetRequestPathOptions> = { - path: Path - method: Method +export interface GetRequestPathOptions { + path: keyof paths + method: HttpMethod + pathParams?: string[] + queryParams?: Record region?: Region -} & PathParams & - QueryParams +} /** * Formats a URL for the FingerprintJS server API by replacing placeholders and @@ -125,18 +72,18 @@ export type GetRequestPathOptions} options - * @param {Path} options.path - The path of the API endpoint + * @param {GetRequestPathOptions} options + * @param {keyof paths} options.path - The path of the API endpoint * @param {string[]} [options.pathParams] - Path parameters to be replaced in the path - * @param {QueryParams["queryParams"]} [options.queryParams] - Query string + * @param {GetRequestPathOptions["queryParams"]} [options.queryParams] - Query string * parameters to be appended to the URL * @param {Region} options.region - The region of the API endpoint - * @param {Method} options.method - The method of the API endpoint + * @param {HttpMethod} options.method - The method of the API endpoint * * @returns {string} The formatted URL with parameters replaced and query string * parameters appended */ -export function getRequestPath>({ +export function getRequestPath({ path, pathParams, queryParams, @@ -144,7 +91,7 @@ export function getRequestPath): string { +}: GetRequestPathOptions): string { // Step 1: Extract the path parameters (placeholders) from the path const placeholders = Array.from(path.matchAll(/{(.*?)}/g)).map((match) => match[1]) diff --git a/tests/mocked-responses-tests/updateEventTests.spec.ts b/tests/mocked-responses-tests/updateEventTests.spec.ts index ee6e83ac..b598cbe5 100644 --- a/tests/mocked-responses-tests/updateEventTests.spec.ts +++ b/tests/mocked-responses-tests/updateEventTests.spec.ts @@ -23,7 +23,7 @@ describe('[Mocked response] Update event', () => { linked_id: 'linked_id', suspect: true, } - const response = await client.updateEvent(body, existingEventId) + const response = await client.updateEvent(existingEventId, body) expect(response).toBeUndefined() @@ -52,7 +52,7 @@ describe('[Mocked response] Update event', () => { linked_id: 'linked_id', suspect: true, } - await expect(client.updateEvent(body, existingEventId)).rejects.toThrow( + await expect(client.updateEvent(existingEventId, body)).rejects.toThrow( RequestError.fromErrorResponse(Error404 as ErrorResponse, mockResponse) ) }) @@ -68,7 +68,7 @@ describe('[Mocked response] Update event', () => { linked_id: 'linked_id', suspect: true, } - await expect(client.updateEvent(body, existingEventId)).rejects.toThrow( + await expect(client.updateEvent(existingEventId, body)).rejects.toThrow( RequestError.fromErrorResponse(Error403 as ErrorResponse, mockResponse) ) }) @@ -84,7 +84,7 @@ describe('[Mocked response] Update event', () => { linked_id: 'linked_id', suspect: true, } - await expect(client.updateEvent(body, existingEventId)).rejects.toThrow( + await expect(client.updateEvent(existingEventId, body)).rejects.toThrow( RequestError.fromErrorResponse(Error400 as ErrorResponse, mockResponse) ) }) @@ -100,7 +100,7 @@ describe('[Mocked response] Update event', () => { linked_id: 'linked_id', suspect: true, } - await expect(client.updateEvent(body, existingEventId)).rejects.toThrow( + await expect(client.updateEvent(existingEventId, body)).rejects.toThrow( RequestError.fromErrorResponse(Error409 as ErrorResponse, mockResponse) ) }) @@ -115,7 +115,7 @@ describe('[Mocked response] Update event', () => { linked_id: 'linked_id', suspect: true, } - await expect(client.updateEvent(body, existingEventId)).rejects.toMatchObject( + await expect(client.updateEvent(existingEventId, body)).rejects.toMatchObject( new SdkError( 'Failed to parse JSON response', mockResponse, @@ -141,7 +141,7 @@ describe('[Mocked response] Update event', () => { linked_id: 'linked_id', suspect: true, } - await expect(client.updateEvent(body, existingEventId)).rejects.toThrow(RequestError) - await expect(client.updateEvent(body, existingEventId)).rejects.toThrow('Unknown error') + await expect(client.updateEvent(existingEventId, body)).rejects.toThrow(RequestError) + await expect(client.updateEvent(existingEventId, body)).rejects.toThrow('Unknown error') }) }) diff --git a/tests/unit-tests/serverApiClientTests.spec.ts b/tests/unit-tests/serverApiClientTests.spec.ts index f8f440a2..65eb1029 100644 --- a/tests/unit-tests/serverApiClientTests.spec.ts +++ b/tests/unit-tests/serverApiClientTests.spec.ts @@ -85,11 +85,11 @@ describe('ServerApiClient', () => { region: 'Global', }) - await expect(client.updateEvent(undefined as unknown as EventUpdate, '')).rejects.toThrow( + await expect(client.updateEvent('', undefined as unknown as EventUpdate)).rejects.toThrow( new TypeError('body is not set') ) - await expect(client.updateEvent({ linked_id: '' }, undefined as unknown as string)).rejects.toThrow( + await expect(client.updateEvent(undefined as unknown as string, { linked_id: '' })).rejects.toThrow( new TypeError('eventId is not set') ) }) @@ -140,6 +140,54 @@ describe('ServerApiClient', () => { await expect(client.getEvent('')).rejects.toBeInstanceOf(SdkError) }) + it('should throw error when the response has status 204', async () => { + const mockFetch = jest + .fn() + .mockResolvedValue(new Response(null, { status: 204, headers: { 'content-type': 'application/json' } })) + + const client = new FingerprintServerApiClient({ fetch: mockFetch, apiKey: 'test' }) + + await expect(client.getEvent('')).rejects.toThrow('Expected JSON response but response body is empty') + await expect(client.getEvent('')).rejects.toBeInstanceOf(SdkError) + }) + + it('should throw error when the response has zero content length', async () => { + const mockFetch = jest + .fn() + .mockResolvedValue( + new Response('', { status: 200, headers: { 'content-type': 'application/json', 'content-length': '0' } }) + ) + + const client = new FingerprintServerApiClient({ fetch: mockFetch, apiKey: 'test' }) + + await expect(client.getEvent('')).rejects.toThrow('Expected JSON response but response body is empty') + await expect(client.getEvent('')).rejects.toBeInstanceOf(SdkError) + }) + + it('should throw error when the response has incorrect content type', async () => { + const mockFetch = jest + .fn() + .mockResolvedValue(new Response('not json', { status: 200, headers: { 'content-type': 'text/plain' } })) + + const client = new FingerprintServerApiClient({ fetch: mockFetch, apiKey: 'test' }) + + await expect(client.getEvent('')).rejects.toThrow( + 'Expected JSON response but received non-JSON content type' + ) + await expect(client.getEvent('')).rejects.toBeInstanceOf(SdkError) + }) + + it('should throw error when the response has no content-type header', async () => { + const mockFetch = jest.fn().mockResolvedValue(new Response(null, { status: 200 })) + + const client = new FingerprintServerApiClient({ fetch: mockFetch, apiKey: 'test' }) + + await expect(client.getEvent('')).rejects.toThrow( + 'Expected JSON response but received non-JSON content type' + ) + await expect(client.getEvent('')).rejects.toBeInstanceOf(SdkError) + }) + it('throws SdkError when response has invalid json', async () => { const badJsonOk = { ok: true,