Skip to content
Open
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
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
export { generateId, generateUlid, resetIdState, ID_PREFIXES } from './id.js';
export {
parseListParams,
parseArrayParam,
cursorPaginate,
type CursorPaginationOptions,
type CursorPaginatedResult,
Expand Down
11 changes: 11 additions & 0 deletions src/core/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ export function parseListParams(url: URL) {
return { limit, order, before, after };
}

/**
* Reads an array query parameter the way production does. Production parses the query with `qs`
* and splits a scalar on commas, so it takes `name=a,b`, repeated `name=` (workos-go, -node,
* -ruby), `name[]=` (workos-dotnet) and indexed `name[0]=` (workos-php).
*/
export function parseArrayParam(url: URL, name: string): string[] {
return [...url.searchParams]
.filter(([key]) => key === name || (key.startsWith(`${name}[`) && /^\[\d*\]$/.test(key.slice(name.length))))
.flatMap(([, value]) => value.split(','));
}

export function cursorPaginate<T extends Entity>(
items: T[],
options: CursorPaginationOptions<T> = {},
Expand Down
10 changes: 3 additions & 7 deletions src/workos/routes/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type RouteContext, parseListParams } from '../../core/index.js';
import { type RouteContext, parseArrayParam, parseListParams } from '../../core/index.js';
import { getWorkOSStore } from '../store.js';
import { formatEvent, formatListResponse } from '../helpers.js';
import type { WorkOSEvent } from '../entities.js';
Expand All @@ -11,12 +11,8 @@ export function eventRoutes(ctx: RouteContext): void {
const url = new URL(c.req.url);
const params = parseListParams(url);
// The spec says `style: form, explode: false` (`events=a,b`), which is what workos-python,
// -kotlin, -elixir and -rust send. Production parses the query with `qs` and splits a scalar
// on commas, so it also takes repeated `events=` (workos-go, -node, -ruby), `events[]=`
// (workos-dotnet) and indexed `events[0]=` (workos-php). Accept all four.
const eventTypes = [...url.searchParams]
.filter(([key]) => key === 'events' || /^events\[\d*\]$/.test(key))
.flatMap(([, value]) => value.split(','));
// -kotlin, -elixir and -rust send. The other SDKs send the forms parseArrayParam also takes.
const eventTypes = parseArrayParam(url, 'events');
const organizationId = url.searchParams.get('organization_id');
const rangeStart = url.searchParams.get('range_start');
const rangeEnd = url.searchParams.get('range_end');
Expand Down
35 changes: 35 additions & 0 deletions src/workos/routes/memberships.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,41 @@ describe('Membership routes', () => {
expect(list.data).toHaveLength(2);
});

describe('list statuses filter', () => {
async function seedActiveAndInactive() {
const org = await createOrg('Statuses Org');
const ids: Record<string, string> = {};
for (const name of ['active', 'inactive']) {
const user = await createUser(`${name}@statuses.test`);
const m = await json(
await req('/user_management/organization_memberships', {
method: 'POST',
body: JSON.stringify({ organization_id: org.id, user_id: user.id }),
}),
);
ids[name] = m.id;
}
await req(`/user_management/organization_memberships/${ids.inactive}/deactivate`, { method: 'PUT' });
return org.id as string;
}

// Each row was checked against the real WorkOS API.
it.each([
['', ['active']],
['statuses=active', ['active']],
['statuses[]=active', ['active']],
['statuses%5B%5D=active', ['active']],
['statuses=inactive', ['inactive']],
['statuses=active&statuses=inactive', ['active', 'inactive']],
['statuses[]=active&statuses[]=inactive', ['active', 'inactive']],
['statuses=active,inactive', ['active', 'inactive']],
])('?%s returns %p', async (query, expected) => {
const orgId = await seedActiveAndInactive();
const list = await json(await req(`/user_management/organization_memberships?organization_id=${orgId}&${query}`));
expect(list.data.map((m: { status: string }) => m.status).sort()).toEqual(expected);
});
});

it('deactivates and reactivates a membership', async () => {
const org = await createOrg('Toggle Org');
const user = await createUser('toggle@test.com');
Expand Down
7 changes: 5 additions & 2 deletions src/workos/routes/memberships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseJsonBody,
WorkOSApiError,
parseListParams,
parseArrayParam,
} from '../../core/index.js';
import { getWorkOSStore } from '../store.js';
import { formatMembership, formatListResponse } from '../helpers.js';
Expand Down Expand Up @@ -61,14 +62,16 @@ export function membershipRoutes(ctx: RouteContext): void {
const params = parseListParams(url);
const orgFilter = url.searchParams.get('organization_id') ?? undefined;
const userFilter = url.searchParams.get('user_id') ?? undefined;
const statusesParam = url.searchParams.getAll('statuses[]');
// Production returns only active memberships when the request names no status.
const requestedStatuses = parseArrayParam(url, 'statuses');
const statuses = requestedStatuses.length > 0 ? requestedStatuses : ['active'];

const result = ws.organizationMemberships.list({
...params,
filter: (m) => {
if (orgFilter && m.organization_id !== orgFilter) return false;
if (userFilter && m.user_id !== userFilter) return false;
if (statusesParam.length > 0 && !statusesParam.includes(m.status)) return false;
if (!statuses.includes(m.status)) return false;
return true;
},
});
Expand Down
Loading