@paima/schema2typebox
Version:
Creates typebox code from JSON schemas
382 lines (381 loc) • 16.4 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, protobuf) => {
const entries = new Map();
const schemas = Array.isArray(jsonSchema) ? jsonSchema : [jsonSchema];
const parsedSchemas = await Promise.all(schemas.map((jsonSchema) => {
return json_schema_ref_parser_1.default.dereference(JSON.parse(jsonSchema));
}));
for (let i = 0; i < parsedSchemas.length; i++) {
const parsedSchema = parsedSchemas[i];
parseSchema(parsedSchema, entries, protobuf);
}
const typeBoxType = Array.from(entries.entries())
.map(([key, value]) => {
return `${key}: ${value}`;
})
.join(",\n");
const typeAliases = Array.from(entries.keys())
.map((key) => {
return `export const ${key} = Module.Import('${key}');
${createExportedTypeForName(key)};`;
})
.join("\n");
return `${createImportStatements()}
${typeBoxType.includes("OneOf([") ? (0, exports.createOneOfTypeboxSupportCode)() : ""}
export const Module = Type.Module({${typeBoxType}});
${typeAliases}`;
};
exports.schema2typebox = schema2typebox;
const parseSchema = (dereferencedSchema, entries, protobuf) => {
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;
}
(0, exports.collect)(dereferencedSchema, entries, protobuf);
};
/**
* 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, entries, protobuf) => {
if (typeof schema === "object" && schema.$id !== undefined) {
const exportedName = createExportNameForSchema(schema);
if (entries.has(exportedName)) {
return `Type.Ref("${exportedName}")`;
}
// we add a placeholder to fill later
// this is so that we can support recursive types (we can tell we've already seen this entry)
entries.set(exportedName, ``);
}
const innerSchema = (() => {
// 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, entries, protobuf);
}
else if ((0, schema_matchers_1.isEnumSchema)(schema)) {
return (0, exports.parseEnum)(schema, protobuf);
}
else if ((0, schema_matchers_1.isAnyOfSchema)(schema)) {
return (0, exports.parseAnyOf)(schema, entries, protobuf);
}
else if ((0, schema_matchers_1.isAllOfSchema)(schema)) {
return (0, exports.parseAllOf)(schema, entries, protobuf);
}
else if ((0, schema_matchers_1.isOneOfSchema)(schema)) {
return (0, exports.parseOneOf)(schema, entries, protobuf);
}
else if ((0, schema_matchers_1.isNotSchema)(schema)) {
return (0, exports.parseNot)(schema, entries, protobuf);
}
else if ((0, schema_matchers_1.isArraySchema)(schema)) {
return (0, exports.parseArray)(schema, entries, protobuf);
}
else if ((0, schema_matchers_1.isSchemaWithMultipleTypes)(schema)) {
return (0, exports.parseWithMultipleTypes)(schema, entries, protobuf);
}
else if ((0, schema_matchers_1.isConstSchema)(schema)) {
return (0, exports.parseConst)(schema, protobuf);
}
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, entries, protobuf);
}
throw new Error(`Unsupported schema. Did not match any type of the parsers. Schema was: ${JSON.stringify(schema)}`);
})();
if (typeof schema === "object" && schema.$id !== undefined) {
const exportedName = createExportNameForSchema(schema);
entries.set(exportedName, innerSchema);
return `Type.Ref("${exportedName}")`;
}
return innerSchema;
};
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, entries, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
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, entries, protobuf), propertyName, requiredProperties)}`;
})
.join(",\n");
return schemaOptions === undefined
? `Type.Object({${code}})`
: `Type.Object({${code}}, ${schemaOptions})`;
};
exports.parseObject = parseObject;
const parseEnum = (schema, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
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, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
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, entries, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
const code = schema.anyOf.reduce((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema, entries, protobuf)}`;
}, "");
return schemaOptions === undefined
? `Type.Union([${code}])`
: `Type.Union([${code}], ${schemaOptions})`;
};
exports.parseAnyOf = parseAnyOf;
const parseAllOf = (schema, entries, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
const code = schema.allOf.reduce((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema, entries, protobuf)}`;
}, "");
return schemaOptions === undefined
? `Type.Intersect([${code}])`
: `Type.Intersect([${code}], ${schemaOptions})`;
};
exports.parseAllOf = parseAllOf;
const parseOneOf = (schema, entries, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
const code = schema.oneOf.reduce((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema, entries, protobuf)}`;
}, "");
return schemaOptions === undefined
? `OneOf([${code}])`
: `OneOf([${code}], ${schemaOptions})`;
};
exports.parseOneOf = parseOneOf;
const parseNot = (schema, entries, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
return schemaOptions === undefined
? `Type.Not(${(0, exports.collect)(schema.not, entries, protobuf)})`
: `Type.Not(${(0, exports.collect)(schema.not, entries, protobuf)}, ${schemaOptions})`;
};
exports.parseNot = parseNot;
const parseArray = (schema, entries, protobuf) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
if (Array.isArray(schema.items)) {
const code = schema.items.reduce((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${(0, exports.collect)(schema, entries, protobuf)}`;
}, "");
return schemaOptions === undefined
? `Type.Array(Type.Union(${code}))`
: `Type.Array(Type.Union(${code}),${schemaOptions})`;
}
const itemsType = schema.items
? (0, exports.collect)(schema.items, entries, protobuf)
: "Type.Unknown()";
return schemaOptions === undefined
? `Type.Array(${itemsType})`
: `Type.Array(${itemsType},${schemaOptions})`;
};
exports.parseArray = parseArray;
const parseWithMultipleTypes = (schema, entries, protobuf) => {
const code = schema.type.reduce((acc, typeName) => {
return (acc +
`${acc === "" ? "" : ",\n"} ${(0, exports.parseTypeName)(typeName, schema, entries, protobuf)}`);
}, "");
return `Type.Union([${code}])`;
};
exports.parseWithMultipleTypes = parseWithMultipleTypes;
const parseTypeName = (type, schema = {}, entries = new Map(), protobuf = false) => {
const schemaOptions = parseSchemaOptions(schema, protobuf);
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, entries, protobuf);
// 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, entries, protobuf);
}
throw new Error(`Should never happen..? parseType got type: ${type}`);
};
exports.parseTypeName = parseTypeName;
const parseSchemaOptions = (schema, protobuf) => {
const properties = Object.entries(schema).filter(([key, _value]) => {
if (protobuf) {
if (key === "additionalProperties") {
return false;
}
}
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" &&
key !== "patternProperties" &&
key !== "$defs");
});
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