diff --git a/src/core/index.ts b/src/core/index.ts index e888a01..440a0b8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -10,6 +10,7 @@ export { export { generateId, generateUlid, resetIdState, ID_PREFIXES } from './id.js'; export { parseListParams, + parseArrayParam, cursorPaginate, type CursorPaginationOptions, type CursorPaginatedResult, diff --git a/src/core/pagination.ts b/src/core/pagination.ts index f0bb8e4..e9c6d2f 100644 --- a/src/core/pagination.ts +++ b/src/core/pagination.ts @@ -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( items: T[], options: CursorPaginationOptions = {}, diff --git a/src/workos/routes/events.ts b/src/workos/routes/events.ts index 9feee79..83fec0e 100644 --- a/src/workos/routes/events.ts +++ b/src/workos/routes/events.ts @@ -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'; @@ -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'); diff --git a/src/workos/routes/memberships.spec.ts b/src/workos/routes/memberships.spec.ts index 79699bb..b4bb8af 100644 --- a/src/workos/routes/memberships.spec.ts +++ b/src/workos/routes/memberships.spec.ts @@ -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 = {}; + 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'); diff --git a/src/workos/routes/memberships.ts b/src/workos/routes/memberships.ts index d742a6e..9839678 100644 --- a/src/workos/routes/memberships.ts +++ b/src/workos/routes/memberships.ts @@ -5,6 +5,7 @@ import { parseJsonBody, WorkOSApiError, parseListParams, + parseArrayParam, } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatMembership, formatListResponse } from '../helpers.js'; @@ -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; }, });