schema2typebox
Version:
Creates typebox code from JSON schemas
364 lines (339 loc) • 12.1 kB
text/typescript
import $Refparser from "@apidevtools/json-schema-ref-parser";
import { isBoolean } from "fp-ts/lib/boolean";
import { isNumber } from "fp-ts/lib/number";
import { isString } from "fp-ts/lib/string";
import {
JSONSchema7,
JSONSchema7Definition,
JSONSchema7Type,
JSONSchema7TypeName,
} from "json-schema";
import {
AllOfSchema,
AnyOfSchema,
ArraySchema,
ConstSchema,
EnumSchema,
MultipleTypesSchema,
NotSchema,
ObjectSchema,
OneOfSchema,
UnknownSchema,
isAllOfSchema,
isAnyOfSchema,
isArraySchema,
isConstSchema,
isEnumSchema,
isNotSchema,
isNullType,
isObjectSchema,
isOneOfSchema,
isSchemaWithMultipleTypes,
isUnknownSchema,
} from "./schema-matchers";
type Code = string;
/** Generates TypeBox code from a given JSON schema */
export const schema2typebox = async (jsonSchema: string) => {
const schemaObj = JSON.parse(jsonSchema);
const dereferencedSchema = (await $Refparser.dereference(
schemaObj
)) as JSONSchema7Definition;
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 = collect(dereferencedSchema);
const exportedType = createExportedTypeForName(exportedName);
return `${createImportStatements()}
${typeBoxType.includes("OneOf([") ? createOneOfTypeboxSupportCode() : ""}
${exportedType}
export const ${exportedName} = ${typeBoxType}`;
};
/**
* 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
*/
export const collect = (schema: JSONSchema7Definition): Code => {
// TODO: boolean schema support..?
if (isBoolean(schema)) {
return JSON.stringify(schema);
} else if (isObjectSchema(schema)) {
return parseObject(schema);
} else if (isEnumSchema(schema)) {
return parseEnum(schema);
} else if (isAnyOfSchema(schema)) {
return parseAnyOf(schema);
} else if (isAllOfSchema(schema)) {
return parseAllOf(schema);
} else if (isOneOfSchema(schema)) {
return parseOneOf(schema);
} else if (isNotSchema(schema)) {
return parseNot(schema);
} else if (isArraySchema(schema)) {
return parseArray(schema);
} else if (isSchemaWithMultipleTypes(schema)) {
return parseWithMultipleTypes(schema);
} else if (isConstSchema(schema)) {
return parseConst(schema);
} else if (isUnknownSchema(schema)) {
return parseUnknown(schema);
} else if (schema.type !== undefined && !Array.isArray(schema.type)) {
return parseTypeName(schema.type, schema);
}
throw new Error(
`Unsupported schema. Did not match any type of the parsers. Schema was: ${JSON.stringify(
schema
)}`
);
};
/**
* 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: JSONSchema7Definition) => {
if (isBoolean(schema)) {
return "T";
}
return schema["title"] ?? "T";
};
/**
* 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
*/
export const createOneOfTypeboxSupportCode = (): Code => {
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";
}, "");
};
/**
* @throws Error
*/
const createExportedTypeForName = (exportedName: string) => {
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: Code,
propertyName: string,
requiredProperties: JSONSchema7["required"]
) => {
return requiredProperties?.includes(propertyName)
? code
: `Type.Optional(${code})`;
};
export const parseObject = (schema: ObjectSchema) => {
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(
collect(schema),
propertyName,
requiredProperties
)}`;
})
.join(",\n");
return schemaOptions === undefined
? `Type.Object({${code}})`
: `Type.Object({${code}}, ${schemaOptions})`;
};
export const parseEnum = (schema: EnumSchema) => {
const schemaOptions = parseSchemaOptions(schema);
const code = schema.enum.reduce<string>((acc, schema) => {
return acc + `${acc === "" ? "" : ","} ${parseType(schema)}`;
}, "");
return schemaOptions === undefined
? `Type.Union([${code}])`
: `Type.Union([${code}], ${schemaOptions})`;
};
export const parseConst = (schema: ConstSchema): Code => {
const schemaOptions = parseSchemaOptions(schema);
if (Array.isArray(schema.const)) {
const code = schema.const.reduce<string>((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${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})`;
};
export const parseUnknown = (_: UnknownSchema): Code => {
return "Type.Unknown()";
};
export const parseType = (type: JSONSchema7Type): Code => {
if (isString(type)) {
return `Type.Literal("${type}")`;
} else if (isNullType(type)) {
return `Type.Null()`;
} else if (isNumber(type) || isBoolean(type)) {
return `Type.Literal(${type})`;
} else if (Array.isArray(type)) {
return `Type.Array([${type.map(parseType)}])`;
} else {
const code = Object.entries(type).reduce<string>((acc, [key, value]) => {
return acc + `${acc === "" ? "" : ",\n"}${key}: ${parseType(value)}`;
}, "");
return `Type.Object({${code}})`;
}
};
export const parseAnyOf = (schema: AnyOfSchema): Code => {
const schemaOptions = parseSchemaOptions(schema);
const code = schema.anyOf.reduce<string>((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${collect(schema)}`;
}, "");
return schemaOptions === undefined
? `Type.Union([${code}])`
: `Type.Union([${code}], ${schemaOptions})`;
};
export const parseAllOf = (schema: AllOfSchema): Code => {
const schemaOptions = parseSchemaOptions(schema);
const code = schema.allOf.reduce<string>((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${collect(schema)}`;
}, "");
return schemaOptions === undefined
? `Type.Intersect([${code}])`
: `Type.Intersect([${code}], ${schemaOptions})`;
};
export const parseOneOf = (schema: OneOfSchema): Code => {
const schemaOptions = parseSchemaOptions(schema);
const code = schema.oneOf.reduce<string>((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${collect(schema)}`;
}, "");
return schemaOptions === undefined
? `OneOf([${code}])`
: `OneOf([${code}], ${schemaOptions})`;
};
export const parseNot = (schema: NotSchema): Code => {
const schemaOptions = parseSchemaOptions(schema);
return schemaOptions === undefined
? `Type.Not(${collect(schema.not)})`
: `Type.Not(${collect(schema.not)}, ${schemaOptions})`;
};
export const parseArray = (schema: ArraySchema): Code => {
const schemaOptions = parseSchemaOptions(schema);
if (Array.isArray(schema.items)) {
const code = schema.items.reduce<string>((acc, schema) => {
return acc + `${acc === "" ? "" : ",\n"} ${collect(schema)}`;
}, "");
return schemaOptions === undefined
? `Type.Array(Type.Union(${code}))`
: `Type.Array(Type.Union(${code}),${schemaOptions})`;
}
const itemsType = schema.items ? collect(schema.items) : "Type.Unknown()";
return schemaOptions === undefined
? `Type.Array(${itemsType})`
: `Type.Array(${itemsType},${schemaOptions})`;
};
export const parseWithMultipleTypes = (schema: MultipleTypesSchema): Code => {
const code = schema.type.reduce<string>((acc, typeName) => {
return (
acc + `${acc === "" ? "" : ",\n"} ${parseTypeName(typeName, schema)}`
);
}, "");
return `Type.Union([${code}])`;
};
export const parseTypeName = (
type: JSONSchema7TypeName,
schema: JSONSchema7 = {}
): Code => {
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 parseObject(schema as ObjectSchema);
// 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 parseArray(schema as ArraySchema);
}
throw new Error(`Should never happen..? parseType got type: ${type}`);
};
const parseSchemaOptions = (schema: JSONSchema7): Code | undefined => {
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<Record<string, unknown>>(
(acc, [key, value]) => {
acc[key] = value;
return acc;
},
{}
);
return JSON.stringify(result);
};