UNPKG

schema2typebox

Version:

Creates typebox code from JSON schemas

336 lines (335 loc) 13.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.parseTypeName = exports.parseWithMultipleTypes = exports.parseArray = exports.parseNot = exports.parseOneOf = exports.parseAllOf = exports.parseAnyOf = exports.parseType = exports.parseUnknown = exports.parseConst = exports.parseEnum = exports.parseObject = exports.createOneOfTypeboxSupportCode = exports.collect = exports.schema2typebox = void 0; const json_schema_ref_parser_1 = __importDefault(require("@apidevtools/json-schema-ref-parser")); const camelcase_1 = __importDefault(require("camelcase")); const boolean_1 = require("fp-ts/lib/boolean"); const number_1 = require("fp-ts/lib/number"); const string_1 = require("fp-ts/lib/string"); const schema_matchers_1 = require("./schema-matchers"); /** Generates TypeBox code from a given JSON schema */ const schema2typebox = async (jsonSchema) => { const schemaObj = JSON.parse(jsonSchema); const dereferencedSchema = (await json_schema_ref_parser_1.default.dereference(schemaObj)); const exportedName = createExportNameForSchema(dereferencedSchema); // Ensuring that generated typebox code will contain an '$id' field. // see: https://github.com/xddq/schema2typebox/issues/32 if (typeof dereferencedSchema !== "boolean" && dereferencedSchema.$id === undefined) { dereferencedSchema.$id = exportedName; } const typeBoxType = (0, exports.collect)(dereferencedSchema); const exportedType = createExportedTypeForName(exportedName); return `${createImportStatements()} ${typeBoxType.includes("OneOf([") ? (0, exports.createOneOfTypeboxSupportCode)() : ""} ${exportedType} export const ${exportedName} = ${typeBoxType}`; }; exports.schema2typebox = schema2typebox; /** * Takes the root schema and recursively collects the corresponding types * for it. Returns the matching typebox code representing the schema. * * @throws Error if an unexpected schema (one with no matching parser) was given */ const collect = (schema) => { // TODO: boolean schema support..? if ((0, boolean_1.isBoolean)(schema)) { return JSON.stringify(schema); } else if ((0, schema_matchers_1.isObjectSchema)(schema)) { return (0, exports.parseObject)(schema); } else if ((0, schema_matchers_1.isEnumSchema)(schema)) { return (0, exports.parseEnum)(schema); } else if ((0, schema_matchers_1.isAnyOfSchema)(schema)) { return (0, exports.parseAnyOf)(schema); } else if ((0, schema_matchers_1.isAllOfSchema)(schema)) { return (0, exports.parseAllOf)(schema); } else if ((0, schema_matchers_1.isOneOfSchema)(schema)) { return (0, exports.parseOneOf)(schema); } else if ((0, schema_matchers_1.isNotSchema)(schema)) { return (0, exports.parseNot)(schema); } else if ((0, schema_matchers_1.isArraySchema)(schema)) { return (0, exports.parseArray)(schema); } else if ((0, schema_matchers_1.isSchemaWithMultipleTypes)(schema)) { return (0, exports.parseWithMultipleTypes)(schema); } else if ((0, schema_matchers_1.isConstSchema)(schema)) { return (0, exports.parseConst)(schema); } else if ((0, schema_matchers_1.isUnknownSchema)(schema)) { return (0, exports.parseUnknown)(schema); } else if (schema.type !== undefined && !Array.isArray(schema.type)) { return (0, exports.parseTypeName)(schema.type, schema); } throw new Error(`Unsupported schema. Did not match any type of the parsers. Schema was: ${JSON.stringify(schema)}`); }; exports.collect = collect; /** * Creates the imports required to build the typebox code. * Unused imports (e.g. if we don't need to create a TypeRegistry for OneOf * types) are stripped in a postprocessing step. */ const createImportStatements = () => { return [ 'import {Kind, SchemaOptions, Static, TSchema, TUnion, Type, TypeRegistry} from "@sinclair/typebox"', 'import { Value } from "@sinclair/typebox/value";', ].join("\n"); }; const createExportNameForSchema = (schema) => { if ((0, boolean_1.isBoolean)(schema)) { return "T"; } const title = schema["title"] ?? "T"; // converting these cases to pascalCase to ensure the resulting name is a // valid name for a typescript type. Based on: https://github.com/xddq/schema2typebox/pull/53 if (title.includes(" ") || title.includes("-") || title.includes("_") || title.includes(".")) { return (0, camelcase_1.default)(title, { pascalCase: true }); } return title; }; /** * Creates custom typebox code to support the JSON schema keyword 'oneOf'. Based * on the suggestion here: https://github.com/xddq/schema2typebox/issues/16#issuecomment-1603731886 */ const createOneOfTypeboxSupportCode = () => { return [ "TypeRegistry.Set('ExtendedOneOf', (schema: any, value) => 1 === schema.oneOf.reduce((acc: number, schema: any) => acc + (Value.Check(schema, value) ? 1 : 0), 0))", "const OneOf = <T extends TSchema[]>(oneOf: [...T], options: SchemaOptions = {}) => Type.Unsafe<Static<TUnion<T>>>({ ...options, [Kind]: 'ExtendedOneOf', oneOf })", ].reduce((acc, curr) => { return acc + curr + "\n\n"; }, ""); }; exports.createOneOfTypeboxSupportCode = createOneOfTypeboxSupportCode; /** * @throws Error */ const createExportedTypeForName = (exportedName) => { if (exportedName.length === 0) { throw new Error("Can't create exported type for a name with length 0."); } const typeName = `${exportedName.charAt(0).toUpperCase()}${exportedName.slice(1)}`; return `export type ${typeName} = Static<typeof ${exportedName}>`; }; const addOptionalModifier = (code, propertyName, requiredProperties) => { return requiredProperties?.includes(propertyName) ? code : `Type.Optional(${code})`; }; const parseObject = (schema) => { const schemaOptions = parseSchemaOptions(schema); const properties = schema.properties; const requiredProperties = schema.required; if (properties === undefined) { return `Type.Unknown()`; } const attributes = Object.entries(properties); // NOTE: Just always quote the propertyName here to make sure we don't run // into issues as they came up before // [here](https://github.com/xddq/schema2typebox/issues/45) or // [here](https://github.com/xddq/schema2typebox/discussions/35). Since we run // prettier as "postprocessor" anyway we will also ensure to still have a sane // output without any unnecessarily quotes attributes. const code = attributes .map(([propertyName, schema]) => { return `"${propertyName}": ${addOptionalModifier((0, exports.collect)(schema), propertyName, requiredProperties)}`; }) .join(",\n"); return schemaOptions === undefined ? `Type.Object({${code}})` : `Type.Object({${code}}, ${schemaOptions})`; }; exports.parseObject = parseObject; const parseEnum = (schema) => { const schemaOptions = parseSchemaOptions(schema); const code = schema.enum.reduce((acc, schema) => { return acc + `${acc === "" ? "" : ","} ${(0, exports.parseType)(schema)}`; }, ""); return schemaOptions === undefined ? `Type.Union([${code}])` : `Type.Union([${code}], ${schemaOptions})`; }; exports.parseEnum = parseEnum; const parseConst = (schema) => { const schemaOptions = parseSchemaOptions(schema); if (Array.isArray(schema.const)) { const code = schema.const.reduce((acc, schema) => { return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.parseType)(schema)}`; }, ""); return schemaOptions === undefined ? `Type.Union([${code}])` : `Type.Union([${code}], ${schemaOptions})`; } // TODO: case where const is object..? if (typeof schema.const === "object") { return "Type.Todo(const with object)"; } if (typeof schema.const === "string") { return schemaOptions === undefined ? `Type.Literal("${schema.const}")` : `Type.Literal("${schema.const}", ${schemaOptions})`; } return schemaOptions === undefined ? `Type.Literal(${schema.const})` : `Type.Literal(${schema.const}, ${schemaOptions})`; }; exports.parseConst = parseConst; const parseUnknown = (_) => { return "Type.Unknown()"; }; exports.parseUnknown = parseUnknown; const parseType = (type) => { if ((0, string_1.isString)(type)) { return `Type.Literal("${type}")`; } else if ((0, schema_matchers_1.isNullType)(type)) { return `Type.Null()`; } else if ((0, number_1.isNumber)(type) || (0, boolean_1.isBoolean)(type)) { return `Type.Literal(${type})`; } else if (Array.isArray(type)) { return `Type.Array([${type.map(exports.parseType)}])`; } else { const code = Object.entries(type).reduce((acc, [key, value]) => { return acc + `${acc === "" ? "" : ",\n"}${key}: ${(0, exports.parseType)(value)}`; }, ""); return `Type.Object({${code}})`; } }; exports.parseType = parseType; const parseAnyOf = (schema) => { const schemaOptions = parseSchemaOptions(schema); const code = schema.anyOf.reduce((acc, schema) => { return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema)}`; }, ""); return schemaOptions === undefined ? `Type.Union([${code}])` : `Type.Union([${code}], ${schemaOptions})`; }; exports.parseAnyOf = parseAnyOf; const parseAllOf = (schema) => { const schemaOptions = parseSchemaOptions(schema); const code = schema.allOf.reduce((acc, schema) => { return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema)}`; }, ""); return schemaOptions === undefined ? `Type.Intersect([${code}])` : `Type.Intersect([${code}], ${schemaOptions})`; }; exports.parseAllOf = parseAllOf; const parseOneOf = (schema) => { const schemaOptions = parseSchemaOptions(schema); const code = schema.oneOf.reduce((acc, schema) => { return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema)}`; }, ""); return schemaOptions === undefined ? `OneOf([${code}])` : `OneOf([${code}], ${schemaOptions})`; }; exports.parseOneOf = parseOneOf; const parseNot = (schema) => { const schemaOptions = parseSchemaOptions(schema); return schemaOptions === undefined ? `Type.Not(${(0, exports.collect)(schema.not)})` : `Type.Not(${(0, exports.collect)(schema.not)}, ${schemaOptions})`; }; exports.parseNot = parseNot; const parseArray = (schema) => { const schemaOptions = parseSchemaOptions(schema); if (Array.isArray(schema.items)) { const code = schema.items.reduce((acc, schema) => { return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema)}`; }, ""); return schemaOptions === undefined ? `Type.Array(Type.Union(${code}))` : `Type.Array(Type.Union(${code}),${schemaOptions})`; } const itemsType = schema.items ? (0, exports.collect)(schema.items) : "Type.Unknown()"; return schemaOptions === undefined ? `Type.Array(${itemsType})` : `Type.Array(${itemsType},${schemaOptions})`; }; exports.parseArray = parseArray; const parseWithMultipleTypes = (schema) => { const code = schema.type.reduce((acc, typeName) => { return (acc + `${acc === "" ? "" : ",\n"} ${(0, exports.parseTypeName)(typeName, schema)}`); }, ""); return `Type.Union([${code}])`; }; exports.parseWithMultipleTypes = parseWithMultipleTypes; const parseTypeName = (type, schema = {}) => { const schemaOptions = parseSchemaOptions(schema); if (type === "number" || type === "integer") { return schemaOptions === undefined ? "Type.Number()" : `Type.Number(${schemaOptions})`; } else if (type === "string") { return schemaOptions === undefined ? "Type.String()" : `Type.String(${schemaOptions})`; } else if (type === "boolean") { return schemaOptions === undefined ? "Type.Boolean()" : `Type.Boolean(${schemaOptions})`; } else if (type === "null") { return schemaOptions === undefined ? "Type.Null()" : `Type.Null(${schemaOptions})`; } else if (type === "object") { return (0, exports.parseObject)(schema); // We don't want to trust on build time checking here, json can contain anything // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (type === "array") { return (0, exports.parseArray)(schema); } throw new Error(`Should never happen..? parseType got type: ${type}`); }; exports.parseTypeName = parseTypeName; const parseSchemaOptions = (schema) => { const properties = Object.entries(schema).filter(([key, _value]) => { return ( // NOTE: To be fair, not sure if we should filter out the title. If this // makes problems one day, think about not filtering it. key !== "title" && key !== "type" && key !== "items" && key !== "allOf" && key !== "anyOf" && key !== "oneOf" && key !== "not" && key !== "properties" && key !== "required" && key !== "const" && key !== "enum"); }); if (properties.length === 0) { return undefined; } const result = properties.reduce((acc, [key, value]) => { acc[key] = value; return acc; }, {}); return JSON.stringify(result); }; //# sourceMappingURL=schema-to-typebox.js.map