Skip to content

Commit

Permalink
feat(backend): Support totalCount on BAPI responses (#2199)
Browse files Browse the repository at this point in the history
The backend clerkClient will return a `totalCount: number`
in the responses for BAPI responses:
- with `total_count` prop of response
- with an array of objects as `data` prop of response

The `totalCount` is NOT included in:
- single resource responses
- error responses
  • Loading branch information
dimkl authored Nov 24, 2023
1 parent 043801f commit 0307957
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 19 deletions.
23 changes: 23 additions & 0 deletions .changeset/new-cougars-taste.md
Original file line number Diff line number Diff line change
@@ -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);

```
41 changes: 37 additions & 4 deletions packages/backend/src/api/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -30,14 +30,15 @@ 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');
assert.equal(payload.emailAddresses[0].emailAddress, '[email protected]');
assert.equal(payload.phoneNumbers[0].phoneNumber, '+311-555-2368');
assert.equal(payload.externalAccounts[0].emailAddress, '[email protected]');
assert.equal(payload.publicMetadata.zodiac_sign, 'leo');
assert.equal(totalCount, undefined);

assert.ok(
fakeFetch.calledOnceWith('https://api.clerk.test/v1/users/user_deadbeef', {
Expand All @@ -57,14 +58,15 @@ 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');
assert.equal(payload[0].emailAddresses[0].emailAddress, '[email protected]');
assert.equal(payload[0].phoneNumbers[0].phoneNumber, '+311-555-2368');
assert.equal(payload[0].externalAccounts[0].emailAddress, '[email protected]');
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', {
Expand Down Expand Up @@ -92,10 +94,11 @@ export default (QUnit: QUnit) => {
'{"from_email_name":"foobar123","email_address_id":"[email protected]","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', {
Expand All @@ -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, '[email protected]');
assert.equal(payload[0].phoneNumbers[0].phoneNumber, '+311-555-2368');
assert.equal(payload[0].externalAccounts[0].emailAddress, '[email protected]');
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));
Expand Down
10 changes: 6 additions & 4 deletions packages/backend/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export type ClerkBackendApiResponse<T> =
| {
data: T;
errors: null;
totalCount?: number;
}
| {
data: null;
errors: ClerkAPIError[];
totalCount?: never;
clerkTraceId?: string;
status?: number;
statusText?: string;
Expand Down Expand Up @@ -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<T>(responseBody),
errors: null,
};
} catch (err) {
Expand Down
39 changes: 28 additions & 11 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
data: T;
};

type PaginatedResponse<T> = {
data: T;
totalCount?: number;
};

export function deserialize<U = any>(payload: unknown): PaginatedResponse<U> | ResourceResponse<U> {
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) && <PaginatedResponse>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;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,8 @@ export interface DeletedObjectJSON {
slug?: string;
deleted: boolean;
}

export interface PaginatedResponseJSON {
data: object[];
total_count?: number;
}
18 changes: 18 additions & 0 deletions packages/backend/src/util/mockFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down

0 comments on commit 0307957

Please sign in to comment.