diff --git a/.changeset/new-cougars-taste.md b/.changeset/new-cougars-taste.md new file mode 100644 index 0000000000..36bfa1f36a --- /dev/null +++ b/.changeset/new-cougars-taste.md @@ -0,0 +1,23 @@ +--- +'@clerk/backend': minor +--- + +Expose `totalCount` from `@clerk/backend` client responses for responses +containing pagination information or for responses with type `{ data: object[] }`. + + +Example: +```typescript +import { Clerk } from '@clerk/backend' + +const clerkClient = Clerk({ secretKey: '...'}); + +// current +const { data } = await clerkClient.organizations.getOrganizationList(); +console.log('totalCount: ', data.length); + +// new +const { data, totalCount } = await clerkClient.organizations.getOrganizationList(); +console.log('totalCount: ', totalCount); + +``` \ No newline at end of file diff --git a/packages/backend/src/api/factory.test.ts b/packages/backend/src/api/factory.test.ts index 88be8073eb..b5e0612c35 100644 --- a/packages/backend/src/api/factory.test.ts +++ b/packages/backend/src/api/factory.test.ts @@ -5,7 +5,7 @@ import emailJson from '../fixtures/responses/email.json'; import userJson from '../fixtures/responses/user.json'; import runtime from '../runtime'; import { assertErrorResponse, assertResponse } from '../util/assertResponse'; -import { jsonError, jsonNotOk, jsonOk } from '../util/mockFetch'; +import { jsonError, jsonNotOk, jsonOk, jsonPaginatedOk } from '../util/mockFetch'; import { createBackendApiClient } from './factory'; export default (QUnit: QUnit) => { @@ -30,7 +30,7 @@ export default (QUnit: QUnit) => { const response = await apiClient.users.getUser('user_deadbeef'); assertResponse(assert, response); - const { data: payload } = response; + const { data: payload, totalCount } = response; assert.equal(payload.firstName, 'John'); assert.equal(payload.lastName, 'Doe'); @@ -38,6 +38,7 @@ export default (QUnit: QUnit) => { assert.equal(payload.phoneNumbers[0].phoneNumber, '+311-555-2368'); assert.equal(payload.externalAccounts[0].emailAddress, 'john.doe@clerk.test'); assert.equal(payload.publicMetadata.zodiac_sign, 'leo'); + assert.equal(totalCount, undefined); assert.ok( fakeFetch.calledOnceWith('https://api.clerk.test/v1/users/user_deadbeef', { @@ -57,7 +58,7 @@ export default (QUnit: QUnit) => { const response = await apiClient.users.getUserList({ offset: 2, limit: 5 }); assertResponse(assert, response); - const { data: payload } = response; + const { data: payload, totalCount } = response; assert.equal(payload[0].firstName, 'John'); assert.equal(payload[0].lastName, 'Doe'); @@ -65,6 +66,7 @@ export default (QUnit: QUnit) => { assert.equal(payload[0].phoneNumbers[0].phoneNumber, '+311-555-2368'); assert.equal(payload[0].externalAccounts[0].emailAddress, 'john.doe@clerk.test'); assert.equal(payload[0].publicMetadata.zodiac_sign, 'leo'); + assert.equal(totalCount, 1); assert.ok( fakeFetch.calledOnceWith('https://api.clerk.test/v1/users?offset=2&limit=5', { @@ -92,10 +94,11 @@ export default (QUnit: QUnit) => { '{"from_email_name":"foobar123","email_address_id":"test@test.dev","body":"this is a test","subject":"this is a test"}'; const response = await apiClient.emails.createEmail(body); assertResponse(assert, response); - const { data: payload } = response; + const { data: payload, totalCount } = response; assert.equal(JSON.stringify(payload.data), '{}'); assert.equal(payload.id, 'ema_2PHa2N3bS7D6NPPQ5mpHEg0waZQ'); + assert.equal(totalCount, undefined); assert.ok( fakeFetch.calledOnceWith('https://api.clerk.test/v1/emails', { @@ -110,6 +113,36 @@ export default (QUnit: QUnit) => { ); }); + test('executes a successful backend API request for a paginated response', async assert => { + fakeFetch = sinon.stub(runtime, 'fetch'); + fakeFetch.onCall(0).returns(jsonPaginatedOk([userJson], 3)); + + const response = await apiClient.users.getUserList({ offset: 2, limit: 5 }); + assertResponse(assert, response); + const { data: payload, totalCount } = response; + + assert.equal(payload[0].firstName, 'John'); + assert.equal(payload[0].lastName, 'Doe'); + assert.equal(payload[0].emailAddresses[0].emailAddress, 'john.doe@clerk.test'); + assert.equal(payload[0].phoneNumbers[0].phoneNumber, '+311-555-2368'); + assert.equal(payload[0].externalAccounts[0].emailAddress, 'john.doe@clerk.test'); + assert.equal(payload[0].publicMetadata.zodiac_sign, 'leo'); + // payload.length is different from response total_count to check that totalCount use the total_count from response + assert.equal(payload.length, 1); + assert.equal(totalCount, 3); + + assert.ok( + fakeFetch.calledOnceWith('https://api.clerk.test/v1/users?offset=2&limit=5', { + method: 'GET', + headers: { + Authorization: 'Bearer deadbeef', + 'Content-Type': 'application/json', + 'Clerk-Backend-SDK': '@clerk/backend', + }, + }), + ); + }); + test('executes a successful backend API request to create a new resource', async assert => { fakeFetch = sinon.stub(runtime, 'fetch'); fakeFetch.onCall(0).returns(jsonOk(userJson)); diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index b6ad827024..e670b87dcf 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -30,10 +30,12 @@ export type ClerkBackendApiResponse = | { data: T; errors: null; + totalCount?: number; } | { data: null; errors: ClerkAPIError[]; + totalCount?: never; clerkTraceId?: string; status?: number; statusText?: string; @@ -107,20 +109,20 @@ export function buildRequest(options: BuildRequestOptions) { // TODO: Parse JSON or Text response based on a response header const isJSONResponse = res?.headers && res.headers?.get(constants.Headers.ContentType) === constants.ContentTypes.Json; - const data = await (isJSONResponse ? res.json() : res.text()); + const responseBody = await (isJSONResponse ? res.json() : res.text()); if (!res.ok) { return { data: null, - errors: data?.errors || data, + errors: responseBody?.errors || responseBody, status: res?.status, statusText: res?.statusText, - clerkTraceId: getTraceId(data, res?.headers), + clerkTraceId: getTraceId(responseBody, res?.headers), }; } return { - data: deserialize(data), + ...deserialize(responseBody), errors: null, }; } catch (err) { diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 26bdec7bab..81a43c171f 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -17,28 +17,45 @@ import { Token, User, } from '.'; +import type { PaginatedResponseJSON } from './JSON'; import { ObjectType } from './JSON'; -// FIXME don't return any -export function deserialize(payload: any): any { +type ResourceResponse = { + data: T; +}; + +type PaginatedResponse = { + data: T; + totalCount?: number; +}; + +export function deserialize(payload: unknown): PaginatedResponse | ResourceResponse { + let data, totalCount: number | undefined; + if (Array.isArray(payload)) { - return payload.map(item => jsonToObject(item)); + data = payload.map(item => jsonToObject(item)) as U; + totalCount = payload.length; + + return { data, totalCount }; } else if (isPaginated(payload)) { - return payload.data.map(item => jsonToObject(item)); + data = payload.data.map(item => jsonToObject(item)) as U; + totalCount = payload.total_count; + + return { data, totalCount }; } else { - return jsonToObject(payload); + return { data: jsonToObject(payload) }; } } -type PaginatedResponse = { - data: object[]; -}; +function isPaginated(payload: unknown): payload is PaginatedResponseJSON { + if (!payload || typeof payload !== 'object' || !('data' in payload)) { + return false; + } -function isPaginated(payload: any): payload is PaginatedResponse { - return Array.isArray(payload.data) && payload.data !== undefined; + return Array.isArray(payload.data) && payload.data !== undefined; } -function getCount(item: { total_count: number }) { +function getCount(item: PaginatedResponseJSON) { return item.total_count; } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index a094f177d4..78c27c0b25 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -320,3 +320,8 @@ export interface DeletedObjectJSON { slug?: string; deleted: boolean; } + +export interface PaginatedResponseJSON { + data: object[]; + total_count?: number; +} diff --git a/packages/backend/src/util/mockFetch.ts b/packages/backend/src/util/mockFetch.ts index ada6a78f2a..8524a845fe 100644 --- a/packages/backend/src/util/mockFetch.ts +++ b/packages/backend/src/util/mockFetch.ts @@ -15,6 +15,24 @@ export function jsonOk(body: unknown, status = 200) { return Promise.resolve(mockResponse); } +export function jsonPaginatedOk(body: unknown[], total_count: number, status = 200) { + // Mock response object that satisfies the window.Response interface + const mockResponse = { + ok: true, + status, + statusText: status.toString(), + headers: { get: mockHeadersGet }, + json() { + return Promise.resolve({ + data: body, + total_count, + }); + }, + }; + + return Promise.resolve(mockResponse); +} + export function jsonNotOk(body: unknown) { // Mock response object that satisfies the window.Response interface const mockResponse = {