Skip to content

Commit bb8bac1

Browse files
authored
feat(clerk-js): Filter undefined values from request body (#6776)
1 parent 4cebe1c commit bb8bac1

File tree

7 files changed

+320
-77
lines changed

7 files changed

+320
-77
lines changed

.changeset/fuzzy-books-win.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/clerk-js/src/core/__tests__/fapiClient.spec.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,136 @@ describe('request', () => {
252252
it.todo('sets the __clerk_db_jwt cookie from the response Clerk-Cookie header');
253253
});
254254

255+
describe('request body filtering', () => {
256+
it('filters out undefined values from request body objects', async () => {
257+
const requestBody = {
258+
definedValue: 'test',
259+
undefinedValue: undefined,
260+
nullValue: null,
261+
falseValue: false,
262+
zeroValue: 0,
263+
emptyString: '',
264+
} as any;
265+
266+
await fapiClient.request({
267+
path: '/foo',
268+
method: 'POST',
269+
body: requestBody,
270+
});
271+
272+
expect(fetch).toHaveBeenCalledWith(
273+
expect.any(URL),
274+
expect.objectContaining({
275+
body: 'defined_value=test&null_value=&false_value=false&zero_value=0&empty_string=',
276+
}),
277+
);
278+
});
279+
280+
it('preserves FormData objects without filtering', async () => {
281+
const formData = new FormData();
282+
formData.append('key', 'value');
283+
formData.append('undefinedKey', 'undefined'); // FormData doesn't have undefined values
284+
285+
await fapiClient.request({
286+
path: '/foo',
287+
method: 'POST',
288+
body: formData,
289+
});
290+
291+
expect(fetch).toHaveBeenCalledWith(
292+
expect.any(URL),
293+
expect.objectContaining({
294+
body: formData,
295+
}),
296+
);
297+
});
298+
299+
it('preserves non-object bodies without filtering', async () => {
300+
const stringBody = 'raw string body';
301+
302+
await fapiClient.request({
303+
path: '/foo',
304+
method: 'POST',
305+
body: stringBody,
306+
headers: {
307+
'content-type': 'text/plain',
308+
},
309+
});
310+
311+
expect(fetch).toHaveBeenCalledWith(
312+
expect.any(URL),
313+
expect.objectContaining({
314+
body: stringBody,
315+
}),
316+
);
317+
});
318+
319+
it('handles empty objects', async () => {
320+
await fapiClient.request({
321+
path: '/foo',
322+
method: 'POST',
323+
body: {} as any,
324+
});
325+
326+
expect(fetch).toHaveBeenCalledWith(
327+
expect.any(URL),
328+
expect.objectContaining({
329+
body: '',
330+
}),
331+
);
332+
});
333+
334+
it('handles objects with only undefined values', async () => {
335+
const requestBody = {
336+
undefinedValue1: undefined,
337+
undefinedValue2: undefined,
338+
} as any;
339+
340+
await fapiClient.request({
341+
path: '/foo',
342+
method: 'POST',
343+
body: requestBody,
344+
});
345+
346+
expect(fetch).toHaveBeenCalledWith(
347+
expect.any(URL),
348+
expect.objectContaining({
349+
body: '',
350+
}),
351+
);
352+
});
353+
354+
it('does not perform deep filtering - preserves nested undefined values', async () => {
355+
const requestBody = {
356+
topLevel: 'value',
357+
topLevelUndefined: undefined,
358+
nested: {
359+
nestedDefined: 'nested value',
360+
nestedUndefined: undefined,
361+
},
362+
} as any;
363+
364+
await fapiClient.request({
365+
path: '/foo',
366+
method: 'POST',
367+
body: requestBody,
368+
});
369+
370+
// The nested object should be JSON stringified with undefined values preserved
371+
// Note: JSON.stringify removes undefined values, so we expect only the defined nested value
372+
const expectedNestedJson = JSON.stringify({
373+
nestedDefined: 'nested value',
374+
} as any);
375+
376+
expect(fetch).toHaveBeenCalledWith(
377+
expect.any(URL),
378+
expect.objectContaining({
379+
body: `top_level=value&nested=${encodeURIComponent(expectedNestedJson).replace(/%20/g, '+')}`,
380+
}),
381+
);
382+
});
383+
});
384+
255385
describe('retry logic', () => {
256386
it('does not send retry query parameter on initial request', async () => {
257387
await fapiClient.request({

packages/clerk-js/src/core/fapiClient.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import type { ClerkAPIErrorJSON, ClientJSON, InstanceType } from '@clerk/types';
55

66
import { debugLogger } from '@/utils/debug';
77

8-
import { buildEmailAddress as buildEmailAddressUtil, buildURL as buildUrlUtil, stringifyQueryParams } from '../utils';
8+
import {
9+
buildEmailAddress as buildEmailAddressUtil,
10+
buildURL as buildUrlUtil,
11+
filterUndefinedValues,
12+
stringifyQueryParams,
13+
} from '../utils';
914
import { SUPPORTED_FAPI_VERSION } from './constants';
1015
import { clerkNetworkError } from './errors';
1116

@@ -195,6 +200,10 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
195200
const requestInit = { ..._requestInit };
196201
const { method = 'GET', body } = requestInit;
197202

203+
if (body && typeof body === 'object' && !(body instanceof FormData)) {
204+
requestInit.body = filterUndefinedValues(body);
205+
}
206+
198207
requestInit.url = buildUrl({
199208
...requestInit,
200209
// TODO: Pass these values to the FAPI client instead of calculating them on the spot
@@ -214,7 +223,6 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
214223
// Massage the body depending on the content type if needed.
215224
// Currently, this is needed only for form-urlencoded, so that the values reach the server in the form
216225
// foo=bar&baz=bar&whatever=1
217-
218226
if (requestInit.headers.get('content-type') === 'application/x-www-form-urlencoded') {
219227
// The native BodyInit type is too wide for our use case,
220228
// so we're casting it to a more specific type here.

packages/clerk-js/src/core/resources/SignUp.ts

Lines changed: 11 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -603,55 +603,22 @@ class SignUpFuture implements SignUpFutureResource {
603603
captchaToken,
604604
captchaWidgetType,
605605
captchaError,
606+
...params,
607+
unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined,
606608
};
607609

608-
if (params.firstName) {
609-
body.firstName = params.firstName;
610-
}
611-
612-
if (params.lastName) {
613-
body.lastName = params.lastName;
614-
}
615-
616-
if (params.unsafeMetadata) {
617-
body.unsafeMetadata = normalizeUnsafeMetadata(params.unsafeMetadata);
618-
}
619-
620-
if (typeof params.legalAccepted !== 'undefined') {
621-
body.legalAccepted = params.legalAccepted;
622-
}
623-
624-
await this.resource.__internal_basePost({
625-
path: this.resource.pathRoot,
626-
body,
627-
});
610+
await this.resource.__internal_basePost({ path: this.resource.pathRoot, body });
628611
});
629612
}
630613

631614
async update(params: SignUpFutureUpdateParams): Promise<{ error: unknown }> {
632615
return runAsyncResourceTask(this.resource, async () => {
633-
const body: Record<string, unknown> = {};
634-
635-
if (params.firstName) {
636-
body.firstName = params.firstName;
637-
}
638-
639-
if (params.lastName) {
640-
body.lastName = params.lastName;
641-
}
642-
643-
if (params.unsafeMetadata) {
644-
body.unsafeMetadata = normalizeUnsafeMetadata(params.unsafeMetadata);
645-
}
646-
647-
if (typeof params.legalAccepted !== 'undefined') {
648-
body.legalAccepted = params.legalAccepted;
649-
}
616+
const body: Record<string, unknown> = {
617+
...params,
618+
unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined,
619+
};
650620

651-
await this.resource.__internal_basePatch({
652-
path: this.resource.pathRoot,
653-
body,
654-
});
621+
await this.resource.__internal_basePatch({ path: this.resource.pathRoot, body });
655622
});
656623
}
657624

@@ -661,44 +628,14 @@ class SignUpFuture implements SignUpFutureResource {
661628

662629
const body: Record<string, unknown> = {
663630
strategy: 'password',
664-
password: params.password,
665631
captchaToken,
666632
captchaWidgetType,
667633
captchaError,
634+
...params,
635+
unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined,
668636
};
669637

670-
if (params.phoneNumber) {
671-
body.phoneNumber = params.phoneNumber;
672-
}
673-
674-
if (params.emailAddress) {
675-
body.emailAddress = params.emailAddress;
676-
}
677-
678-
if (params.username) {
679-
body.username = params.username;
680-
}
681-
682-
if (params.firstName) {
683-
body.firstName = params.firstName;
684-
}
685-
686-
if (params.lastName) {
687-
body.lastName = params.lastName;
688-
}
689-
690-
if (params.unsafeMetadata) {
691-
body.unsafeMetadata = normalizeUnsafeMetadata(params.unsafeMetadata);
692-
}
693-
694-
if (typeof params.legalAccepted !== 'undefined') {
695-
body.legalAccepted = params.legalAccepted;
696-
}
697-
698-
await this.resource.__internal_basePost({
699-
path: this.resource.pathRoot,
700-
body,
701-
});
638+
await this.resource.__internal_basePost({ path: this.resource.pathRoot, body });
702639
});
703640
}
704641

0 commit comments

Comments
 (0)