diff --git a/README.md b/README.md index d454fcd..29e5d04 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,16 @@ 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. diff --git a/package.json b/package.json index 5e3e53e..0fc05bb 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/src/schema-to-typebox.ts b/src/schema-to-typebox.ts index ee4a015..eaacead 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, opts?: { enumMode?: EnumModeOption, packageName?: PackageName }) => { @@ -21,6 +21,14 @@ export const schema2typebox = async (jsonSchema: string, opts?: { enumMode?: Enu ${customTypes}${enumCode}${typeForObj}\nexport const ${valueName} = ${typeBoxType}`; }; +const generateTypeForName = (name: string) => { + if (!name?.length) { + throw new Error(`Can't generate type for empty string. Got input: ${name}`); + } + const typeName = capitalize(name); + return `export type ${typeName} = Static`; +}; + /** * "SimpleType" here basically means any type that is directly mapable to a * Typebox type without any recursion (everything besides "array" and "object" @@ -111,26 +119,20 @@ const isNotSchemaObj = ( return schemaObj["not"] !== undefined; }; -const generateTypeForName = (name: string) => { - if (!name?.length) { - throw new Error(`Can't generate type for empty string. Got input: ${name}`); - } - const typeName = capitalize(name); - return `export type ${typeName} = Static`; -}; -const createUnionName = (propertyName: string) => { +const createTypedName = (suffix: string) => (propertyName: string | string[]) => { if (!propertyName?.length) { - throw new Error("Can't create union name with empty string."); + throw new Error(`Can't create ${suffix} name with empty path or property name`); } - return `${capitalize(propertyName)}Union`; -}; -const createEnumName = (propertyName: string) => { - if (!propertyName?.length) { - throw new Error("Can't create enum name with empty string."); + if (typeof propertyName === "string") { + return `${capitalize(propertyName)}${suffix}`; } - return `${capitalize(propertyName)}Enum`; + 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. @@ -148,6 +150,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 @@ -205,7 +214,7 @@ export const collect = ( schemaObj: Record, requiredAttributes: string[] = [], propertyName?: string, - itemPropertyName?: string + itemPath?: string[] ): string => { const schemaOptions = getSchemaOptions(schemaObj).reduce>( (prev, [optionName, optionValue]) => { @@ -226,11 +235,11 @@ 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 && !itemPropertyName) { + if (propertyName === undefined && !itemPath?.length) { throw new Error("cant create enum without propertyName or path"); } const enumValues = schemaObj.enum; @@ -239,17 +248,19 @@ export const collect = ( ); const pairs = zip(enumKeys, enumValues); - const enumName = createEnumName(propertyName || itemPropertyName || ""); // create typescript enum + const enumName = createEnumName(buildPropertyPath(propertyName, itemPath)); const enumInTypescript = pairs.reduce((prev, [enumKey, enumValue]) => { const correctEnumValue = typeof enumValue === "string" ? `"${enumValue}"` : enumValue; - const correctEnumKey = + const validEnumKey = enumKey === "" ? "EMPTY_STRING" : enumKey.replace(/[-]/g, "_"); - return `${prev}${correctEnumKey} = ${correctEnumValue},\n`; + return `${prev}${validEnumKey} = ${correctEnumValue},\n`; }, `export enum ${enumName} {\n`) + "}"; - const unionName = createUnionName(propertyName || itemPropertyName || ""); + + // create typescript union + const unionName = createUnionName(buildPropertyPath(propertyName, itemPath)); const unionInTypescript = enumValues.reduce((prev, enumValue) => { const correctEnumValue = @@ -260,8 +271,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) @@ -376,7 +388,7 @@ export const collect = ( : `${propertyName}: Type.${typeboxType}(\n${typeboxForProperties})`; } else { typeboxForProperties = propertiesOfObj.map(([propertyName, property]) => { - return collect(property, requiredAttributesOfObject, propertyName); + return collect(property, requiredAttributesOfObject, propertyName, buildPropertyPath(propertyName, itemPath)); }); } // propertyName will only be undefined for the "top level" schemaObj @@ -432,11 +444,14 @@ 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, - itemPropertyName + undefined, + buildPropertyPath(itemPropertyName, itemPath) )}, (${JSON.stringify(schemaOptions)}))`; } else { result = `Type.Array(${collect(itemsSchemaObj)})`; @@ -614,7 +629,7 @@ const getType = ( if (!VALID_TYPE_VALUES.includes(type) && Object.keys(schemaObj).length > 0) { throw new Error( - `[${propertyName}] JSON schema had invalid value for 'type' attribute. Got: ${type} Schemaobject was: ${JSON.stringify( + `JSON schema had invalid value for 'type' attribute in property _${propertyName}_. Got: ${type} Schema object was: ${JSON.stringify( schemaObj )}` ); diff --git a/src/utils.ts b/src/utils.ts index 6bb8828..67637e1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,9 @@ +/** + * @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) { @@ -5,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])); +}; 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(); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..b957850 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,15 @@ +// 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..969baa1 --- /dev/null +++ b/test/enums.test.ts @@ -0,0 +1,174 @@ +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}` + ); + }); + + 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 + ); + }); + }); +}); diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index c696b21..0000000 --- a/test/index.ts +++ /dev/null @@ -1,927 +0,0 @@ -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, -} from "../src/schema-to-typebox"; -import { - addCommentThatCodeIsGenerated, - schema2typebox, -} from "../src/programmatic-usage"; -import { zip } from "fp-ts/Array"; - -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. -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 enum StatusEnum { - UNKNOWN = "unknown", - ACCEPTED = "accepted", - DENIED = "denied", - } - - export type T = Static - export const T = Type.Object({ - status: Type.Enum(StatusEnum) - }) - `); - 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 enum StatusEnum { - 1 = 1, - TRUE = true, - HELLO = "hello", - } - - export enum OptionalStatusEnum { - UNKNOWN = "unknown", - ACCEPTED = "accepted", - DENIED = "denied", - } - - export type T = Static - export const T = Type.Object({ - status: Type.Enum(StatusEnum), - optionalStatus: Type.Optional(Type.Enum(OptionalStatusEnum)) - }) - `); - 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 enum StatusEnum { - UNKNOWN = "unknown", - ACCEPTED = "accepted", - DENIED = "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(Type.Enum(StatusEnum)), - }); - `); - - 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 - ); - }); - 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 - ); - }); -}); - -// 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 new file mode 100644 index 0000000..a4a34a0 --- /dev/null +++ b/test/internals.test.ts @@ -0,0 +1,488 @@ +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 + ); + }); +}); diff --git a/test/programatic-usage.test.ts b/test/programatic-usage.test.ts new file mode 100644 index 0000000..dbc9f0e --- /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 + ); + }); +}); diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..1ffdd06 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,26 @@ +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)); +};