mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-21 13:06:01 +08:00
refactor: rewrite sanitize to support nested objects (@fehmer) (#6875)
This commit is contained in:
parent
e6dc6d16c8
commit
8fe0e65045
6 changed files with 475 additions and 214 deletions
|
@ -1,10 +1,4 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
deepClone,
|
||||
getErrorMessage,
|
||||
isObject,
|
||||
sanitize,
|
||||
} from "../../src/ts/utils/misc";
|
||||
import { deepClone, getErrorMessage, isObject } from "../../src/ts/utils/misc";
|
||||
import {
|
||||
getLanguageDisplayString,
|
||||
removeLanguageSize,
|
||||
|
@ -233,127 +227,4 @@ describe("misc.ts", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
describe("sanitize function", () => {
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
age: z.number().positive(),
|
||||
tags: z.array(z.string()),
|
||||
enumArray: z.array(z.enum(["one", "two"])).min(2),
|
||||
})
|
||||
.partial()
|
||||
.strip();
|
||||
|
||||
it("should return the same object if it is valid", () => {
|
||||
const obj = { name: "Alice", age: 30, tags: ["developer", "coder"] };
|
||||
expect(sanitize(schema, obj)).toEqual(obj);
|
||||
});
|
||||
|
||||
it("should remove properties with invalid values", () => {
|
||||
const obj = { name: "Alice", age: -5, tags: ["developer", "coder"] };
|
||||
expect(sanitize(schema, obj)).toEqual({
|
||||
name: "Alice",
|
||||
tags: ["developer", "coder"],
|
||||
age: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove invalid array elements", () => {
|
||||
const obj = {
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
tags: ["developer", 123, "coder"] as any,
|
||||
};
|
||||
expect(sanitize(schema, obj)).toEqual({
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
tags: ["developer", "coder"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove invalid array elements with min size", () => {
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
tags: z.array(z.enum(["coder", "developer"])).min(2),
|
||||
})
|
||||
.partial();
|
||||
const obj = {
|
||||
name: "Alice",
|
||||
tags: ["developer", "unknown"] as any,
|
||||
};
|
||||
expect(sanitize(schema, obj)).toEqual({
|
||||
name: "Alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove entire property if all array elements are invalid", () => {
|
||||
const obj = { name: "Alice", age: 30, tags: [123, 456] as any };
|
||||
const sanitized = sanitize(schema, obj);
|
||||
expect(sanitized).toEqual({
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
});
|
||||
expect(sanitized).not.toHaveProperty("tags");
|
||||
});
|
||||
|
||||
it("should remove object properties if they are invalid", () => {
|
||||
const obj = { name: 123 as any, age: 30, tags: ["developer", "coder"] };
|
||||
const sanitized = sanitize(schema, obj);
|
||||
expect(sanitized).toEqual({
|
||||
age: 30,
|
||||
tags: ["developer", "coder"],
|
||||
});
|
||||
expect(sanitized).not.toHaveProperty("name");
|
||||
});
|
||||
|
||||
it("should remove nested objects if not valid", () => {
|
||||
//GIVEN
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
info: z.object({ age: z.number() }).partial(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
const obj = {
|
||||
name: "Alice",
|
||||
info: { age: "42" as any },
|
||||
};
|
||||
//WHEN / THEN
|
||||
expect(sanitize(schema, obj)).toEqual({
|
||||
name: "Alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("should strip extra keys", () => {
|
||||
const obj = {
|
||||
name: "bob",
|
||||
age: 30,
|
||||
tags: ["developer", "coder"],
|
||||
powerLevel: 9001,
|
||||
} as any;
|
||||
const stripped = sanitize(schema.strip(), obj);
|
||||
expect(stripped).not.toHaveProperty("powerLevel");
|
||||
});
|
||||
it("should strip extra keys on error", () => {
|
||||
const obj = {
|
||||
name: "bob",
|
||||
age: 30,
|
||||
powerLevel: 9001,
|
||||
} as any;
|
||||
const stripped = sanitize(schema.strip(), obj);
|
||||
expect(stripped).not.toHaveProperty("powerLevel");
|
||||
});
|
||||
it("should provide a readable error message", () => {
|
||||
const obj = {
|
||||
arrayOneTwo: ["one", "nonexistent"],
|
||||
} as any;
|
||||
expect(() => {
|
||||
sanitize(schema.required().strip(), obj);
|
||||
}).toThrowError(
|
||||
"unable to sanitize: name: Required, age: Required, tags: Required, enumArray: Required"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
372
frontend/__tests__/utils/sanitize.spec.ts
Normal file
372
frontend/__tests__/utils/sanitize.spec.ts
Normal file
|
@ -0,0 +1,372 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { sanitize } from "../../src/ts/utils/sanitize";
|
||||
|
||||
describe("sanitize function", () => {
|
||||
describe("arrays", () => {
|
||||
const numberArraySchema = z.array(z.number());
|
||||
const numbersArrayMin2Schema = numberArraySchema.min(2);
|
||||
|
||||
const testCases: {
|
||||
input: number[];
|
||||
expected: {
|
||||
numbers: number[] | boolean;
|
||||
numbersMin: number[] | boolean;
|
||||
};
|
||||
}[] = [
|
||||
{ input: [], expected: { numbers: true, numbersMin: false } },
|
||||
{ input: [1, 2], expected: { numbers: true, numbersMin: true } },
|
||||
{
|
||||
input: [1, "2" as any],
|
||||
expected: { numbers: [1], numbersMin: false },
|
||||
},
|
||||
{
|
||||
input: ["one", "two"] as any,
|
||||
expected: { numbers: [], numbersMin: false },
|
||||
},
|
||||
];
|
||||
it.for(testCases)("number array with $input", ({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.numbers === false
|
||||
? () => sanitize(numberArraySchema, input)
|
||||
: sanitize(numberArraySchema, input)
|
||||
);
|
||||
|
||||
if (expected.numbers === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.numbers === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.numbers);
|
||||
}
|
||||
});
|
||||
it.for(testCases)(
|
||||
"number array.min(2) with $input",
|
||||
({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.numbersMin === false
|
||||
? () => sanitize(numbersArrayMin2Schema, input)
|
||||
: sanitize(numbersArrayMin2Schema, input)
|
||||
);
|
||||
|
||||
if (expected.numbersMin === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.numbersMin === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.numbersMin);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
describe("objects", () => {
|
||||
const objectSchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number().positive(),
|
||||
tags: z.array(z.string()),
|
||||
enumArray: z.array(z.enum(["one", "two"])).min(2),
|
||||
});
|
||||
const objectSchemaFullPartial = objectSchema.partial().strip();
|
||||
const objectSchemaWithOptional = objectSchema.partial({
|
||||
tags: true,
|
||||
enumArray: true,
|
||||
});
|
||||
|
||||
const testCases: {
|
||||
input: z.infer<typeof objectSchemaFullPartial>;
|
||||
expected: {
|
||||
mandatory: z.infer<typeof objectSchema> | boolean;
|
||||
partial: z.infer<typeof objectSchemaFullPartial> | boolean;
|
||||
optional: z.infer<typeof objectSchemaWithOptional> | boolean;
|
||||
};
|
||||
}[] = [
|
||||
{
|
||||
input: {},
|
||||
expected: { mandatory: false, partial: true, optional: false },
|
||||
},
|
||||
{
|
||||
input: {
|
||||
name: "Alice",
|
||||
age: 23,
|
||||
tags: ["one", "two"],
|
||||
enumArray: ["one", "two"],
|
||||
},
|
||||
expected: { mandatory: true, partial: true, optional: true },
|
||||
},
|
||||
{
|
||||
input: {
|
||||
name: "Alice",
|
||||
age: 23,
|
||||
},
|
||||
expected: { mandatory: false, partial: true, optional: true },
|
||||
},
|
||||
{
|
||||
input: {
|
||||
name: "Alice",
|
||||
age: "sixty" as any,
|
||||
},
|
||||
expected: {
|
||||
mandatory: false,
|
||||
partial: { name: "Alice" },
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
name: "Alice",
|
||||
age: 23,
|
||||
tags: ["one", 2 as any],
|
||||
enumArray: "one" as any,
|
||||
},
|
||||
expected: {
|
||||
mandatory: false,
|
||||
partial: { name: "Alice", age: 23, tags: ["one"] },
|
||||
optional: { name: "Alice", age: 23, tags: ["one"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
name: "Alice",
|
||||
age: 23,
|
||||
tags: [1, 2] as any,
|
||||
enumArray: [1, 2] as any,
|
||||
},
|
||||
expected: {
|
||||
mandatory: false,
|
||||
partial: { name: "Alice", age: 23 },
|
||||
optional: { name: "Alice", age: 23 },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
name: "Alice",
|
||||
age: 23,
|
||||
extraArray: [],
|
||||
extraObject: {},
|
||||
extraString: "",
|
||||
} as any,
|
||||
expected: {
|
||||
mandatory: false,
|
||||
partial: { name: "Alice", age: 23 },
|
||||
optional: { name: "Alice", age: 23 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.for(testCases)("object mandatory with $input", ({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.mandatory === false
|
||||
? () => sanitize(objectSchema, input as any)
|
||||
: sanitize(objectSchema, input as any)
|
||||
);
|
||||
|
||||
if (expected.mandatory === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.mandatory === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.mandatory);
|
||||
}
|
||||
});
|
||||
it.for(testCases)(
|
||||
"object full partial with $input",
|
||||
({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.partial === false
|
||||
? () => sanitize(objectSchemaFullPartial, input as any)
|
||||
: sanitize(objectSchemaFullPartial, input as any)
|
||||
);
|
||||
|
||||
if (expected.partial === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.partial === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.partial);
|
||||
}
|
||||
}
|
||||
);
|
||||
it.for(testCases)("object optional with $input", ({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.optional === false
|
||||
? () => sanitize(objectSchemaWithOptional, input as any)
|
||||
: sanitize(objectSchemaWithOptional, input as any)
|
||||
);
|
||||
|
||||
if (expected.optional === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.optional === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.optional);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested", () => {
|
||||
const itemSchema = z.object({
|
||||
name: z.string(),
|
||||
enumArray: z.array(z.enum(["one", "two"])).min(2),
|
||||
});
|
||||
const nestedSchema = z.object({
|
||||
nested: z.array(itemSchema),
|
||||
});
|
||||
|
||||
const nestedSchemaFullPartial = z
|
||||
.object({
|
||||
nested: z.array(itemSchema.partial()),
|
||||
})
|
||||
.partial();
|
||||
const nestedSchemaWithMin2Array = z.object({
|
||||
nested: z.array(itemSchema).min(2),
|
||||
});
|
||||
|
||||
const testCases: {
|
||||
input: z.infer<typeof nestedSchema>;
|
||||
expected: {
|
||||
mandatory: z.infer<typeof nestedSchema> | boolean;
|
||||
partial: z.infer<typeof nestedSchemaFullPartial> | boolean;
|
||||
minArray: z.infer<typeof nestedSchemaWithMin2Array> | boolean;
|
||||
};
|
||||
}[] = [
|
||||
{
|
||||
input: {} as any,
|
||||
expected: {
|
||||
mandatory: false,
|
||||
partial: true,
|
||||
minArray: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
nested: [
|
||||
{ name: "Alice", enumArray: ["one", "two"] },
|
||||
{ name: "Bob", enumArray: ["one", "two"] },
|
||||
],
|
||||
},
|
||||
expected: {
|
||||
mandatory: true,
|
||||
partial: true,
|
||||
minArray: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
nested: [
|
||||
{ name: "Alice", enumArray: ["one", "two"] },
|
||||
{ name: "Bob" } as any,
|
||||
],
|
||||
},
|
||||
expected: {
|
||||
mandatory: {
|
||||
nested: [{ name: "Alice", enumArray: ["one", "two"] }],
|
||||
},
|
||||
partial: true,
|
||||
minArray: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
nested: [
|
||||
{ enumArray: ["one", "two"] } as any,
|
||||
{ name: "Bob" } as any,
|
||||
],
|
||||
},
|
||||
expected: {
|
||||
mandatory: false,
|
||||
partial: true,
|
||||
minArray: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.for(testCases)("nested mandatory with $input", ({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.mandatory === false
|
||||
? () => sanitize(nestedSchema, input as any)
|
||||
: sanitize(nestedSchema, input as any)
|
||||
);
|
||||
|
||||
if (expected.mandatory === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.mandatory === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.mandatory);
|
||||
}
|
||||
});
|
||||
it.for(testCases)("nested partial with $input", ({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.partial === false
|
||||
? () => sanitize(nestedSchemaFullPartial, input as any)
|
||||
: sanitize(nestedSchemaFullPartial, input as any)
|
||||
);
|
||||
|
||||
if (expected.partial === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.partial === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.partial);
|
||||
}
|
||||
});
|
||||
it.for(testCases)(
|
||||
"nested array min(2) with $input",
|
||||
({ input, expected }) => {
|
||||
const sanitized = expect(
|
||||
expected.minArray === false
|
||||
? () => sanitize(nestedSchemaWithMin2Array, input as any)
|
||||
: sanitize(nestedSchemaWithMin2Array, input as any)
|
||||
);
|
||||
|
||||
if (expected.minArray === false) {
|
||||
sanitized.toThrowError();
|
||||
} else if (expected.minArray === true) {
|
||||
sanitized.toStrictEqual(input);
|
||||
} else {
|
||||
sanitized.toStrictEqual(expected.minArray);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
age: z.number().positive(),
|
||||
tags: z.array(z.string()),
|
||||
enumArray: z.array(z.enum(["one", "two"])).min(2),
|
||||
})
|
||||
.partial()
|
||||
.strip();
|
||||
|
||||
it("should strip extra keys", () => {
|
||||
const obj = {
|
||||
name: "bob",
|
||||
age: 30,
|
||||
tags: ["developer", "coder"],
|
||||
powerLevel: 9001,
|
||||
} as any;
|
||||
const stripped = sanitize(schema.strip(), obj);
|
||||
expect(stripped).not.toHaveProperty("powerLevel");
|
||||
});
|
||||
it("should strip extra keys on error", () => {
|
||||
const obj = {
|
||||
name: "bob",
|
||||
age: 30,
|
||||
powerLevel: 9001,
|
||||
} as any;
|
||||
const stripped = sanitize(schema.strip(), obj);
|
||||
expect(stripped).not.toHaveProperty("powerLevel");
|
||||
});
|
||||
it("should provide a readable error message", () => {
|
||||
const obj = {
|
||||
arrayOneTwo: ["one", "nonexistent"],
|
||||
} as any;
|
||||
expect(() => {
|
||||
sanitize(schema.required().strip(), obj);
|
||||
}).toThrowError(
|
||||
"unable to sanitize: name: Required, age: Required, tags: Required, enumArray: Required"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -18,6 +18,7 @@ import defaultResultFilters from "../../constants/default-result-filters";
|
|||
import { getAllFunboxes } from "@monkeytype/funbox";
|
||||
import { Snapshot, SnapshotUserTag } from "../../constants/default-snapshot";
|
||||
import { LanguageList } from "../../constants/languages";
|
||||
import { sanitize } from "../../utils/sanitize";
|
||||
|
||||
export function mergeWithDefaultFilters(
|
||||
filters: Partial<ResultFilters>
|
||||
|
@ -56,10 +57,7 @@ const resultFiltersLS = new LocalStorageWithSchema({
|
|||
return defaultResultFilters;
|
||||
}
|
||||
return mergeWithDefaultFilters(
|
||||
Misc.sanitize(
|
||||
ResultFiltersSchema.partial().strip(),
|
||||
unknown as ResultFilters
|
||||
)
|
||||
sanitize(ResultFiltersSchema.partial().strip(), unknown as ResultFilters)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -930,10 +928,7 @@ $(".group.presetFilterButtons .filterBtns").on(
|
|||
|
||||
function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters {
|
||||
const filter = mergeWithDefaultFilters(
|
||||
Misc.sanitize(
|
||||
ResultFiltersSchema.partial().strip(),
|
||||
Misc.deepClone(filterIn)
|
||||
)
|
||||
sanitize(ResultFiltersSchema.partial().strip(), Misc.deepClone(filterIn))
|
||||
);
|
||||
|
||||
return filter;
|
||||
|
|
|
@ -4,7 +4,8 @@ import {
|
|||
PartialConfig,
|
||||
FunboxName,
|
||||
} from "@monkeytype/schemas/configs";
|
||||
import { sanitize, typedKeys } from "./misc";
|
||||
import { typedKeys } from "./misc";
|
||||
import { sanitize } from "./sanitize";
|
||||
import * as ConfigSchemas from "@monkeytype/schemas/configs";
|
||||
import { getDefaultConfig } from "../constants/default-config";
|
||||
/**
|
||||
|
|
|
@ -4,7 +4,6 @@ import { lastElementFromArray } from "./arrays";
|
|||
import { Config } from "@monkeytype/schemas/configs";
|
||||
import { Mode, Mode2, PersonalBests } from "@monkeytype/schemas/shared";
|
||||
import { Result } from "@monkeytype/schemas/results";
|
||||
import { z } from "zod";
|
||||
|
||||
export function whorf(speed: number, wordlen: number): number {
|
||||
return Math.min(
|
||||
|
@ -710,80 +709,6 @@ export function debounceUntilResolved<TArgs extends unknown[], TResult>(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize object. Remove invalid values based on the schema.
|
||||
* @param schema zod schema
|
||||
* @param obj object
|
||||
* @returns sanitized object
|
||||
*/
|
||||
export function sanitize<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
obj: z.infer<T>
|
||||
): z.infer<T> {
|
||||
const validate = schema.safeParse(obj);
|
||||
|
||||
if (validate.success) {
|
||||
//use the parsed data, not the obj. keys might been removed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return validate.data;
|
||||
}
|
||||
|
||||
const errors: Map<string, number[] | undefined> = new Map();
|
||||
for (const error of validate.error.errors) {
|
||||
const element = error.path[0] as string;
|
||||
let val = errors.get(element);
|
||||
if (typeof error.path[1] === "number") {
|
||||
val = [...(val ?? []), error.path[1]];
|
||||
}
|
||||
errors.set(element, val);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
const cleanedObject = Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.map(([key, value]) => {
|
||||
if (!errors.has(key)) {
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
const error = errors.get(key);
|
||||
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
error !== undefined && //error is not on the array itself
|
||||
error.length < value.length //not all items in the array are invalid
|
||||
) {
|
||||
//some items of the array are invalid
|
||||
const cleanedArray = value.filter(
|
||||
(_element, index) => !error.includes(index)
|
||||
);
|
||||
const cleanedArrayValidation = schema.safeParse(
|
||||
Object.fromEntries([[key, cleanedArray]])
|
||||
);
|
||||
if (cleanedArrayValidation.success) {
|
||||
return [key, cleanedArray];
|
||||
} else {
|
||||
return [key, undefined];
|
||||
}
|
||||
} else {
|
||||
return [key, undefined];
|
||||
}
|
||||
})
|
||||
.filter((it) => it[1] !== undefined)
|
||||
) as z.infer<T>;
|
||||
|
||||
const cleanValidate = schema.safeParse(cleanedObject);
|
||||
if (cleanValidate.success) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return cleanValidate.data;
|
||||
}
|
||||
|
||||
const errorsString = cleanValidate.error.errors
|
||||
.map((e) => e.path.join(".") + ": " + e.message)
|
||||
.join(", ");
|
||||
throw new Error("unable to sanitize: " + errorsString);
|
||||
}
|
||||
|
||||
export function triggerResize(): void {
|
||||
$(window).trigger("resize");
|
||||
}
|
||||
|
|
97
frontend/src/ts/utils/sanitize.ts
Normal file
97
frontend/src/ts/utils/sanitize.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { z } from "zod";
|
||||
import { deepClone } from "./misc";
|
||||
|
||||
function removeProblems<T extends object | unknown[]>(
|
||||
obj: T,
|
||||
problems: (number | string)[]
|
||||
): T | undefined {
|
||||
if (Array.isArray(obj)) {
|
||||
if (problems.length === obj.length) return undefined;
|
||||
|
||||
return obj.filter((_, index) => !problems.includes(index)) as T;
|
||||
} else {
|
||||
const entries = Object.entries(obj);
|
||||
if (problems.length === entries.length) return undefined;
|
||||
|
||||
return Object.fromEntries(
|
||||
entries.filter(([key]) => !problems.includes(key))
|
||||
) as T;
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(obj: [] | object, path: string[]): [] | object {
|
||||
//@ts-expect-error can be array or object
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return path.slice(0, -1).reduce((acc, it: string) => acc[it], obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize object. Remove invalid values based on the schema.
|
||||
* @param schema zod schema
|
||||
* @param obj object
|
||||
* @returns sanitized object
|
||||
*/
|
||||
export function sanitize<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
obj: z.infer<T>
|
||||
): z.infer<T> {
|
||||
const maxAttempts = 2;
|
||||
let result;
|
||||
let current = deepClone(obj);
|
||||
|
||||
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
|
||||
result = schema.safeParse(current);
|
||||
|
||||
if (result.success) {
|
||||
//use the parsed data, not the obj. keys might been removed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return result.data as z.infer<T>;
|
||||
}
|
||||
if (attempt === maxAttempts) {
|
||||
//exit loop and throw error
|
||||
break;
|
||||
}
|
||||
const pathsWithProblems = result.error.errors.reduce((acc, { path }) => {
|
||||
const parent = path.slice(0, -1).join(".");
|
||||
const element = path.at(-1);
|
||||
|
||||
if (element !== undefined) {
|
||||
acc.set(parent, [...(acc.get(parent) ?? []), element]);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<string, Array<string | number>>()) as Map<
|
||||
string, //parent path
|
||||
string[] | number[] //childs with problems
|
||||
>;
|
||||
|
||||
for (const [pathString, problems] of pathsWithProblems.entries()) {
|
||||
if (pathString === "") {
|
||||
current =
|
||||
removeProblems(current, problems) ??
|
||||
(Array.isArray(current) ? [] : {});
|
||||
} else {
|
||||
const path = pathString.split(".");
|
||||
const parent = getNestedValue(current, path);
|
||||
const key = path.at(-1) as string;
|
||||
|
||||
//@ts-expect-error can be object or array
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const cleaned = removeProblems(parent[key], problems);
|
||||
|
||||
if (cleaned === undefined) {
|
||||
//@ts-expect-error can be object or array
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete parent[key];
|
||||
} else {
|
||||
//@ts-expect-error can be object or array
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
parent[key] = cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const errorsString = result?.error.errors
|
||||
.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
.join(", ");
|
||||
throw new Error("unable to sanitize: " + errorsString);
|
||||
}
|
Loading…
Add table
Reference in a new issue