fix(ts-sdk): preserve user metadata keys across the case-conversion round-trip (#5515)
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
import { camelToSnakeKeys, snakeToCamelKeys } from "../utils";
|
||||||
|
|
||||||
|
describe("camelToSnakeKeys / snakeToCamelKeys", () => {
|
||||||
|
it("converts SDK-defined keys between camelCase and snake_case", () => {
|
||||||
|
expect(camelToSnakeKeys({ userId: "u1", agentId: "a1" })).toEqual({
|
||||||
|
user_id: "u1",
|
||||||
|
agent_id: "a1",
|
||||||
|
});
|
||||||
|
expect(snakeToCamelKeys({ user_id: "u1", agent_id: "a1" })).toEqual({
|
||||||
|
userId: "u1",
|
||||||
|
agentId: "a1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves logical operator keys (OR/AND/NOT)", () => {
|
||||||
|
expect(camelToSnakeKeys({ OR: [{ userId: "u1" }] })).toEqual({
|
||||||
|
OR: [{ user_id: "u1" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("user-controlled metadata blob (issue #5055)", () => {
|
||||||
|
it("does not camelize snake_case keys inside metadata on read", () => {
|
||||||
|
const apiResponse = {
|
||||||
|
id: "mem-1",
|
||||||
|
user_id: "u1",
|
||||||
|
metadata: { message_id: "x", some_custom_key: "y" },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(snakeToCamelKeys(apiResponse)).toEqual({
|
||||||
|
id: "mem-1",
|
||||||
|
userId: "u1",
|
||||||
|
metadata: { message_id: "x", some_custom_key: "y" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not snake_case camelCase keys inside metadata on write", () => {
|
||||||
|
const payload = {
|
||||||
|
userId: "u1",
|
||||||
|
metadata: { messageId: "x", someCustomKey: "y" },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(camelToSnakeKeys(payload)).toEqual({
|
||||||
|
user_id: "u1",
|
||||||
|
metadata: { messageId: "x", someCustomKey: "y" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips arbitrary metadata keys losslessly", () => {
|
||||||
|
const metadata = {
|
||||||
|
message_id: "abc",
|
||||||
|
camelKey: 1,
|
||||||
|
nested: { deep_snake: true, deepCamel: false },
|
||||||
|
arr: [{ inner_key: 1 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundTripped = snakeToCamelKeys(
|
||||||
|
camelToSnakeKeys({ userId: "u1", metadata }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(roundTripped.metadata).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves metadata nested inside an array of results", () => {
|
||||||
|
const apiResponse = {
|
||||||
|
results: [
|
||||||
|
{ id: "1", metadata: { message_id: "x" } },
|
||||||
|
{ id: "2", metadata: { another_key: "z" } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(snakeToCamelKeys(apiResponse)).toEqual({
|
||||||
|
results: [
|
||||||
|
{ id: "1", metadata: { message_id: "x" } },
|
||||||
|
{ id: "2", metadata: { another_key: "z" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("user-controlled structuredDataSchema blob (issue #5055)", () => {
|
||||||
|
it("converts the outer key but leaves user field names on write", () => {
|
||||||
|
expect(
|
||||||
|
camelToSnakeKeys({
|
||||||
|
structuredDataSchema: { firstName: "string", lastName: "string" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
// outer SDK key is snake_cased, user-defined field names are not
|
||||||
|
structured_data_schema: { firstName: "string", lastName: "string" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts the outer key but leaves user field names on read", () => {
|
||||||
|
expect(
|
||||||
|
snakeToCamelKeys({
|
||||||
|
structured_data_schema: { first_name: "string", last_name: "string" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
structuredDataSchema: { first_name: "string", last_name: "string" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,9 +14,27 @@ function snakeToCamel(str: string): string {
|
|||||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys whose values are user-controlled, opaque blobs. Their nested keys must
|
||||||
|
* be passed through verbatim — converting them would silently rewrite the
|
||||||
|
* user's own keys and break round-trips (see issue #5055).
|
||||||
|
*
|
||||||
|
* The check runs against the source key, so a multi-word key must be listed in
|
||||||
|
* both casings to be covered in both directions: the camelCase form for the
|
||||||
|
* outbound `camelToSnakeKeys` path and the snake_case form for the inbound
|
||||||
|
* `snakeToCamelKeys` path. `metadata` is spelled identically in both, so one
|
||||||
|
* entry suffices; `structuredDataSchema` needs both.
|
||||||
|
*/
|
||||||
|
const OPAQUE_VALUE_KEYS = new Set([
|
||||||
|
"metadata",
|
||||||
|
"structuredDataSchema",
|
||||||
|
"structured_data_schema",
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively converts all keys of an object from camelCase to snake_case.
|
* Recursively converts all keys of an object from camelCase to snake_case.
|
||||||
* Used for converting user-facing camelCase params to API snake_case payloads.
|
* Used for converting user-facing camelCase params to API snake_case payloads.
|
||||||
|
* Values under {@link OPAQUE_VALUE_KEYS} (e.g. `metadata`) are left untouched.
|
||||||
*/
|
*/
|
||||||
export function camelToSnakeKeys(obj: any): any {
|
export function camelToSnakeKeys(obj: any): any {
|
||||||
if (obj === null || obj === undefined || typeof obj !== "object") return obj;
|
if (obj === null || obj === undefined || typeof obj !== "object") return obj;
|
||||||
@@ -26,7 +44,7 @@ export function camelToSnakeKeys(obj: any): any {
|
|||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(obj).map(([key, value]) => [
|
Object.entries(obj).map(([key, value]) => [
|
||||||
camelToSnake(key),
|
camelToSnake(key),
|
||||||
camelToSnakeKeys(value),
|
OPAQUE_VALUE_KEYS.has(key) ? value : camelToSnakeKeys(value),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,6 +52,7 @@ export function camelToSnakeKeys(obj: any): any {
|
|||||||
/**
|
/**
|
||||||
* Recursively converts all keys of an object from snake_case to camelCase.
|
* Recursively converts all keys of an object from snake_case to camelCase.
|
||||||
* Used for converting API snake_case responses to user-facing camelCase.
|
* Used for converting API snake_case responses to user-facing camelCase.
|
||||||
|
* Values under {@link OPAQUE_VALUE_KEYS} (e.g. `metadata`) are left untouched.
|
||||||
*/
|
*/
|
||||||
export function snakeToCamelKeys(obj: any): any {
|
export function snakeToCamelKeys(obj: any): any {
|
||||||
if (obj === null || obj === undefined || typeof obj !== "object") return obj;
|
if (obj === null || obj === undefined || typeof obj !== "object") return obj;
|
||||||
@@ -43,7 +62,7 @@ export function snakeToCamelKeys(obj: any): any {
|
|||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(obj).map(([key, value]) => [
|
Object.entries(obj).map(([key, value]) => [
|
||||||
snakeToCamel(key),
|
snakeToCamel(key),
|
||||||
snakeToCamelKeys(value),
|
OPAQUE_VALUE_KEYS.has(key) ? value : snakeToCamelKeys(value),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user