From c14d59456a79ea91c7a9c9699c2ce2fdee222eee Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 11:10:41 -0700 Subject: [PATCH 01/11] chore: create utility method to capitalize first letter of strings --- src/schema-to-typebox.ts | 17 ++++++----------- src/utils.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 src/utils.ts diff --git a/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index 0006d47..a56731a 100644 --- a/src/schema-to-typebox.ts +++ b/src/schema-to-typebox.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { zip } from "fp-ts/Array"; import $Refparser from "@apidevtools/json-schema-ref-parser"; +import { capitalize } from "./utils"; /** Generates TypeBox code from JSON schema */ export const schema2typebox = async (jsonSchema: string) => { @@ -18,16 +19,11 @@ ${customTypes}${enumCode}${typeForObj}\nexport const ${valueName} = ${typeBoxTyp }; const generateTypeForName = (name: string) => { - const [head, ...tail] = name; - if (head === undefined) { + if (!name?.length) { throw new Error(`Can't generate type for empty string. Got input: ${name}`); } - if (tail.length === 0) { - return `export type ${head.toUpperCase()} = Static`; - } - return `export type ${head.toUpperCase()}${tail.join( - "" - )} = Static`; + const typeName = capitalize(name); + return `export type ${typeName} = Static`; }; /** @@ -121,11 +117,10 @@ const isNotSchemaObj = ( }; const createEnumName = (propertyName: string) => { - const [head, ...tail] = propertyName; - if (head === undefined) { + if (!propertyName?.length) { throw new Error("Can't create enum name with empty string."); } - return `${head.toUpperCase()}${tail.join("")}Enum`; + return `${capitalize(propertyName)}Enum`; }; /** diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..3f2404a --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,13 @@ +/** + * @name capitalize + * @description + * Capitalize the first letter of a string + * @returns {string} The capitalized string + */ +export const capitalize = (name: string) => { + const [head, ...tail] = name; + if (head === undefined) { + return name; + } + return `${head.toUpperCase()}${tail.join("")}`; +}; From 58c86b7a3cb08fb949e0709a7a75d3cf0ea0bb4e Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 11:20:37 -0700 Subject: [PATCH 02/11] feat: ts union types are now default instead of ts enums * configurability via `setEnumMode` method --- src/schema-to-typebox.ts | 49 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index a56731a..3cc811a 100644 --- a/src/schema-to-typebox.ts +++ b/src/schema-to-typebox.ts @@ -161,6 +161,24 @@ export const resetCustomTypes = () => { customTypes = ""; }; +/** + * Enums in Typescript have been controversial and along with the limitations + * around valid key names, many developers prefer to use Union Types. This config + * will give the developer the option to choose between the two options. + */ +let enumMode: "union" | "enum" | "preferEnum" | "preferUnion" = "union"; + +export const setEnumMode = (mode: typeof enumMode) => { + enumMode = mode; +}; + +const createUnionName = (propertyName: string) => { + if (!propertyName?.length) { + throw new Error("Can't create union name with empty string."); + } + return `${capitalize(propertyName)}Union`; +}; + type PackageName = string; type ImportValue = string; const requiredImports = new Map>(); @@ -216,14 +234,35 @@ export const collect = ( const enumName = createEnumName(propertyName); // create typescript enum const enumInTypescript = - pairs.reduce((prev, [enumKey, enumValue]) => { + pairs.reduce((prev, [enumKey, enumValue]) => { + const correctEnumValue = + typeof enumValue === "string" ? `"${enumValue}"` : enumValue; + const validEnumKey = + enumKey === "" ? "EMPTY_STRING" : enumKey.replace(/[-]/g, "_"); + return `${prev}${validEnumKey} = ${correctEnumValue},\n`; + }, `export enum ${enumName} {\n`) + "}"; + + // create typescript union + const unionName = createUnionName(propertyName || itemPropertyName || ""); + const unionInTypescript = + enumValues.reduce((prev, enumValue) => { const correctEnumValue = typeof enumValue === "string" ? `"${enumValue}"` : enumValue; - return `${prev}${enumKey} = ${correctEnumValue},\n`; - }, `export enum ${enumName} {\n`) + "}"; - enumCode = enumCode + enumInTypescript + "\n\n"; - let result = `Type.Enum(${enumName})`; + return `${prev}Type.Literal(${correctEnumValue}),\n`; + }, `export const ${unionName} = Type.Union([\n`) + "])"; + + enumCode = [ + enumCode, + /enum/i.test(enumMode) && enumInTypescript, + /union/i.test(enumMode) && unionInTypescript, + "\n\n", + ] + .filter((x) => !!x) + .join(""); + + let result = /union/i.test(enumMode) ? unionName : `Type.Enum(${enumName})`; + if (!isRequiredAttribute) { result = `Type.Optional(${result})`; } From 58b4e98954851647541accd875d8539aee493742 Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 15:34:01 -0700 Subject: [PATCH 03/11] ci: add tests around enum mode --- src/schema-to-typebox.ts | 30 ++++--- test/index.ts | 184 ++++++++++++++++++++++++++++----------- 2 files changed, 151 insertions(+), 63 deletions(-) diff --git a/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index 3cc811a..e3bfcea 100644 --- a/src/schema-to-typebox.ts +++ b/src/schema-to-typebox.ts @@ -140,6 +140,13 @@ export const resetEnumCode = () => { enumCode = ""; }; +/** + * Used to programatically retrieve the enum code. Used in tests. + */ +export const getEnumCode = () => { + return enumCode; +}; + /** * Adds custom types to the typebox registry in order to validate them and use * the typecompiler against them. Used to e.g. implement 'oneOf' which does @@ -231,19 +238,19 @@ export const collect = ( ); const pairs = zip(enumKeys, enumValues); - const enumName = createEnumName(propertyName); // create typescript enum + const enumName = createEnumName(propertyName); const enumInTypescript = - pairs.reduce((prev, [enumKey, enumValue]) => { - const correctEnumValue = - typeof enumValue === "string" ? `"${enumValue}"` : enumValue; - const validEnumKey = - enumKey === "" ? "EMPTY_STRING" : enumKey.replace(/[-]/g, "_"); - return `${prev}${validEnumKey} = ${correctEnumValue},\n`; - }, `export enum ${enumName} {\n`) + "}"; + pairs.reduce((prev, [enumKey, enumValue]) => { + const correctEnumValue = + typeof enumValue === "string" ? `"${enumValue}"` : enumValue; + const validEnumKey = + enumKey === "" ? "EMPTY_STRING" : enumKey.replace(/[-]/g, "_"); + return `${prev}${validEnumKey} = ${correctEnumValue},\n`; + }, `export enum ${enumName} {\n`) + "}"; // create typescript union - const unionName = createUnionName(propertyName || itemPropertyName || ""); + const unionName = createUnionName(propertyName); const unionInTypescript = enumValues.reduce((prev, enumValue) => { const correctEnumValue = @@ -254,8 +261,9 @@ export const collect = ( enumCode = [ enumCode, - /enum/i.test(enumMode) && enumInTypescript, - /union/i.test(enumMode) && unionInTypescript, + /enum|prefer/i.test(enumMode) && enumInTypescript, + /prefer/i.test(enumMode) && "\n", + /union|prefer/i.test(enumMode) && unionInTypescript, "\n\n", ] .filter((x) => !!x) diff --git a/test/index.ts b/test/index.ts index c696b21..1c6e62f 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, test } from "node:test"; +import { afterEach, beforeEach, describe, test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; @@ -9,6 +9,8 @@ import { collect, resetEnumCode, resetCustomTypes, + setEnumMode, + getEnumCode, } from "../src/schema-to-typebox"; import { addCommentThatCodeIsGenerated, @@ -67,15 +69,15 @@ describe("programmatic usage API", () => { const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; - export enum StatusEnum { - UNKNOWN = "unknown", - ACCEPTED = "accepted", - DENIED = "denied", - } + export const StatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ]) export type T = Static export const T = Type.Object({ - status: Type.Enum(StatusEnum) + status: StatusUnion }) `); expectEqualIgnoreFormatting( @@ -110,22 +112,22 @@ describe("programmatic usage API", () => { const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; - export enum StatusEnum { - 1 = 1, - TRUE = true, - HELLO = "hello", - } + export const StatusUnion = Type.Union([ + Type.Literal(1), + Type.Literal(true), + Type.Literal("hello"), + ]) - export enum OptionalStatusEnum { - UNKNOWN = "unknown", - ACCEPTED = "accepted", - DENIED = "denied", - } + export const OptionalStatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ]) export type T = Static export const T = Type.Object({ - status: Type.Enum(StatusEnum), - optionalStatus: Type.Optional(Type.Enum(OptionalStatusEnum)) + status: StatusUnion, + optionalStatus: Type.Optional(OptionalStatusUnion) }) `); expectEqualIgnoreFormatting( @@ -204,11 +206,11 @@ describe("programmatic usage API", () => { const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; - export enum StatusEnum { - UNKNOWN = "unknown", - ACCEPTED = "accepted", - DENIED = "denied", - } + export const StatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ]); export type Contract = Static; export const Contract = Type.Object({ @@ -216,7 +218,7 @@ describe("programmatic usage API", () => { name: Type.String({ maxLength: 100 }), age: Type.Number({ minimum: 18 }), }), - status: Type.Optional(Type.Enum(StatusEnum)), + status: Type.Optional(StatusUnion), }); `); @@ -880,33 +882,111 @@ describe("schema2typebox internal - collect()", () => { expectedTypebox ); }); - test("object with enum", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "status": { - "enum": [ - "unknown", - "accepted", - "denied" - ] - } - }, - "required": [ - "status" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - status: Type.Enum(StatusEnum), - }) - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); + + describe("object with enum", () => { + let dummySchema: string; + + beforeEach(() => { + dummySchema = ` + { + "type": "object", + "properties": { + "status": { + "enum": [ + "unknown", + "accepted", + "denied" + ] + } + }, + "required": [ + "status" + ] + } + `; + }); + + afterEach(() => { + resetEnumCode(); + }); + + const expectedEnumCode = `export enum StatusEnum { + UNKNOWN = "unknown", + ACCEPTED = "accepted", + DENIED = "denied", + }`; + const expectedUnionCode = `export const StatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ])`; + + test("in enum mode", () => { + setEnumMode("enum"); + const expectedTypebox = ` + Type.Object({ + status: Type.Enum(StatusEnum), + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + + expectEqualIgnoreFormatting(getEnumCode(), expectedEnumCode); + }); + test("in preferEnum mode", () => { + setEnumMode("preferEnum"); + const expectedTypebox = ` + Type.Object({ + status: Type.Enum(StatusEnum), + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + expectEqualIgnoreFormatting( + getEnumCode(), + `${expectedEnumCode}${expectedUnionCode}` + ); + }); + test("in union mode", () => { + setEnumMode("union"); + + const expectedTypebox = ` + Type.Object({ + status: StatusUnion, + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + + expectEqualIgnoreFormatting(getEnumCode(), expectedUnionCode); + }); + test("in preferUnion mode", () => { + setEnumMode("preferUnion"); + const expectedTypebox = ` + Type.Object({ + status: StatusUnion, + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + + expectEqualIgnoreFormatting( + getEnumCode(), + `${expectedEnumCode}${expectedUnionCode}` + ); + }); }); }); From 1a0b210d46586313a70c1fb2e8a39a333504ea8c Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 17:04:04 -0700 Subject: [PATCH 04/11] chore: update README to reflect updated features and others in-flight --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d454fcd..14f197f 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ whats already implemented and what is missing. - [x] Type.Literal() via "const" property - [x] Type.Union() via "anyOf" property - [x] Type.Intersect() via "allOf" property -- [x] Type.Enum() via "enum" property +- [x] Type.Union() via "enum" property - [x] OneOf() via "oneOf" property - This adds oneOf to the typebox type registry as (Kind: 'ExtendedOneOf') in order to be able to align to oneOf json schema semantics and still be able @@ -211,6 +211,14 @@ whats already implemented and what is missing. - [ ] (low prio) Type.Tuple() via "array" instance type with minimalItems, maximalItems and additionalItems false +#### To Be Prioritized +Support for the following types and functionality is being split from PR #23 into individual PRs +- [ ] Type.Enum() via "enum" property +- [ ] Type.Array() with "array" instance type +- [ ] Nullable Literal types, eg: `type: ['string', 'null']` +- [ ] "Unknown" object types +- [ ] Disambiguation of overlapping property names in nested schemas + ## DEV/CONTRIBUTOR NOTES - If you have an idea or want to help implement something, feel free to do so. From fc71193009ae6028caea3d75fd92b9f28f7c5796 Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 15:49:58 -0700 Subject: [PATCH 05/11] refactor: break apart tests --- test/cli.test.ts | 16 ++ test/enums.test.ts | 115 ++++++++ test/index.ts | 133 +-------- test/internals.test.ts | 491 +++++++++++++++++++++++++++++++++ test/programatic-usage.test.ts | 376 +++++++++++++++++++++++++ test/utils.ts | 27 ++ 6 files changed, 1027 insertions(+), 131 deletions(-) create mode 100644 test/cli.test.ts create mode 100644 test/enums.test.ts create mode 100644 test/internals.test.ts create mode 100644 test/programatic-usage.test.ts create mode 100644 test/utils.ts diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..b04b8e0 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,16 @@ +// NOTE: these are the most "high level" tests. Create them sparsely. Focus on +// cli usage aspects rather then implementation of the business logic below it. +// describe("cli usage", () => { +// TODO: how can we test this? +// test("pipes to stdout if -h or --help is given", async () => { +// Ideas: +// - can we provide process.stdout with our own stream and check the +// output? +// - can we mock process.stdout? does not work with mock.method since +// process.stdout is not a function. +// const getHelpTextMock = mock.method(getHelpText, "run", () => {}); +// await runCli(); +// assert.equal(getHelpTextMock.mock.callCount(), 10); +// }); +// }); + diff --git a/test/enums.test.ts b/test/enums.test.ts new file mode 100644 index 0000000..3571009 --- /dev/null +++ b/test/enums.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, test } from "node:test"; + +import { + collect, + resetEnumCode, + setEnumMode, + getEnumCode, +} from "../src/schema-to-typebox"; +import { expectEqualIgnoreFormatting } from "./utils"; + +describe("object with enum", () => { + let dummySchema: string; + + beforeEach(() => { + dummySchema = ` + { + "type": "object", + "properties": { + "status": { + "enum": [ + "unknown", + "accepted", + "denied" + ] + } + }, + "required": [ + "status" + ] + } + `; + }); + + afterEach(() => { + resetEnumCode(); + }); + + const expectedEnumCode = `export enum StatusEnum { + UNKNOWN = "unknown", + ACCEPTED = "accepted", + DENIED = "denied", + }`; + const expectedUnionCode = `export const StatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ])`; + + test("in enum mode", () => { + setEnumMode("enum"); + const expectedTypebox = ` + Type.Object({ + status: Type.Enum(StatusEnum), + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + + expectEqualIgnoreFormatting(getEnumCode(), expectedEnumCode); + }); + test("in preferEnum mode", () => { + setEnumMode("preferEnum"); + const expectedTypebox = ` + Type.Object({ + status: Type.Enum(StatusEnum), + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + expectEqualIgnoreFormatting( + getEnumCode(), + `${expectedEnumCode}${expectedUnionCode}` + ); + }); + test("in union mode", () => { + setEnumMode("union"); + + const expectedTypebox = ` + Type.Object({ + status: StatusUnion, + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + + expectEqualIgnoreFormatting(getEnumCode(), expectedUnionCode); + }); + test("in preferUnion mode", () => { + setEnumMode("preferUnion"); + const expectedTypebox = ` + Type.Object({ + status: StatusUnion, + }) + `; + + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + + expectEqualIgnoreFormatting( + getEnumCode(), + `${expectedEnumCode}${expectedUnionCode}` + ); + }); + }); \ No newline at end of file diff --git a/test/index.ts b/test/index.ts index 1c6e62f..98cb30e 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,44 +1,20 @@ -import { afterEach, beforeEach, describe, test } from "node:test"; +import { afterEach, describe, test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; -import path from "node:path"; -import * as prettier from "prettier"; import shell from "shelljs"; import { collect, resetEnumCode, resetCustomTypes, - setEnumMode, - getEnumCode, } from "../src/schema-to-typebox"; import { addCommentThatCodeIsGenerated, schema2typebox, } from "../src/programmatic-usage"; import { zip } from "fp-ts/Array"; +import { SHELLJS_RETURN_CODE_OK, buildOsIndependentPath, expectEqualIgnoreFormatting } from "./utils"; -const SHELLJS_RETURN_CODE_OK = 0; -const buildOsIndependentPath = (foldersOrFiles: string[]) => { - return foldersOrFiles.join(path.sep); -}; - -const formatWithPrettier = (input: string): string => { - return prettier.format(input, { parser: "typescript" }); -}; - -/** - * Formats given input with prettier and returns the result. This is used for - * testing to be able to compare generated types with expected types without - * having to take care of formatting. - * @throws Error - **/ -export const expectEqualIgnoreFormatting = ( - input1: string, - input2: string -): void => { - assert.equal(formatWithPrettier(input1), formatWithPrettier(input2)); -}; // NOTE: Rather test the collect() function whenever we can instead // of schema2typebox. @@ -883,111 +859,6 @@ describe("schema2typebox internal - collect()", () => { ); }); - describe("object with enum", () => { - let dummySchema: string; - - beforeEach(() => { - dummySchema = ` - { - "type": "object", - "properties": { - "status": { - "enum": [ - "unknown", - "accepted", - "denied" - ] - } - }, - "required": [ - "status" - ] - } - `; - }); - - afterEach(() => { - resetEnumCode(); - }); - - const expectedEnumCode = `export enum StatusEnum { - UNKNOWN = "unknown", - ACCEPTED = "accepted", - DENIED = "denied", - }`; - const expectedUnionCode = `export const StatusUnion = Type.Union([ - Type.Literal("unknown"), - Type.Literal("accepted"), - Type.Literal("denied"), - ])`; - - test("in enum mode", () => { - setEnumMode("enum"); - const expectedTypebox = ` - Type.Object({ - status: Type.Enum(StatusEnum), - }) - `; - - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - - expectEqualIgnoreFormatting(getEnumCode(), expectedEnumCode); - }); - test("in preferEnum mode", () => { - setEnumMode("preferEnum"); - const expectedTypebox = ` - Type.Object({ - status: Type.Enum(StatusEnum), - }) - `; - - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - expectEqualIgnoreFormatting( - getEnumCode(), - `${expectedEnumCode}${expectedUnionCode}` - ); - }); - test("in union mode", () => { - setEnumMode("union"); - - const expectedTypebox = ` - Type.Object({ - status: StatusUnion, - }) - `; - - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - - expectEqualIgnoreFormatting(getEnumCode(), expectedUnionCode); - }); - test("in preferUnion mode", () => { - setEnumMode("preferUnion"); - const expectedTypebox = ` - Type.Object({ - status: StatusUnion, - }) - `; - - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - - expectEqualIgnoreFormatting( - getEnumCode(), - `${expectedEnumCode}${expectedUnionCode}` - ); - }); - }); }); // NOTE: these are the most "high level" tests. Create them sparsely. Focus on diff --git a/test/internals.test.ts b/test/internals.test.ts new file mode 100644 index 0000000..a72de05 --- /dev/null +++ b/test/internals.test.ts @@ -0,0 +1,491 @@ +import { describe, test } from "node:test"; + +import { + collect, +} from "../src/schema-to-typebox"; +import { expectEqualIgnoreFormatting } from "./utils"; + +/** + * I think it is best to test the collect() function directly(less + * overhead) instead of programmatic usage or cli usage for new features + * whenever possible. + * + * For testing against schemas containing $refs please add these tests to the + * schema2typebox programmatic usage section instead. This is required since + * collect() expects an already dereferenced JSON schema and therefore testing with $refs + * would make no sense. + */ +describe("schema2typebox internal - collect()", () => { + test("object with required string property", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }`; + const expectedTypebox = ` + Type.Object({ + name: Type.String(), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with optional string property", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + `; + const expectedTypebox = ` + Type.Object({ + name: Type.Optional(Type.String()), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with string that has schemaOptions", () => { + // src for properties + // 1. https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5 + // (careful, this is 2020 spec src): + // 2. https://json-schema.org/draft/2020-12/json-schema-validation.html#name-validation-keywords-for-num + const dummySchema = ` + { + "type": "object", + "properties": { + "name": { + "description": "full name of the person", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-zA-Z]+(s)+[a-zA-Z]+$", + "type": "string" + } + } + } + `; + const expectedTypebox = ` + Type.Object({ + name: Type.Optional( + Type.String({ + description: "full name of the person", + minLength: 1, + maxLength: 100, + pattern: "^[a-zA-Z]+(\s)+[a-zA-Z]+$", + }) + ), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with required number property", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "age": { + "type": "number" + } + }, + "required": [ + "age" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + age: Type.Number() + }) + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with null property", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "age": { + "type": "null" + } + }, + "required": [ + "age" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + age: Type.Null() + }) + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with boolean property", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "funny": { + "type": "boolean" + } + }, + "required": [ + "funny" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + funny: Type.Boolean() + }) + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with array property and simple type (string)", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "hobbies": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "hobbies" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + hobbies: Type.Array(Type.String(), { minItems: 1 }), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + // TODO: test object with array property and object type + test("object with object property", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + } + }, + "required": [ + "street", + "city" + ] + } + }, + "required": [ + "address" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + address: Type.Object({ + street: Type.String(), + city: Type.String() + }) + }) + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with const", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "nickname": { + "const": "xddq", + "type": "string" + }, + "x": { + "const": 99, + "type": "number" + }, + "y": { + "const": true, + "type": "boolean" + }, + "z": { + "const": false, + "type": "boolean" + }, + "a": { + "type": "array", + "items": { + "const": 1, + "type": "number" + } + }, + "b": { + "type": "array", + "items": { + "const": "hi", + "type": "string" + } + }, + "c": { + "const": 10, + "type": "number" + }, + "d": { + "type": "array", + "items": { + "const": 1, + "type": "number" + } + }, + "e": { + "type": "array", + "items": { + "const": "hi", + "type": "string" + } + } + }, + "required": [ + "nickname", + "x", + "y", + "z", + "a", + "b" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + nickname: Type.Literal("xddq"), + x: Type.Literal(99), + y: Type.Literal(true), + z: Type.Literal(false), + a: Type.Array(Type.Literal(1)), + b: Type.Array(Type.Literal("hi")), + c: Type.Optional(Type.Literal(10)), + d: Type.Optional(Type.Array(Type.Literal(1))), + e: Type.Optional(Type.Array(Type.Literal("hi"))), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with anyOf", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "a": { + "anyOf": [ + { + "const": 1, + "type": "number" + }, + { + "const": 2, + "type": "number" + } + ] + }, + "b": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "c": { + "description": "a union type", + "anyOf": [ + { + "maxLength": 20, + "type": "string" + }, + { + "description": "can only be 1", + "const": 1, + "type": "number" + } + ] + } + }, + "required": [ + "a", + "c" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + a: Type.Union([Type.Literal(1), Type.Literal(2)]), + b: Type.Union([Type.String(), Type.Number(), Type.Null()]), + c: Type.Union([Type.String({ maxLength: 20 }), + Type.Literal(1, { description: "can only be 1" }),], { description: "a union type",}), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with oneOf", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "a": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "required": ["a"] + } + `; + const expectedTypebox = ` + Type.Object({ + a: OneOf([Type.String(), Type.Number()]), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with allOf", () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "a": { + "allOf": [ + { + "const": 1, + "type": "number" + }, + { + "const": 2, + "type": "number" + } + ] + }, + "b": { + "allOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "c": { + "description": "intersection of two types", + "allOf": [ + { + "description": "important", + "type": "string" + }, + { + "minimum": 1, + "type": "number" + } + ] + } + }, + "required": [ + "a", + "c" + ] + } + `; + const expectedTypebox = ` + Type.Object({ + a: Type.Intersect([Type.Literal(1), Type.Literal(2)]), + b: Type.Intersect([Type.String(), Type.Number()]), + c: Type.Intersect([Type.String({ description: "important" }), Type.Number({ minimum: 1 })], {description: "intersection of two types"})}); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + test("object with not", () => { + const dummySchema = ` + { + "type": "object", + "properties": { "x": { "not": { "type": "number" } } }, + "required": ["x"] + } + `; + const expectedTypebox = ` + Type.Object({ + x: Type.Not(Type.Number()), + }); + `; + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + }); + +}); \ No newline at end of file diff --git a/test/programatic-usage.test.ts b/test/programatic-usage.test.ts new file mode 100644 index 0000000..cd8cb4a --- /dev/null +++ b/test/programatic-usage.test.ts @@ -0,0 +1,376 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import shell from "shelljs"; + +import { + resetEnumCode, + resetCustomTypes, +} from "../src/schema-to-typebox"; +import { + addCommentThatCodeIsGenerated, + schema2typebox, +} from "../src/programmatic-usage"; +import { zip } from "fp-ts/Array"; +import { SHELLJS_RETURN_CODE_OK, buildOsIndependentPath, expectEqualIgnoreFormatting } from "./utils"; + +// NOTE: Rather test the collect() function whenever we can instead +// of schema2typebox. +describe("programmatic usage API", () => { + // TODO: remove this once global state enumCode and customCode were removed + afterEach(() => { + resetEnumCode(); + resetCustomTypes(); + }); + test("object with enum (all keys string)", async () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "status": { + "enum": [ + "unknown", + "accepted", + "denied" + ] + } + }, + "required": [ + "status" + ] + } + `; + const expectedTypebox = addCommentThatCodeIsGenerated.run(` + import { Static, Type } from "@sinclair/typebox"; + + export const StatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ]) + + export type T = Static + export const T = Type.Object({ + status: StatusUnion + }) + `); + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + }); + test("object with enum (mixed types for keys) and optional enum with string keys", async () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "status": { + "enum": [ + 1, + true, + "hello" + ] + }, + "optionalStatus": { + "enum": [ + "unknown", + "accepted", + "denied"] + } + }, + "required": [ + "status" + ] + } + `; + const expectedTypebox = addCommentThatCodeIsGenerated.run(` + import { Static, Type } from "@sinclair/typebox"; + + export const StatusUnion = Type.Union([ + Type.Literal(1), + Type.Literal(true), + Type.Literal("hello"), + ]) + + export const OptionalStatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ]) + + export type T = Static + export const T = Type.Object({ + status: StatusUnion, + optionalStatus: Type.Optional(OptionalStatusUnion) + }) + `); + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + }); + test("generated typebox names are based on title attribute", async () => { + const dummySchema = ` + { + "title": "Contract", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + `; + const expectedTypebox = addCommentThatCodeIsGenerated.run(` + import { Static, Type } from "@sinclair/typebox"; + + export type Contract = Static; + export const Contract = Type.Object({ + name: Type.String(), + }); + `); + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + }); + test("object with $ref pointing to external files in relative path", async () => { + const dummySchema = ` + { + "title": "Contract", + "type": "object", + "properties": { + "person": { + "$ref": "person.json" + }, + "status": { + "$ref": "status.json" + } + }, + "required": ["person"] + } + `; + + const referencedPersonSchema = ` + { + "title": "Person", + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 100 + }, + "age": { + "type": "number", + "minimum": 18 + } + }, + "required": ["name", "age"] + } + `; + + const referencedStatusSchema = ` + { + "title": "Status", + "enum": ["unknown", "accepted", "denied"] + } + `; + + const expectedTypebox = addCommentThatCodeIsGenerated.run(` + import { Static, Type } from "@sinclair/typebox"; + + export const StatusUnion = Type.Union([ + Type.Literal("unknown"), + Type.Literal("accepted"), + Type.Literal("denied"), + ]); + + export type Contract = Static; + export const Contract = Type.Object({ + person: Type.Object({ + name: Type.String({ maxLength: 100 }), + age: Type.Number({ minimum: 18 }), + }), + status: Type.Optional(StatusUnion), + }); + `); + + const inputPaths = ["person.json", "status.json"].flatMap((currItem) => + buildOsIndependentPath([__dirname, "..", "..", currItem]) + ); + zip(inputPaths, [referencedPersonSchema, referencedStatusSchema]).map( + ([fileName, data]) => fs.writeFileSync(fileName, data, undefined) + ); + + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + + // cleanup generated files + const { code: returnCode } = shell.rm("-f", inputPaths); + assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); + }); + test("object with $ref inside anyOf", async () => { + const dummySchema = { + anyOf: [{ $ref: "./cat.json" }, { $ref: "./dog.json" }], + }; + + const referencedCatSchema = { + title: "Cat", + type: "object", + properties: { + type: { + type: "string", + const: "cat", + }, + name: { + type: "string", + maxLength: 100, + }, + }, + required: ["type", "name"], + }; + + const referencedDogSchema = { + title: "Dog", + type: "object", + properties: { + type: { + type: "string", + const: "dog", + }, + name: { + type: "string", + maxLength: 100, + }, + }, + required: ["type", "name"], + }; + const expectedTypebox = addCommentThatCodeIsGenerated.run(` + import { Static, Type } from "@sinclair/typebox"; + + export type T = Static; + export const T = Type.Union([ + Type.Object({ + type: Type.Literal("cat"), + name: Type.String({ maxLength: 100 }), + }), + Type.Object({ + type: Type.Literal("dog"), + name: Type.String({ maxLength: 100 }), + }), + ]); + `); + + const inputPaths = ["cat.json", "dog.json"].flatMap((currItem) => + buildOsIndependentPath([__dirname, "..", "..", currItem]) + ); + zip(inputPaths, [referencedCatSchema, referencedDogSchema]).map( + ([fileName, data]) => + fs.writeFileSync(fileName, JSON.stringify(data), undefined) + ); + + expectEqualIgnoreFormatting( + await schema2typebox({ input: JSON.stringify(dummySchema) }), + expectedTypebox + ); + + // cleanup generated files + const { code: returnCode } = shell.rm("-f", inputPaths); + assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); + }); + // NOTE: This test might break if github adapts their links to raw github user + // content. The branch "feature/fix-refs-using-refparser" will not be deleted. + test("object with $ref to remote files", async () => { + const dummySchema = { + anyOf: [ + { + $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/cat.json", + }, + { + $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/dog.json", + }, + ], + }; + + const expectedTypebox = addCommentThatCodeIsGenerated.run(` + import { Static, Type } from "@sinclair/typebox"; + + export type T = Static; + export const T = Type.Union([ + Type.Object({ + type: Type.Literal("cat"), + name: Type.String({ maxLength: 100 }), + }), + Type.Object({ + type: Type.Literal("dog"), + name: Type.String({ maxLength: 100 }), + }), + ]); + `); + + expectEqualIgnoreFormatting( + await schema2typebox({ input: JSON.stringify(dummySchema) }), + expectedTypebox + ); + }); + test("object with oneOf generates custom typebox TypeRegistry code", async () => { + const dummySchema = ` + { + "type": "object", + "properties": { + "a": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "required": ["a"] + } + `; + const expectedTypebox = addCommentThatCodeIsGenerated.run(` + import { + Kind, + SchemaOptions, + Static, + TSchema, + TUnion, + Type, + TypeRegistry, + } from "@sinclair/typebox"; + import { Value } from "@sinclair/typebox/value"; + + TypeRegistry.Set( + "ExtendedOneOf", + (schema: any, value) => + 1 === + schema.oneOf.reduce( + (acc: number, schema: any) => acc + (Value.Check(schema, value) ? 1 : 0), + 0 + ) + ); + + const OneOf = ( + oneOf: [...T], + options: SchemaOptions = {} + ) => Type.Unsafe>>({ ...options, [Kind]: "ExtendedOneOf", oneOf }); + + export type T = Static; + export const T = Type.Object({ + a: OneOf([Type.String(), Type.Number()]), + }); + `); + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + }); + }); + \ No newline at end of file diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..a3befe7 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,27 @@ + +import path from "node:path"; +import * as prettier from "prettier"; +import assert from "node:assert/strict"; + +export const SHELLJS_RETURN_CODE_OK = 0; + +export const buildOsIndependentPath = (foldersOrFiles: string[]) => { + return foldersOrFiles.join(path.sep); +}; + +export const formatWithPrettier = (input: string): string => { + return prettier.format(input, { parser: "typescript" }); +}; + +/** + * Formats given input with prettier and returns the result. This is used for + * testing to be able to compare generated types with expected types without + * having to take care of formatting. + * @throws Error + **/ +export const expectEqualIgnoreFormatting = ( + input1: string, + input2: string + ): void => { + assert.equal(formatWithPrettier(input1), formatWithPrettier(input2)); + }; \ No newline at end of file From f90e0b224c98c528be14e7f16badaecdd8773e15 Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 16:21:50 -0700 Subject: [PATCH 06/11] ci: add "watch" mode for test target - invoked via `yarn watch-test` or `yarn test --watch` --- package.json | 1 + tasks.mjs | 29 +++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0a22ef5..f666f20 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "lint-check": "node tasks.mjs lint", "start": "node tasks.mjs start", "test": "node tasks.mjs test", + "watch-test": "node tasks.mjs test --watch", "watch-node": "node tasks.mjs watch-node", "watch-ts": "node tasks.mjs watch-ts", "publish": "yarn build && npm publish" diff --git a/tasks.mjs b/tasks.mjs index 737d027..d03ac97 100644 --- a/tasks.mjs +++ b/tasks.mjs @@ -25,14 +25,39 @@ export const build = (additionalArguments = []) => { /** * Tests the project. */ -export const test = () => { +export const test = (additionalArguments = []) => { build(); + + if (additionalArguments.includes("--watch")) { + return testWatch(); + } + // runs tests based on the test runner execution model // src: https://nodejs.org/api/test.html#test-runner-execution-model" const { code } = shell.exec(`node --test --test-reporter spec`); handleNonZeroReturnCode(code); }; +export const testWatch = () => { + nodemon( + `--exec "node ${["node_modules", "typescript", "bin", "tsc"].join( + path.sep + )} && node --test --test-reporter spec" --watch src --watch test -e ts` + ); + + nodemon + .on("start", async function () { + console.log("Test Reporter has started"); + }) + .on("quit", function () { + console.log("Test Reporter has quit"); + process.exit(); + }) + .on("restart", function (files) { + console.log("Test Reporter restarted due to: ", files); + }); +}; + /** * Bundles the code into a single file. */ @@ -172,7 +197,7 @@ const main = () => { return start(); } if (taskName === "test") { - return test(); + return test(additionalArguments); } if (taskName === "watch-node") { return watchNode(); From 8f171396bf2b2612a20b1fb89afe747f92dbca80 Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 22:15:52 -0700 Subject: [PATCH 07/11] chore: formatting --- README.md | 2 + src/schema-to-typebox.ts | 2 +- test/cli.test.ts | 1 - test/enums.test.ts | 110 +++-- test/index.ts | 878 --------------------------------- test/internals.test.ts | 7 +- test/programatic-usage.test.ts | 284 +++++------ test/utils.ts | 11 +- 8 files changed, 208 insertions(+), 1087 deletions(-) delete mode 100644 test/index.ts diff --git a/README.md b/README.md index 14f197f..29e5d04 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,9 @@ whats already implemented and what is missing. maximalItems and additionalItems false #### To Be Prioritized + Support for the following types and functionality is being split from PR #23 into individual PRs + - [ ] Type.Enum() via "enum" property - [ ] Type.Array() with "array" instance type - [ ] Nullable Literal types, eg: `type: ['string', 'null']` diff --git a/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index e3bfcea..b7d15c6 100644 --- a/src/schema-to-typebox.ts +++ b/src/schema-to-typebox.ts @@ -574,7 +574,7 @@ const getType = (schemaObj: Record): VALID_TYPE_VALUE => { if (!VALID_TYPE_VALUES.includes(type)) { throw new Error( `JSON schema had invalid value for 'type' attribute. Got: ${type} - Schemaobject was: ${JSON.stringify(schemaObj)}` + Schema object was: ${JSON.stringify(schemaObj)}` ); } diff --git a/test/cli.test.ts b/test/cli.test.ts index b04b8e0..b957850 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -13,4 +13,3 @@ // assert.equal(getHelpTextMock.mock.callCount(), 10); // }); // }); - diff --git a/test/enums.test.ts b/test/enums.test.ts index 3571009..6dced7a 100644 --- a/test/enums.test.ts +++ b/test/enums.test.ts @@ -9,10 +9,10 @@ import { import { expectEqualIgnoreFormatting } from "./utils"; describe("object with enum", () => { - let dummySchema: string; + let dummySchema: string; - beforeEach(() => { - dummySchema = ` + beforeEach(() => { + dummySchema = ` { "type": "object", "properties": { @@ -29,87 +29,89 @@ describe("object with enum", () => { ] } `; - }); + }); - afterEach(() => { - resetEnumCode(); - }); + afterEach(() => { + resetEnumCode(); + }); - const expectedEnumCode = `export enum StatusEnum { + const expectedEnumCode = `export enum StatusEnum { UNKNOWN = "unknown", ACCEPTED = "accepted", DENIED = "denied", }`; - const expectedUnionCode = `export const StatusUnion = Type.Union([ + const expectedUnionCode = `export const StatusUnion = Type.Union([ Type.Literal("unknown"), Type.Literal("accepted"), Type.Literal("denied"), ])`; - test("in enum mode", () => { - setEnumMode("enum"); - const expectedTypebox = ` + test("in enum mode", () => { + setEnumMode("enum"); + const expectedTypebox = ` Type.Object({ status: Type.Enum(StatusEnum), }) `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); - expectEqualIgnoreFormatting(getEnumCode(), expectedEnumCode); - }); - test("in preferEnum mode", () => { - setEnumMode("preferEnum"); - const expectedTypebox = ` + expectEqualIgnoreFormatting(getEnumCode(), expectedEnumCode); + }); + + test("in preferEnum mode", () => { + setEnumMode("preferEnum"); + const expectedTypebox = ` Type.Object({ status: Type.Enum(StatusEnum), }) `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - expectEqualIgnoreFormatting( - getEnumCode(), - `${expectedEnumCode}${expectedUnionCode}` - ); - }); - test("in union mode", () => { - setEnumMode("union"); - - const expectedTypebox = ` + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + expectEqualIgnoreFormatting( + getEnumCode(), + `${expectedEnumCode}${expectedUnionCode}` + ); + }); + test("in union mode", () => { + setEnumMode("union"); + + const expectedTypebox = ` Type.Object({ status: StatusUnion, }) `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); - expectEqualIgnoreFormatting(getEnumCode(), expectedUnionCode); - }); - test("in preferUnion mode", () => { - setEnumMode("preferUnion"); - const expectedTypebox = ` + expectEqualIgnoreFormatting(getEnumCode(), expectedUnionCode); + }); + + test("in preferUnion mode", () => { + setEnumMode("preferUnion"); + const expectedTypebox = ` Type.Object({ status: StatusUnion, }) `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - - expectEqualIgnoreFormatting( - getEnumCode(), - `${expectedEnumCode}${expectedUnionCode}` - ); - }); - }); \ No newline at end of file + expectEqualIgnoreFormatting( + collect(JSON.parse(dummySchema)), + expectedTypebox + ); + + expectEqualIgnoreFormatting( + getEnumCode(), + `${expectedEnumCode}${expectedUnionCode}` + ); + }); +}); diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index 98cb30e..0000000 --- a/test/index.ts +++ /dev/null @@ -1,878 +0,0 @@ -import { afterEach, describe, test } from "node:test"; -import assert from "node:assert/strict"; -import fs from "node:fs"; -import shell from "shelljs"; - -import { - collect, - resetEnumCode, - resetCustomTypes, -} from "../src/schema-to-typebox"; -import { - addCommentThatCodeIsGenerated, - schema2typebox, -} from "../src/programmatic-usage"; -import { zip } from "fp-ts/Array"; -import { SHELLJS_RETURN_CODE_OK, buildOsIndependentPath, expectEqualIgnoreFormatting } from "./utils"; - - -// NOTE: Rather test the collect() function whenever we can instead -// of schema2typebox. -describe("programmatic usage API", () => { - // TODO: remove this once global state enumCode and customCode were removed - afterEach(() => { - resetEnumCode(); - resetCustomTypes(); - }); - test("object with enum (all keys string)", async () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "status": { - "enum": [ - "unknown", - "accepted", - "denied" - ] - } - }, - "required": [ - "status" - ] - } - `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` - import { Static, Type } from "@sinclair/typebox"; - - export const StatusUnion = Type.Union([ - Type.Literal("unknown"), - Type.Literal("accepted"), - Type.Literal("denied"), - ]) - - export type T = Static - export const T = Type.Object({ - status: StatusUnion - }) - `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); - test("object with enum (mixed types for keys) and optional enum with string keys", async () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "status": { - "enum": [ - 1, - true, - "hello" - ] - }, - "optionalStatus": { - "enum": [ - "unknown", - "accepted", - "denied"] - } - }, - "required": [ - "status" - ] - } - `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` - import { Static, Type } from "@sinclair/typebox"; - - export const StatusUnion = Type.Union([ - Type.Literal(1), - Type.Literal(true), - Type.Literal("hello"), - ]) - - export const OptionalStatusUnion = Type.Union([ - Type.Literal("unknown"), - Type.Literal("accepted"), - Type.Literal("denied"), - ]) - - export type T = Static - export const T = Type.Object({ - status: StatusUnion, - optionalStatus: Type.Optional(OptionalStatusUnion) - }) - `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); - test("generated typebox names are based on title attribute", async () => { - const dummySchema = ` - { - "title": "Contract", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` - import { Static, Type } from "@sinclair/typebox"; - - export type Contract = Static; - export const Contract = Type.Object({ - name: Type.String(), - }); - `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); - test("object with $ref pointing to external files in relative path", async () => { - const dummySchema = ` - { - "title": "Contract", - "type": "object", - "properties": { - "person": { - "$ref": "person.json" - }, - "status": { - "$ref": "status.json" - } - }, - "required": ["person"] - } - `; - - const referencedPersonSchema = ` - { - "title": "Person", - "type": "object", - "properties": { - "name": { - "type": "string", - "maxLength": 100 - }, - "age": { - "type": "number", - "minimum": 18 - } - }, - "required": ["name", "age"] - } - `; - - const referencedStatusSchema = ` - { - "title": "Status", - "enum": ["unknown", "accepted", "denied"] - } - `; - - const expectedTypebox = addCommentThatCodeIsGenerated.run(` - import { Static, Type } from "@sinclair/typebox"; - - export const StatusUnion = Type.Union([ - Type.Literal("unknown"), - Type.Literal("accepted"), - Type.Literal("denied"), - ]); - - export type Contract = Static; - export const Contract = Type.Object({ - person: Type.Object({ - name: Type.String({ maxLength: 100 }), - age: Type.Number({ minimum: 18 }), - }), - status: Type.Optional(StatusUnion), - }); - `); - - const inputPaths = ["person.json", "status.json"].flatMap((currItem) => - buildOsIndependentPath([__dirname, "..", "..", currItem]) - ); - zip(inputPaths, [referencedPersonSchema, referencedStatusSchema]).map( - ([fileName, data]) => fs.writeFileSync(fileName, data, undefined) - ); - - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - - // cleanup generated files - const { code: returnCode } = shell.rm("-f", inputPaths); - assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); - }); - test("object with $ref inside anyOf", async () => { - const dummySchema = { - anyOf: [{ $ref: "./cat.json" }, { $ref: "./dog.json" }], - }; - - const referencedCatSchema = { - title: "Cat", - type: "object", - properties: { - type: { - type: "string", - const: "cat", - }, - name: { - type: "string", - maxLength: 100, - }, - }, - required: ["type", "name"], - }; - - const referencedDogSchema = { - title: "Dog", - type: "object", - properties: { - type: { - type: "string", - const: "dog", - }, - name: { - type: "string", - maxLength: 100, - }, - }, - required: ["type", "name"], - }; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` - import { Static, Type } from "@sinclair/typebox"; - - export type T = Static; - export const T = Type.Union([ - Type.Object({ - type: Type.Literal("cat"), - name: Type.String({ maxLength: 100 }), - }), - Type.Object({ - type: Type.Literal("dog"), - name: Type.String({ maxLength: 100 }), - }), - ]); - `); - - const inputPaths = ["cat.json", "dog.json"].flatMap((currItem) => - buildOsIndependentPath([__dirname, "..", "..", currItem]) - ); - zip(inputPaths, [referencedCatSchema, referencedDogSchema]).map( - ([fileName, data]) => - fs.writeFileSync(fileName, JSON.stringify(data), undefined) - ); - - expectEqualIgnoreFormatting( - await schema2typebox({ input: JSON.stringify(dummySchema) }), - expectedTypebox - ); - - // cleanup generated files - const { code: returnCode } = shell.rm("-f", inputPaths); - assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); - }); - // NOTE: This test might break if github adapts their links to raw github user - // content. The branch "feature/fix-refs-using-refparser" will not be deleted. - test("object with $ref to remote files", async () => { - const dummySchema = { - anyOf: [ - { - $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/cat.json", - }, - { - $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/dog.json", - }, - ], - }; - - const expectedTypebox = addCommentThatCodeIsGenerated.run(` - import { Static, Type } from "@sinclair/typebox"; - - export type T = Static; - export const T = Type.Union([ - Type.Object({ - type: Type.Literal("cat"), - name: Type.String({ maxLength: 100 }), - }), - Type.Object({ - type: Type.Literal("dog"), - name: Type.String({ maxLength: 100 }), - }), - ]); - `); - - expectEqualIgnoreFormatting( - await schema2typebox({ input: JSON.stringify(dummySchema) }), - expectedTypebox - ); - }); - test("object with oneOf generates custom typebox TypeRegistry code", async () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "a": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - } - }, - "required": ["a"] - } - `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` - import { - Kind, - SchemaOptions, - Static, - TSchema, - TUnion, - Type, - TypeRegistry, - } from "@sinclair/typebox"; - import { Value } from "@sinclair/typebox/value"; - - TypeRegistry.Set( - "ExtendedOneOf", - (schema: any, value) => - 1 === - schema.oneOf.reduce( - (acc: number, schema: any) => acc + (Value.Check(schema, value) ? 1 : 0), - 0 - ) - ); - - const OneOf = ( - oneOf: [...T], - options: SchemaOptions = {} - ) => Type.Unsafe>>({ ...options, [Kind]: "ExtendedOneOf", oneOf }); - - export type T = Static; - export const T = Type.Object({ - a: OneOf([Type.String(), Type.Number()]), - }); - `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); -}); - -/** - * I think it is best to test the collect() function directly(less - * overhead) instead of programmatic usage or cli usage for new features - * whenever possible. - * - * For testing against schemas containing $refs please add these tests to the - * schema2typebox programmatic usage section instead. This is required since - * collect() expects an already dereferenced JSON schema and therefore testing with $refs - * would make no sense. - */ -describe("schema2typebox internal - collect()", () => { - test("object with required string property", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - }`; - const expectedTypebox = ` - Type.Object({ - name: Type.String(), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with optional string property", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - `; - const expectedTypebox = ` - Type.Object({ - name: Type.Optional(Type.String()), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with string that has schemaOptions", () => { - // src for properties - // 1. https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5 - // (careful, this is 2020 spec src): - // 2. https://json-schema.org/draft/2020-12/json-schema-validation.html#name-validation-keywords-for-num - const dummySchema = ` - { - "type": "object", - "properties": { - "name": { - "description": "full name of the person", - "minLength": 1, - "maxLength": 100, - "pattern": "^[a-zA-Z]+(s)+[a-zA-Z]+$", - "type": "string" - } - } - } - `; - const expectedTypebox = ` - Type.Object({ - name: Type.Optional( - Type.String({ - description: "full name of the person", - minLength: 1, - maxLength: 100, - pattern: "^[a-zA-Z]+(\s)+[a-zA-Z]+$", - }) - ), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with required number property", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "age": { - "type": "number" - } - }, - "required": [ - "age" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - age: Type.Number() - }) - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with null property", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "age": { - "type": "null" - } - }, - "required": [ - "age" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - age: Type.Null() - }) - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with boolean property", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "funny": { - "type": "boolean" - } - }, - "required": [ - "funny" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - funny: Type.Boolean() - }) - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with array property and simple type (string)", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "hobbies": { - "minItems": 1, - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "hobbies" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - hobbies: Type.Array(Type.String(), { minItems: 1 }), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - // TODO: test object with array property and object type - test("object with object property", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "address": { - "type": "object", - "properties": { - "street": { - "type": "string" - }, - "city": { - "type": "string" - } - }, - "required": [ - "street", - "city" - ] - } - }, - "required": [ - "address" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - address: Type.Object({ - street: Type.String(), - city: Type.String() - }) - }) - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with const", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "nickname": { - "const": "xddq", - "type": "string" - }, - "x": { - "const": 99, - "type": "number" - }, - "y": { - "const": true, - "type": "boolean" - }, - "z": { - "const": false, - "type": "boolean" - }, - "a": { - "type": "array", - "items": { - "const": 1, - "type": "number" - } - }, - "b": { - "type": "array", - "items": { - "const": "hi", - "type": "string" - } - }, - "c": { - "const": 10, - "type": "number" - }, - "d": { - "type": "array", - "items": { - "const": 1, - "type": "number" - } - }, - "e": { - "type": "array", - "items": { - "const": "hi", - "type": "string" - } - } - }, - "required": [ - "nickname", - "x", - "y", - "z", - "a", - "b" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - nickname: Type.Literal("xddq"), - x: Type.Literal(99), - y: Type.Literal(true), - z: Type.Literal(false), - a: Type.Array(Type.Literal(1)), - b: Type.Array(Type.Literal("hi")), - c: Type.Optional(Type.Literal(10)), - d: Type.Optional(Type.Array(Type.Literal(1))), - e: Type.Optional(Type.Array(Type.Literal("hi"))), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with anyOf", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "a": { - "anyOf": [ - { - "const": 1, - "type": "number" - }, - { - "const": 2, - "type": "number" - } - ] - }, - "b": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "c": { - "description": "a union type", - "anyOf": [ - { - "maxLength": 20, - "type": "string" - }, - { - "description": "can only be 1", - "const": 1, - "type": "number" - } - ] - } - }, - "required": [ - "a", - "c" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - a: Type.Union([Type.Literal(1), Type.Literal(2)]), - b: Type.Union([Type.String(), Type.Number(), Type.Null()]), - c: Type.Union([Type.String({ maxLength: 20 }), - Type.Literal(1, { description: "can only be 1" }),], { description: "a union type",}), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with oneOf", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "a": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - } - }, - "required": ["a"] - } - `; - const expectedTypebox = ` - Type.Object({ - a: OneOf([Type.String(), Type.Number()]), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with allOf", () => { - const dummySchema = ` - { - "type": "object", - "properties": { - "a": { - "allOf": [ - { - "const": 1, - "type": "number" - }, - { - "const": 2, - "type": "number" - } - ] - }, - "b": { - "allOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "c": { - "description": "intersection of two types", - "allOf": [ - { - "description": "important", - "type": "string" - }, - { - "minimum": 1, - "type": "number" - } - ] - } - }, - "required": [ - "a", - "c" - ] - } - `; - const expectedTypebox = ` - Type.Object({ - a: Type.Intersect([Type.Literal(1), Type.Literal(2)]), - b: Type.Intersect([Type.String(), Type.Number()]), - c: Type.Intersect([Type.String({ description: "important" }), Type.Number({ minimum: 1 })], {description: "intersection of two types"})}); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - test("object with not", () => { - const dummySchema = ` - { - "type": "object", - "properties": { "x": { "not": { "type": "number" } } }, - "required": ["x"] - } - `; - const expectedTypebox = ` - Type.Object({ - x: Type.Not(Type.Number()), - }); - `; - expectEqualIgnoreFormatting( - collect(JSON.parse(dummySchema)), - expectedTypebox - ); - }); - -}); - -// NOTE: these are the most "high level" tests. Create them sparsely. Focus on -// cli usage aspects rather then implementation of the business logic below it. -// describe("cli usage", () => { -// TODO: how can we test this? -// test("pipes to stdout if -h or --help is given", async () => { -// Ideas: -// - can we provide process.stdout with our own stream and check the -// output? -// - can we mock process.stdout? does not work with mock.method since -// process.stdout is not a function. -// const getHelpTextMock = mock.method(getHelpText, "run", () => {}); -// await runCli(); -// assert.equal(getHelpTextMock.mock.callCount(), 10); -// }); -// }); diff --git a/test/internals.test.ts b/test/internals.test.ts index a72de05..a4a34a0 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -1,8 +1,6 @@ import { describe, test } from "node:test"; -import { - collect, -} from "../src/schema-to-typebox"; +import { collect } from "../src/schema-to-typebox"; import { expectEqualIgnoreFormatting } from "./utils"; /** @@ -487,5 +485,4 @@ describe("schema2typebox internal - collect()", () => { expectedTypebox ); }); - -}); \ No newline at end of file +}); diff --git a/test/programatic-usage.test.ts b/test/programatic-usage.test.ts index cd8cb4a..dbc9f0e 100644 --- a/test/programatic-usage.test.ts +++ b/test/programatic-usage.test.ts @@ -3,27 +3,28 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import shell from "shelljs"; -import { - resetEnumCode, - resetCustomTypes, -} from "../src/schema-to-typebox"; +import { resetEnumCode, resetCustomTypes } from "../src/schema-to-typebox"; import { addCommentThatCodeIsGenerated, schema2typebox, } from "../src/programmatic-usage"; import { zip } from "fp-ts/Array"; -import { SHELLJS_RETURN_CODE_OK, buildOsIndependentPath, expectEqualIgnoreFormatting } from "./utils"; +import { + SHELLJS_RETURN_CODE_OK, + buildOsIndependentPath, + expectEqualIgnoreFormatting, +} from "./utils"; // NOTE: Rather test the collect() function whenever we can instead // of schema2typebox. describe("programmatic usage API", () => { - // TODO: remove this once global state enumCode and customCode were removed - afterEach(() => { - resetEnumCode(); - resetCustomTypes(); - }); - test("object with enum (all keys string)", async () => { - const dummySchema = ` + // TODO: remove this once global state enumCode and customCode were removed + afterEach(() => { + resetEnumCode(); + resetCustomTypes(); + }); + test("object with enum (all keys string)", async () => { + const dummySchema = ` { "type": "object", "properties": { @@ -40,7 +41,7 @@ describe("programmatic usage API", () => { ] } `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` + const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; export const StatusUnion = Type.Union([ @@ -54,13 +55,13 @@ describe("programmatic usage API", () => { status: StatusUnion }) `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); - test("object with enum (mixed types for keys) and optional enum with string keys", async () => { - const dummySchema = ` + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + }); + test("object with enum (mixed types for keys) and optional enum with string keys", async () => { + const dummySchema = ` { "type": "object", "properties": { @@ -83,7 +84,7 @@ describe("programmatic usage API", () => { ] } `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` + const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; export const StatusUnion = Type.Union([ @@ -104,13 +105,13 @@ describe("programmatic usage API", () => { optionalStatus: Type.Optional(OptionalStatusUnion) }) `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); - test("generated typebox names are based on title attribute", async () => { - const dummySchema = ` + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + }); + test("generated typebox names are based on title attribute", async () => { + const dummySchema = ` { "title": "Contract", "type": "object", @@ -122,7 +123,7 @@ describe("programmatic usage API", () => { "required": ["name"] } `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` + const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; export type Contract = Static; @@ -130,13 +131,13 @@ describe("programmatic usage API", () => { name: Type.String(), }); `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); - test("object with $ref pointing to external files in relative path", async () => { - const dummySchema = ` + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + }); + test("object with $ref pointing to external files in relative path", async () => { + const dummySchema = ` { "title": "Contract", "type": "object", @@ -151,8 +152,8 @@ describe("programmatic usage API", () => { "required": ["person"] } `; - - const referencedPersonSchema = ` + + const referencedPersonSchema = ` { "title": "Person", "type": "object", @@ -169,15 +170,15 @@ describe("programmatic usage API", () => { "required": ["name", "age"] } `; - - const referencedStatusSchema = ` + + const referencedStatusSchema = ` { "title": "Status", "enum": ["unknown", "accepted", "denied"] } `; - - const expectedTypebox = addCommentThatCodeIsGenerated.run(` + + const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; export const StatusUnion = Type.Union([ @@ -195,60 +196,60 @@ describe("programmatic usage API", () => { status: Type.Optional(StatusUnion), }); `); - - const inputPaths = ["person.json", "status.json"].flatMap((currItem) => - buildOsIndependentPath([__dirname, "..", "..", currItem]) - ); - zip(inputPaths, [referencedPersonSchema, referencedStatusSchema]).map( - ([fileName, data]) => fs.writeFileSync(fileName, data, undefined) - ); - - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - - // cleanup generated files - const { code: returnCode } = shell.rm("-f", inputPaths); - assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); - }); - test("object with $ref inside anyOf", async () => { - const dummySchema = { - anyOf: [{ $ref: "./cat.json" }, { $ref: "./dog.json" }], - }; - - const referencedCatSchema = { - title: "Cat", - type: "object", - properties: { - type: { - type: "string", - const: "cat", - }, - name: { - type: "string", - maxLength: 100, - }, + + const inputPaths = ["person.json", "status.json"].flatMap((currItem) => + buildOsIndependentPath([__dirname, "..", "..", currItem]) + ); + zip(inputPaths, [referencedPersonSchema, referencedStatusSchema]).map( + ([fileName, data]) => fs.writeFileSync(fileName, data, undefined) + ); + + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); + + // cleanup generated files + const { code: returnCode } = shell.rm("-f", inputPaths); + assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); + }); + test("object with $ref inside anyOf", async () => { + const dummySchema = { + anyOf: [{ $ref: "./cat.json" }, { $ref: "./dog.json" }], + }; + + const referencedCatSchema = { + title: "Cat", + type: "object", + properties: { + type: { + type: "string", + const: "cat", }, - required: ["type", "name"], - }; - - const referencedDogSchema = { - title: "Dog", - type: "object", - properties: { - type: { - type: "string", - const: "dog", - }, - name: { - type: "string", - maxLength: 100, - }, + name: { + type: "string", + maxLength: 100, + }, + }, + required: ["type", "name"], + }; + + const referencedDogSchema = { + title: "Dog", + type: "object", + properties: { + type: { + type: "string", + const: "dog", + }, + name: { + type: "string", + maxLength: 100, }, - required: ["type", "name"], - }; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` + }, + required: ["type", "name"], + }; + const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; export type T = Static; @@ -263,39 +264,39 @@ describe("programmatic usage API", () => { }), ]); `); - - const inputPaths = ["cat.json", "dog.json"].flatMap((currItem) => - buildOsIndependentPath([__dirname, "..", "..", currItem]) - ); - zip(inputPaths, [referencedCatSchema, referencedDogSchema]).map( - ([fileName, data]) => - fs.writeFileSync(fileName, JSON.stringify(data), undefined) - ); - - expectEqualIgnoreFormatting( - await schema2typebox({ input: JSON.stringify(dummySchema) }), - expectedTypebox - ); - - // cleanup generated files - const { code: returnCode } = shell.rm("-f", inputPaths); - assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); - }); - // NOTE: This test might break if github adapts their links to raw github user - // content. The branch "feature/fix-refs-using-refparser" will not be deleted. - test("object with $ref to remote files", async () => { - const dummySchema = { - anyOf: [ - { - $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/cat.json", - }, - { - $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/dog.json", - }, - ], - }; - - const expectedTypebox = addCommentThatCodeIsGenerated.run(` + + const inputPaths = ["cat.json", "dog.json"].flatMap((currItem) => + buildOsIndependentPath([__dirname, "..", "..", currItem]) + ); + zip(inputPaths, [referencedCatSchema, referencedDogSchema]).map( + ([fileName, data]) => + fs.writeFileSync(fileName, JSON.stringify(data), undefined) + ); + + expectEqualIgnoreFormatting( + await schema2typebox({ input: JSON.stringify(dummySchema) }), + expectedTypebox + ); + + // cleanup generated files + const { code: returnCode } = shell.rm("-f", inputPaths); + assert.equal(returnCode, SHELLJS_RETURN_CODE_OK); + }); + // NOTE: This test might break if github adapts their links to raw github user + // content. The branch "feature/fix-refs-using-refparser" will not be deleted. + test("object with $ref to remote files", async () => { + const dummySchema = { + anyOf: [ + { + $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/cat.json", + }, + { + $ref: "https://raw.githubusercontent.com/xddq/schema2typebox/main/examples/ref-to-remote-files/dog.json", + }, + ], + }; + + const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Static, Type } from "@sinclair/typebox"; export type T = Static; @@ -310,14 +311,14 @@ describe("programmatic usage API", () => { }), ]); `); - - expectEqualIgnoreFormatting( - await schema2typebox({ input: JSON.stringify(dummySchema) }), - expectedTypebox - ); - }); - test("object with oneOf generates custom typebox TypeRegistry code", async () => { - const dummySchema = ` + + expectEqualIgnoreFormatting( + await schema2typebox({ input: JSON.stringify(dummySchema) }), + expectedTypebox + ); + }); + test("object with oneOf generates custom typebox TypeRegistry code", async () => { + const dummySchema = ` { "type": "object", "properties": { @@ -335,7 +336,7 @@ describe("programmatic usage API", () => { "required": ["a"] } `; - const expectedTypebox = addCommentThatCodeIsGenerated.run(` + const expectedTypebox = addCommentThatCodeIsGenerated.run(` import { Kind, SchemaOptions, @@ -367,10 +368,9 @@ describe("programmatic usage API", () => { a: OneOf([Type.String(), Type.Number()]), }); `); - expectEqualIgnoreFormatting( - await schema2typebox({ input: dummySchema }), - expectedTypebox - ); - }); + expectEqualIgnoreFormatting( + await schema2typebox({ input: dummySchema }), + expectedTypebox + ); }); - \ No newline at end of file +}); diff --git a/test/utils.ts b/test/utils.ts index a3befe7..1ffdd06 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,4 +1,3 @@ - import path from "node:path"; import * as prettier from "prettier"; import assert from "node:assert/strict"; @@ -20,8 +19,8 @@ export const formatWithPrettier = (input: string): string => { * @throws Error **/ export const expectEqualIgnoreFormatting = ( - input1: string, - input2: string - ): void => { - assert.equal(formatWithPrettier(input1), formatWithPrettier(input2)); - }; \ No newline at end of file + input1: string, + input2: string +): void => { + assert.equal(formatWithPrettier(input1), formatWithPrettier(input2)); +}; From 3a348f66ca7a28c3e851fe87a539daeea61ab7d8 Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Thu, 3 Aug 2023 23:49:43 -0700 Subject: [PATCH 08/11] refactor: unify implementation of enum & union var name; add support for paths --- src/schema-to-typebox.ts | 22 +++++++++++----------- src/utils.ts | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index b7d15c6..95cbadd 100644 --- a/src/schema-to-typebox.ts +++ b/src/schema-to-typebox.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import { zip } from "fp-ts/Array"; import $Refparser from "@apidevtools/json-schema-ref-parser"; -import { capitalize } from "./utils"; +import { buildPropertyPath, capitalize } from "./utils"; /** Generates TypeBox code from JSON schema */ export const schema2typebox = async (jsonSchema: string) => { @@ -116,13 +116,20 @@ const isNotSchemaObj = ( return schemaObj["not"] !== undefined; }; -const createEnumName = (propertyName: string) => { +const createTypedName = (suffix: string) => (propertyName: string | string[]) => { if (!propertyName?.length) { - throw new Error("Can't create enum name with empty string."); + throw new Error(`Can't create ${suffix} name with empty path or property name`); } - return `${capitalize(propertyName)}Enum`; + if (typeof propertyName === "string") { + return `${capitalize(propertyName)}${suffix}`; + } + return `${propertyName.map((currItem) => capitalize(currItem)).join("")}${suffix}`; }; +const createEnumName = createTypedName('Enum'); + +const createUnionName = createTypedName('Union'); + /** * Contains Typescript code for the enums that are created based on the JSON * schema. @@ -179,13 +186,6 @@ export const setEnumMode = (mode: typeof enumMode) => { enumMode = mode; }; -const createUnionName = (propertyName: string) => { - if (!propertyName?.length) { - throw new Error("Can't create union name with empty string."); - } - return `${capitalize(propertyName)}Union`; -}; - type PackageName = string; type ImportValue = string; const requiredImports = new Map>(); diff --git a/src/utils.ts b/src/utils.ts index 3f2404a..7b0a55f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,3 +11,23 @@ export const capitalize = (name: string) => { } return `${head.toUpperCase()}${tail.join("")}`; }; + +/** + * @name buildPropertyPath + * @description + * While traversing a JSON schema, this function builds a path to the current node + * for the purpose of disambiguating property names in the case of nested objects. + * + * @returns array representation of the current path, root to leaf + */ +export const buildPropertyPath = (propertyName: string | undefined, path?: string[]) => { + if(!propertyName) { + return path || []; + }; + + if(!path?.length) { + return [propertyName].filter(x => x);; + } + + return [...path, propertyName].filter((x, i, ra) => x && (!i || x !== ra[i - 1])); +}; \ No newline at end of file From 4323f9875567120825baa52c9d8d307338982984 Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Fri, 4 Aug 2023 00:07:21 -0700 Subject: [PATCH 09/11] feat: generate type names based on nested path, not just property name 1. note: this logic depends on deduplication in the buildPropertyPath that will cause issues if a child property shares the same name as its parent 1. note: the logic around root property names (schema-to-typebox:373-376) a partial implementation of the next phase of logic --- src/schema-to-typebox.ts | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index 95cbadd..448e3db 100644 --- a/src/schema-to-typebox.ts +++ b/src/schema-to-typebox.ts @@ -204,7 +204,8 @@ export const resetRequiredImports = () => { export const collect = ( schemaObj: Record, requiredAttributes: string[] = [], - propertyName?: string + propertyName?: string, + itemPath?: string[] ): string => { const schemaOptions = getSchemaOptions(schemaObj).reduce>( (prev, [optionName, optionValue]) => { @@ -225,12 +226,12 @@ export const collect = ( const absolutePath = process.cwd() + "/" + relativePath; const schemaObjAsString = fs.readFileSync(absolutePath, "utf-8"); const parsedSchemaObj = JSON.parse(schemaObjAsString); - return collect(parsedSchemaObj, requiredAttributes, propertyName); + return collect(parsedSchemaObj, requiredAttributes, propertyName, buildPropertyPath(propertyName, itemPath)); } if (isEnumSchemaObj(schemaObj)) { - if (propertyName === undefined) { - throw new Error("cant create enum without propertyName"); + if (propertyName === undefined && !itemPath?.length) { + throw new Error("cant create enum without propertyName or path"); } const enumValues = schemaObj.enum; const enumKeys = enumValues.map((currItem) => @@ -239,7 +240,7 @@ export const collect = ( const pairs = zip(enumKeys, enumValues); // create typescript enum - const enumName = createEnumName(propertyName); + const enumName = createEnumName(buildPropertyPath(propertyName, itemPath)); const enumInTypescript = pairs.reduce((prev, [enumKey, enumValue]) => { const correctEnumValue = @@ -250,7 +251,7 @@ export const collect = ( }, `export enum ${enumName} {\n`) + "}"; // create typescript union - const unionName = createUnionName(propertyName); + const unionName = createUnionName(buildPropertyPath(propertyName, itemPath)); const unionInTypescript = enumValues.reduce((prev, enumValue) => { const correctEnumValue = @@ -365,15 +366,19 @@ export const collect = ( // TODO: replace "as string[]" here const requiredAttributesOfObject = (schemaObj["required"] ?? []) as string[]; - const typeboxForProperties = propertiesOfObj.map( - ([propertyName, property]) => { - return collect(property, requiredAttributesOfObject, propertyName); - } - ); + + let typeboxForProperties; + let typeboxType = "Object"; + + if (propertiesOfObj) { + typeboxForProperties = propertiesOfObj.map(([propertyName, property]) => { + return collect(property, requiredAttributesOfObject, propertyName, buildPropertyPath(propertyName, itemPath)); + }); + } // propertyName will only be undefined for the "top level" schemaObj return propertyName === undefined - ? `Type.Object({\n${typeboxForProperties}})` - : `${propertyName}: Type.Object({\n${typeboxForProperties}})`; + ? `Type.${typeboxType}({\n${typeboxForProperties}})` + : `${propertyName}: Type.${typeboxType}({\n${typeboxForProperties}})`; } else if ( type === "string" || type === "number" || From 5421fa7cd32501208544c622cb0eb9a5569d4250 Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Fri, 4 Aug 2023 14:46:51 -0700 Subject: [PATCH 10/11] test: add test coverage for nested object keys --- test/enums.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/test/enums.test.ts b/test/enums.test.ts index 6dced7a..969baa1 100644 --- a/test/enums.test.ts +++ b/test/enums.test.ts @@ -114,4 +114,61 @@ describe("object with enum", () => { `${expectedEnumCode}${expectedUnionCode}` ); }); + + describe("... and nested object with same key enum", () => { + beforeEach(() => { + dummySchema = `{ + "type": "object", + "properties": { + "status": { + "enum": [ + "unknown", + "accepted", + "denied" + ] + }, + "nested": { + "type": "object", + "properties": { + "status": { + "enum": [ + "hidden", + "visible", + "obscured" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "status" + ] + }`; + }); + + test("should generate unique union type names", () => { + setEnumMode("union"); + const expectedTypebox = ` + Type.Object({ + status: StatusUnion, + nested: Type.Object({ + status: NestedStatusUnion, + }), + }) + `; + + const schema = JSON.parse(dummySchema); + console.error('schema', schema) + const collected = collect(schema); + console.error('collected', collected) + + expectEqualIgnoreFormatting( + collected, + expectedTypebox + ); + }); + }); }); From 5e0bf776f0e9c74dc54c4bf3b3f5dac62cfc842a Mon Sep 17 00:00:00 2001 From: Patrick Fowler Date: Fri, 4 Aug 2023 14:46:18 -0700 Subject: [PATCH 11/11] feat: handle a variety of edge cases WIP --- src/schema-to-typebox.ts | 66 +++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index 448e3db..b26d3dd 100644 --- a/src/schema-to-typebox.ts +++ b/src/schema-to-typebox.ts @@ -359,7 +359,7 @@ export const collect = ( return result + "\n"; } - const type = getType(schemaObj); + const type = getType(schemaObj, propertyName); if (type === "object") { // console.log("type was object"); const propertiesOfObj = getProperties(schemaObj); @@ -370,7 +370,15 @@ export const collect = ( let typeboxForProperties; let typeboxType = "Object"; - if (propertiesOfObj) { + if (!propertiesOfObj) { + console.warn('This logic disabled for testing') + typeboxForProperties = `Type.String(),\nType.Unknown()\n`; + typeboxType = "Record"; + // Handle an "unknown" as a "record" type; could make configurable... + return propertyName === undefined + ? `Type.${typeboxType}(\n${typeboxForProperties})` + : `${propertyName}: Type.${typeboxType}(\n${typeboxForProperties})`; + } else { typeboxForProperties = propertiesOfObj.map(([propertyName, property]) => { return collect(property, requiredAttributesOfObject, propertyName, buildPropertyPath(propertyName, itemPath)); }); @@ -425,11 +433,18 @@ export const collect = ( ); } + const itemPropertyName = `${propertyName}Item`; let result = ""; if (Object.keys(schemaOptions).length > 0) { result = `Type.Array(${collect(itemsSchemaObj)}, (${JSON.stringify( schemaOptions )}))`; + result = `Type.Array(${collect( + itemsSchemaObj, + undefined, + undefined, + buildPropertyPath(itemPropertyName, itemPath) + )}, (${JSON.stringify(schemaOptions)}))`; } else { result = `Type.Array(${collect(itemsSchemaObj)})`; } @@ -440,6 +455,15 @@ export const collect = ( result = `${propertyName}: ${result}`; } return result + "\n"; + } else if (typeof type === "undefined") { + let result = `Type.Unknown()`; + if (!isRequiredAttribute) { + result = `Type.Optional(${result})`; + } + if (propertyName !== undefined) { + result = `${propertyName}: ${result}`; + } + return result + "\n"; } throw new Error(`cant collect ${type} yet`); @@ -549,12 +573,10 @@ type PropertiesOfProperty = Record; */ const getProperties = ( schema: Record -): (readonly [PropertyName, PropertiesOfProperty])[] => { +): (readonly [PropertyName, PropertiesOfProperty])[] | undefined => { const properties = schema["properties"]; if (properties === undefined) { - throw new Error( - "JSON schema was expected to have 'properties' attribute/property. Got: undefined" - ); + return undefined; } const propertyNames = Object.keys(properties); const listWithPropertyObjects = propertyNames.map((currItem) => { @@ -573,13 +595,35 @@ const getProperties = ( * * @throws Error */ -const getType = (schemaObj: Record): VALID_TYPE_VALUE => { - const type = schemaObj["type"]; +const getType = ( + schemaObj: Record, + propertyName?: string +): VALID_TYPE_VALUE => { + let type = schemaObj["type"]; + + if (type?.constructor === Array) { + type = type.filter((t) => t !== "null"); + if (type.length > 1) { + throw new Error( + `[${propertyName}] Cannot handle multiple types value for 'type' attribute. Got: ${schemaObj["type"]}` + ); + } + type = type[0]; + } + + if ( + !type && + schemaObj["default"] && + Object.keys(schemaObj["default"]).length > 0 + ) { + return "object"; + } - if (!VALID_TYPE_VALUES.includes(type)) { + if (!VALID_TYPE_VALUES.includes(type) && Object.keys(schemaObj).length > 0) { throw new Error( - `JSON schema had invalid value for 'type' attribute. Got: ${type} - Schema object was: ${JSON.stringify(schemaObj)}` + `JSON schema had invalid value for 'type' attribute in property _${propertyName}_. Got: ${type} Schema object was: ${JSON.stringify( + schemaObj + )}` ); }