Skip to content

Commit 232d7d3

Browse files
fix(backend): Ability to convert bodyParam keys recursively (#6418)
Co-authored-by: Jared Piedt <[email protected]>
1 parent f1d9d34 commit 232d7d3

File tree

4 files changed

+180
-37
lines changed

4 files changed

+180
-37
lines changed

.changeset/silver-singers-train.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Fix SAML Connection `attributeMapping` keys not being converted from camelCase to snake_case.

packages/backend/src/api/__tests__/SamlConnectionApi.test.ts

Lines changed: 138 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,36 @@ describe('SamlConnectionAPI', () => {
1010
secretKey: 'deadbeef',
1111
});
1212

13+
const mockSamlConnectionResponse = {
14+
object: 'saml_connection',
15+
id: 'samlc_123',
16+
name: 'Test Connection',
17+
provider: 'saml_custom',
18+
domain: 'test.example.com',
19+
organization_id: 'org_123',
20+
created_at: 1672531200000,
21+
updated_at: 1672531200000,
22+
active: true,
23+
sync_user_attributes: false,
24+
allow_subdomains: false,
25+
allow_idp_initiated: false,
26+
idp_entity_id: 'entity_123',
27+
idp_sso_url: 'https://idp.example.com/sso',
28+
idp_certificate: 'cert_data',
29+
idp_metadata_url: null,
30+
idp_metadata: null,
31+
attribute_mapping: {
32+
user_id: 'userId',
33+
email_address: 'email',
34+
first_name: 'firstName',
35+
last_name: 'lastName',
36+
},
37+
};
38+
1339
describe('getSamlConnectionList', () => {
1440
it('successfully fetches SAML connections with all parameters', async () => {
1541
const mockSamlConnectionsResponse = {
16-
data: [
17-
{
18-
object: 'saml_connection',
19-
id: 'samlc_123',
20-
name: 'Test Connection',
21-
provider: 'saml_custom',
22-
domain: 'test.example.com',
23-
organization_id: 'org_123',
24-
created_at: 1672531200000,
25-
updated_at: 1672531200000,
26-
active: true,
27-
sync_user_attributes: false,
28-
allow_subdomains: false,
29-
allow_idp_initiated: false,
30-
idp_entity_id: 'entity_123',
31-
idp_sso_url: 'https://idp.example.com/sso',
32-
idp_certificate: 'cert_data',
33-
idp_metadata_url: null,
34-
idp_metadata: null,
35-
attribute_mapping: {
36-
user_id: 'userId',
37-
email_address: 'email',
38-
first_name: 'firstName',
39-
last_name: 'lastName',
40-
},
41-
},
42-
],
42+
data: [mockSamlConnectionResponse],
4343
total_count: 1,
4444
};
4545

@@ -73,4 +73,115 @@ describe('SamlConnectionAPI', () => {
7373
expect(response.totalCount).toBe(1);
7474
});
7575
});
76+
77+
describe('createSamlConnection', () => {
78+
it('successfully creates a SAML connection', async () => {
79+
server.use(
80+
http.post(
81+
'https://api.clerk.test/v1/saml_connections',
82+
validateHeaders(async ({ request }) => {
83+
const body = await request.json();
84+
85+
expect(body).toEqual({
86+
name: 'Test Connection',
87+
provider: 'saml_custom',
88+
domain: 'test.example.com',
89+
attribute_mapping: {
90+
user_id: 'userId',
91+
email_address: 'email',
92+
first_name: 'firstName',
93+
last_name: 'lastName',
94+
},
95+
});
96+
97+
return HttpResponse.json(mockSamlConnectionResponse);
98+
}),
99+
),
100+
);
101+
102+
const response = await apiClient.samlConnections.createSamlConnection({
103+
name: 'Test Connection',
104+
provider: 'saml_custom',
105+
domain: 'test.example.com',
106+
attributeMapping: {
107+
userId: 'userId',
108+
emailAddress: 'email',
109+
firstName: 'firstName',
110+
lastName: 'lastName',
111+
},
112+
});
113+
114+
expect(response.id).toBe('samlc_123');
115+
expect(response.name).toBe('Test Connection');
116+
expect(response.organizationId).toBe('org_123');
117+
});
118+
});
119+
120+
describe('updateSamlConnection', () => {
121+
it('successfully updates a SAML connection', async () => {
122+
server.use(
123+
http.patch(
124+
'https://api.clerk.test/v1/saml_connections/samlc_123',
125+
validateHeaders(async ({ request }) => {
126+
const body = await request.json();
127+
128+
expect(body).toEqual({
129+
name: 'Test Connection',
130+
provider: 'saml_custom',
131+
domain: 'test.example.com',
132+
organization_id: 'org_123',
133+
idp_entity_id: 'entity_123',
134+
idp_sso_url: 'https://idp.example.com/sso',
135+
idp_certificate: 'cert_data',
136+
attribute_mapping: {
137+
user_id: 'userId2',
138+
email_address: 'email2',
139+
first_name: 'firstName2',
140+
last_name: 'lastName2',
141+
},
142+
});
143+
144+
return HttpResponse.json({
145+
...mockSamlConnectionResponse,
146+
idp_entity_id: 'entity_123',
147+
idp_sso_url: 'https://idp.example.com/sso',
148+
idp_certificate: 'cert_data',
149+
attribute_mapping: {
150+
user_id: 'userId2',
151+
email_address: 'email2',
152+
first_name: 'firstName2',
153+
last_name: 'lastName2',
154+
},
155+
});
156+
}),
157+
),
158+
);
159+
160+
const response = await apiClient.samlConnections.updateSamlConnection('samlc_123', {
161+
name: 'Test Connection',
162+
provider: 'saml_custom',
163+
domain: 'test.example.com',
164+
organizationId: 'org_123',
165+
idpEntityId: 'entity_123',
166+
idpSsoUrl: 'https://idp.example.com/sso',
167+
idpCertificate: 'cert_data',
168+
attributeMapping: {
169+
userId: 'userId2',
170+
emailAddress: 'email2',
171+
firstName: 'firstName2',
172+
lastName: 'lastName2',
173+
},
174+
});
175+
176+
expect(response.id).toBe('samlc_123');
177+
expect(response.name).toBe('Test Connection');
178+
expect(response.organizationId).toBe('org_123');
179+
expect(response.attributeMapping).toEqual({
180+
userId: 'userId2',
181+
emailAddress: 'email2',
182+
firstName: 'firstName2',
183+
lastName: 'lastName2',
184+
});
185+
});
186+
});
76187
});

packages/backend/src/api/endpoints/SamlConnectionApi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export class SamlConnectionAPI extends AbstractAPI {
8282
method: 'POST',
8383
path: basePath,
8484
bodyParams: params,
85+
options: {
86+
deepSnakecaseBodyParamKeys: true,
87+
},
8588
});
8689
}
8790

@@ -100,6 +103,9 @@ export class SamlConnectionAPI extends AbstractAPI {
100103
method: 'PATCH',
101104
path: joinPaths(basePath, samlConnectionId),
102105
bodyParams: params,
106+
options: {
107+
deepSnakecaseBodyParamKeys: true,
108+
},
103109
});
104110
}
105111
public async deleteSamlConnection(samlConnectionId: string) {

packages/backend/src/api/request.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,41 @@ import { assertValidSecretKey } from '../util/optionsAssertions';
88
import { joinPaths } from '../util/path';
99
import { deserialize } from './resources/Deserializer';
1010

11-
export type ClerkBackendApiRequestOptions = {
12-
method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT';
13-
queryParams?: Record<string, unknown>;
14-
headerParams?: Record<string, string>;
15-
bodyParams?: Record<string, unknown> | Array<Record<string, unknown>>;
16-
formData?: FormData;
17-
} & (
11+
type ClerkBackendApiRequestOptionsUrlOrPath =
1812
| {
1913
url: string;
2014
path?: string;
2115
}
2216
| {
2317
url?: string;
2418
path: string;
19+
};
20+
21+
type ClerkBackendApiRequestOptionsBodyParams =
22+
| {
23+
bodyParams: Record<string, unknown> | Array<Record<string, unknown>>;
24+
options?: {
25+
/**
26+
* If true, snakecases the keys of the bodyParams object recursively.
27+
* @default false
28+
*/
29+
deepSnakecaseBodyParamKeys?: boolean;
30+
};
2531
}
26-
);
32+
| {
33+
bodyParams?: never;
34+
options?: {
35+
deepSnakecaseBodyParamKeys?: never;
36+
};
37+
};
38+
39+
export type ClerkBackendApiRequestOptions = {
40+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT';
41+
queryParams?: Record<string, unknown>;
42+
headerParams?: Record<string, string>;
43+
formData?: FormData;
44+
} & ClerkBackendApiRequestOptionsUrlOrPath &
45+
ClerkBackendApiRequestOptionsBodyParams;
2746

2847
export type ClerkBackendApiResponse<T> =
2948
| {
@@ -78,7 +97,8 @@ export function buildRequest(options: BuildRequestOptions) {
7897
userAgent = USER_AGENT,
7998
skipApiVersionInUrl = false,
8099
} = options;
81-
const { path, method, queryParams, headerParams, bodyParams, formData } = requestOptions;
100+
const { path, method, queryParams, headerParams, bodyParams, formData, options: opts } = requestOptions;
101+
const { deepSnakecaseBodyParamKeys = false } = opts || {};
82102

83103
if (requireSecretKey) {
84104
assertValidSecretKey(secretKey);
@@ -130,7 +150,8 @@ export function buildRequest(options: BuildRequestOptions) {
130150
return null;
131151
}
132152

133-
const formatKeys = (object: Parameters<typeof snakecaseKeys>[0]) => snakecaseKeys(object, { deep: false });
153+
const formatKeys = (object: Parameters<typeof snakecaseKeys>[0]) =>
154+
snakecaseKeys(object, { deep: deepSnakecaseBodyParamKeys });
134155

135156
return {
136157
body: JSON.stringify(Array.isArray(bodyParams) ? bodyParams.map(formatKeys) : formatKeys(bodyParams)),

0 commit comments

Comments
 (0)