schema2typebox
Version:
Creates typebox code from JSON schemas
336 lines (335 loc) • 13.9 kB
JavaScript
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
;