diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index c7ccd45..b484be8 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Perf } from "../../src/core/utils"; -import * as reqres from "../../src/core/utils/reqres"; -import * as strings from "../../src/core/utils/strings"; +import * as utils from "../../src/core/utils"; async function wait(ms: number) { return new Promise((resolve) => { @@ -13,7 +12,7 @@ describe("Core Utils", async () => { describe("[core] strings", async () => { test("objectToKeyValueArray", async () => { const obj = { a: 1, b: 2, c: 3 }; - const result = strings.objectToKeyValueArray(obj); + const result = utils.objectToKeyValueArray(obj); expect(result).toEqual([ { key: "a", value: 1 }, { key: "b", value: 2 }, @@ -22,24 +21,24 @@ describe("Core Utils", async () => { }); test("snakeToPascalWithSpaces", async () => { - const result = strings.snakeToPascalWithSpaces("snake_to_pascal"); + const result = utils.snakeToPascalWithSpaces("snake_to_pascal"); expect(result).toBe("Snake To Pascal"); }); test("randomString", async () => { - const result = strings.randomString(10); + const result = utils.randomString(10); expect(result).toHaveLength(10); }); test("pascalToKebab", async () => { - const result = strings.pascalToKebab("PascalCase"); + const result = utils.pascalToKebab("PascalCase"); expect(result).toBe("pascal-case"); }); test("replaceSimplePlaceholders", async () => { const str = "Hello, {$name}!"; const vars = { name: "John" }; - const result = strings.replaceSimplePlaceholders(str, vars); + const result = utils.replaceSimplePlaceholders(str, vars); expect(result).toBe("Hello, John!"); }); }); @@ -49,7 +48,7 @@ describe("Core Utils", async () => { const headers = new Headers(); headers.append("Content-Type", "application/json"); headers.append("Authorization", "Bearer 123"); - const obj = reqres.headersToObject(headers); + const obj = utils.headersToObject(headers); expect(obj).toEqual({ "content-type": "application/json", authorization: "Bearer 123" @@ -59,21 +58,21 @@ describe("Core Utils", async () => { test("replaceUrlParam", () => { const url = "/api/:id/:name"; const params = { id: "123", name: "test" }; - const result = reqres.replaceUrlParam(url, params); + const result = utils.replaceUrlParam(url, params); expect(result).toBe("/api/123/test"); }); test("encode", () => { const obj = { id: "123", name: "test" }; - const result = reqres.encodeSearch(obj); + const result = utils.encodeSearch(obj); expect(result).toBe("id=123&name=test"); const obj2 = { id: "123", name: ["test1", "test2"] }; - const result2 = reqres.encodeSearch(obj2); + const result2 = utils.encodeSearch(obj2); expect(result2).toBe("id=123&name=test1&name=test2"); const obj3 = { id: "123", name: { test: "test" } }; - const result3 = reqres.encodeSearch(obj3, { encode: true }); + const result3 = utils.encodeSearch(obj3, { encode: true }); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); }); }); @@ -108,4 +107,91 @@ describe("Core Utils", async () => { expect(count).toBe(2); }); }); + + describe("objects", () => { + test("omitKeys", () => { + const objects = [ + [{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }], + [{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }], + [{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }], + [{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }], + [{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}] + ] as [object, string[], object][]; + + for (const [obj, keys, expected] of objects) { + const result = utils.omitKeys(obj, keys as any); + expect(result).toEqual(expected); + } + }); + + test("isEqual", () => { + const objects = [ + [1, 1, true], + [1, "1", false], + [1, 2, false], + ["1", "1", true], + ["1", "2", false], + [true, true, true], + [true, false, false], + [false, false, true], + [1, NaN, false], + [NaN, NaN, true], + [null, null, true], + [null, undefined, false], + [undefined, undefined, true], + [new Map([["a", 1]]), new Map([["a", 1]]), true], + [new Map([["a", 1]]), new Map([["a", 2]]), false], + [new Map([["a", 1]]), new Map([["b", 1]]), false], + [ + new Map([["a", 1]]), + new Map([ + ["a", 1], + ["b", 2] + ]), + false + ], + [{ a: 1 }, { a: 1 }, true], + [{ a: 1 }, { a: 2 }, false], + [{ a: 1 }, { b: 1 }, false], + [{ a: "1" }, { a: "1" }, true], + [{ a: "1" }, { a: "2" }, false], + [{ a: "1" }, { b: "1" }, false], + [{ a: 1 }, { a: 1, b: 2 }, false], + [{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true], + [{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false], + [{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false], + [{ a: { b: 1 } }, { a: { b: 1 } }, true], + [{ a: { b: 1 } }, { a: { b: 2 } }, false], + [{ a: { b: 1 } }, { a: { c: 1 } }, false], + [{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false], + [[1, 2, 3], [1, 2, 3], true], + [[1, 2, 3], [1, 2, 4], false], + [[1, 2, 3], [1, 2, 3, 4], false], + [[{ a: 1 }], [{ a: 1 }], true], + [[{ a: 1 }], [{ a: 2 }], false], + [[{ a: 1 }], [{ b: 1 }], false] + ] as [any, any, boolean][]; + + for (const [a, b, expected] of objects) { + const result = utils.isEqual(a, b); + expect(result).toEqual(expected); + } + }); + + test("getPath", () => { + const tests = [ + [{ a: 1, b: 2, c: 3 }, "a", 1], + [{ a: 1, b: 2, c: 3 }, "b", 2], + [{ a: { b: 1 } }, "a.b", 1], + [{ a: { b: 1 } }, "a.b.c", null, null], + [{ a: { b: 1 } }, "a.b.c", 1, 1], + [[[1]], "0.0", 1] + ] as [object, string, any, any][]; + + for (const [obj, path, expected, defaultValue] of tests) { + const result = utils.getPath(obj, path, defaultValue); + expect(result).toEqual(expected); + } + }); + }); }); diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts index 644544b..2fa535a 100644 --- a/app/__test__/ui/json-form.spec.ts +++ b/app/__test__/ui/json-form.spec.ts @@ -2,23 +2,9 @@ import { describe, expect, test } from "bun:test"; import { Draft2019 } from "json-schema-library"; import type { JSONSchema } from "json-schema-to-ts"; import * as utils from "../../src/ui/components/form/json-schema-form/utils"; +import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils"; describe("json form", () => { - test("normalize path", () => { - const examples = [ - ["description", "#/description"], - ["/description", "#/description"], - ["nested/property", "#/nested/property"], - ["nested.property", "#/nested/property"], - ["nested.property[0]", "#/nested/property/0"], - ["nested.property[0].name", "#/nested/property/0/name"] - ]; - - for (const [input, output] of examples) { - expect(utils.normalizePath(input)).toBe(output); - } - }); - test("coerse", () => { const examples = [ ["test", { type: "string" }, "test"], @@ -38,6 +24,25 @@ describe("json form", () => { } }); + test("isType", () => { + const examples = [ + ["string", "string", true], + ["integer", "number", false], + ["number", "number", true], + ["boolean", "boolean", true], + ["null", "null", true], + ["object", "object", true], + ["array", "array", true], + ["object", "array", false], + [["string", "number"], "number", true], + ["number", ["string", "number"], true] + ] satisfies [IsTypeType, IsTypeType, boolean][]; + + for (const [type, schemaType, output] of examples) { + expect(utils.isType(type, schemaType)).toBe(output); + } + }); + test("getParentPointer", () => { const examples = [ ["#/nested/property/0/name", "#/nested/property/0"], @@ -97,49 +102,37 @@ describe("json form", () => { ] satisfies [string, Exclude, boolean][]; for (const [pointer, schema, output] of examples) { - expect(utils.isRequired(pointer, schema)).toBe(output); + expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output); } }); - test("unflatten", () => { + test("prefixPath", () => { const examples = [ - [ - { "#/description": "test" }, - { - type: "object", - properties: { - description: { type: "string" } - } - }, - { - description: "test" - } - ] - ] satisfies [Record, Exclude, object][]; + ["normal", "", "normal"], + ["", "prefix", "prefix"], + ["tags", "0", "0.tags"], + ["tags", 0, "0.tags"], + ["nested.property", "prefix", "prefix.nested.property"], + ["nested.property", "", "nested.property"] + ] satisfies [string, any, string][]; - for (const [input, schema, output] of examples) { - expect(utils.unflatten(input, schema)).toEqual(output); + for (const [path, prefix, output] of examples) { + expect(utils.prefixPath(path, prefix)).toBe(output); } }); - test("...", () => { - const schema = { - type: "object", - properties: { - name: { type: "string", maxLength: 2 }, - description: { type: "string", maxLength: 2 }, - age: { type: "number", description: "Age of you" }, - deep: { - type: "object", - properties: { - nested: { type: "string", maxLength: 2 } - } - } - }, - required: ["description"] - }; + test("suffixPath", () => { + const examples = [ + ["normal", "", "normal"], + ["", "suffix", "suffix"], + ["tags", "0", "tags.0"], + ["tags", 0, "tags.0"], + ["nested.property", "suffix", "nested.property.suffix"], + ["nested.property", "", "nested.property"] + ] satisfies [string, any, string][]; - //const lib = new Draft2019(schema); - //lib.eachSchema(console.log); + for (const [path, suffix, output] of examples) { + expect(utils.suffixPath(path, suffix)).toBe(output); + } }); }); diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index ab5b807..2c7c68d 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -12,6 +12,20 @@ export function isObject(value: unknown): value is Record { return value !== null && typeof value === "object"; } +export function omitKeys( + obj: T, + keys_: readonly K[] +): Omit> { + const keys = new Set(keys_); + const result = {} as Omit>; + for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { + if (!keys.has(key as K)) { + (result as any)[key] = value; + } + } + return result; +} + export function safelyParseObjectValues(obj: T): T { return Object.entries(obj).reduce((acc, [key, value]) => { try { @@ -266,3 +280,82 @@ export function mergeObjectWith(object, source, customizer) { return object; } + +export function isEqual(value1: any, value2: any): boolean { + // Each type corresponds to a particular comparison algorithm + const getType = (value: any) => { + if (value !== Object(value)) return "primitive"; + if (Array.isArray(value)) return "array"; + if (value instanceof Map) return "map"; + if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value))) + return "plainObject"; + if (value instanceof Function) return "function"; + throw new Error( + `deeply comparing an instance of type ${value1.constructor?.name} is not supported.` + ); + }; + + const type = getType(value1); + if (type !== getType(value2)) { + return false; + } + + if (type === "primitive") { + return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2)); + } else if (type === "array") { + return ( + value1.length === value2.length && + value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i])) + ); + } else if (type === "map") { + // In this particular implementation, map keys are not + // being deeply compared, only map values. + return ( + value1.size === value2.size && + [...value1].every(([iterKey, iterValue]) => { + return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey)); + }) + ); + } else if (type === "plainObject") { + const value1AsMap = new Map(Object.entries(value1)); + const value2AsMap = new Map(Object.entries(value2)); + return ( + value1AsMap.size === value2AsMap.size && + [...value1AsMap].every(([iterKey, iterValue]) => { + return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey)); + }) + ); + } else if (type === "function") { + // just check signature + return value1.toString() === value2.toString(); + } else { + throw new Error("Unreachable"); + } +} + +export function getPath( + object: object, + _path: string | (string | number)[], + defaultValue = undefined +): any { + const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path; + + if (path.length === 0) { + return object; + } + + try { + const [head, ...tail] = path; + if (!head || !(head in object)) { + return defaultValue; + } + + return getPath(object[head], tail, defaultValue); + } catch (error) { + if (typeof defaultValue !== "undefined") { + return defaultValue; + } + + throw new Error(`Invalid path: ${path.join(".")}`); + } +} diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index f1d793f..f196c79 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -33,7 +33,6 @@ export function buildMediaSchema() { { body_max_size: Type.Optional( Type.Number({ - minimum: 0, description: "Max size of the body in bytes. Leave blank for unlimited." }) ) diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index a2b0f83..b7fc900 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -191,6 +191,10 @@ export class AdminController extends Controller { /> BKND + {/*