@spec2ts/jsonschema
Version:
Utility to convert JSON Schemas to Typescript using TypeScript native compiler
228 lines (227 loc) • 10.8 kB
JavaScript
import $RefParser from "@apidevtools/json-schema-ref-parser";
import * as core from "@spec2ts/core";
import path from "node:path";
import ts from "typescript";
//#region src/lib/core-parser.ts
/**
* Creates a type node from a given schema.
* Delegates to getBaseTypeFromSchema internally and optionally adds a union with null.
*/
function getTypeFromSchema(schema, context) {
const type = getBaseTypeFromSchema(schema, context);
return isNullable(schema) ? ts.factory.createUnionTypeNode([type, core.keywordType.null]) : type;
}
/** This is the very core of the Schema to TS conversion - it takes a schema and returns the appropriate type. */
function getBaseTypeFromSchema(schema, context) {
if (!schema) return getAnyType(context);
if (isReference(schema)) return getReferenceType(schema, context);
if (schema.oneOf) return ts.factory.createUnionTypeNode(getTypeFromDefinitions(schema.oneOf, context));
if (schema.anyOf) return ts.factory.createUnionTypeNode(getTypeFromDefinitions(schema.anyOf, context));
if (schema.allOf) return ts.factory.createIntersectionTypeNode(getTypeFromDefinitions(schema.allOf, context));
if (schema.prefixItems) {
const types = getTypeFromDefinitions(schema.prefixItems, context);
if (schema.items ?? true) types.push(ts.factory.createRestTypeNode(getBaseTypeFromSchema({ items: schema.items ?? true }, context)));
return ts.factory.createTupleTypeNode(types);
}
if (schema.items) {
if (Array.isArray(schema.items)) return ts.factory.createArrayTypeNode(ts.factory.createUnionTypeNode(getTypeFromDefinitions(schema.items, context)));
if (typeof schema.items === "boolean") return ts.factory.createArrayTypeNode(getAnyType(context));
return ts.factory.createArrayTypeNode(getTypeFromSchema(schema.items, context));
}
if (schema.properties || schema.additionalProperties) return getTypeFromProperties(schema.properties || {}, schema.required || [], schema.additionalProperties, context);
if (typeof schema.const !== "undefined") return ts.factory.createLiteralTypeNode(((s) => {
switch (typeof s) {
case "string": return ts.factory.createStringLiteral(s);
case "number": return ts.factory.createNumericLiteral(s.toString());
case "boolean": return s ? ts.factory.createTrue() : ts.factory.createFalse();
default: return ts.factory.createStringLiteral(String(s));
}
})(schema.const));
if (schema.enum) return ts.factory.createUnionTypeNode(schema.enum.map((s) => ts.factory.createLiteralTypeNode(typeof s === "string" ? ts.factory.createStringLiteral(s) : typeof s === "number" ? ts.factory.createNumericLiteral(s.toString()) : ts.factory.createStringLiteral(String(s)))));
if (schema.format == "binary") return ts.factory.createTypeReferenceNode("Blob", []);
if (context.options.enableDate && (schema.format === "date" || schema.format === "date-time")) if (context.options.enableDate === true || context.options.enableDate === "strict") return ts.factory.createTypeReferenceNode("Date");
else return ts.factory.createUnionTypeNode([core.keywordType.string, ts.factory.createTypeReferenceNode("Date")]);
if (schema.type) return getTypeFromStandardTypes(schema.type, context);
return getAnyType(context);
}
/** Recursively creates a type literal with the given props. */
function getTypeFromProperties(props, required, additionalProperties, context) {
const members = Object.keys(props).map((name) => {
const schema = props[name];
const isRequired = required && required.includes(name);
return core.createPropertySignature({
questionToken: !isRequired,
name,
type: getTypeFromDefinition(schema, context)
});
});
if (additionalProperties) {
const type = additionalProperties === true ? core.keywordType.any : getTypeFromSchema(additionalProperties, context);
members.push(core.createIndexSignature(type));
}
return ts.factory.createTypeLiteralNode(members);
}
/** Creates types from definitions. */
function parseDefinitions(schema, context) {
if (!schema || isReference(schema) || !schema.definitions) return;
return Object.keys(schema.definitions).forEach((refName) => parseReference({ $ref: `#/definitions/${refName}` }, context));
}
/** Extract types from a Type Definition array. */
function getTypeFromDefinitions(definitions, context) {
return definitions.map((s) => getTypeFromDefinition(s, context));
}
/** Get a type from a schema definition. */
function getTypeFromDefinition(schema, context) {
if (typeof schema === "boolean") return getAnyType(context);
return getTypeFromSchema(schema, context);
}
/** Get a type from standard types (eg: string, number, boolean...). */
function getTypeFromStandardTypes(type, context) {
if (!type) return getAnyType(context);
if (Array.isArray(type)) return ts.factory.createUnionTypeNode(type.map((t) => getTypeFromStandardTypes(t, context)));
if (core.isKeywordTypeName(type)) return core.keywordType[type];
if (type === "integer") return core.keywordType.number;
return getAnyType(context);
}
function parseReference(obj, context) {
const $ref = applyRefPrefix(obj.$ref, context);
const { id, schema, path } = resolveReferenceDetails({ $ref }, context);
let ref = context.refs[id];
if (!ref) {
const name = getSchemaName(schema, $ref);
ref = context.refs[id] = {
$ref,
name,
schema,
path,
node: ts.factory.createTypeReferenceNode(name, void 0),
isRemote: $ref.startsWith("http"),
isLocal: $ref.startsWith("#/")
};
(context.options.parseReference || defaultParseReference)(ref, context);
}
return ref;
}
function defaultParseReference(ref, context) {
if (ref.isRemote || ref.isLocal) {
const type = getTypeFromSchema(ref.schema, createRefContext(ref, context));
context.aliases.push(core.createTypeOrInterfaceDeclaration({
modifiers: [core.modifier.export],
name: ref.name,
type
}));
} else {
const importPath = getImportFromRef(ref.$ref);
if (importPath) addOrUpdateImport(importPath, ref, context);
}
}
function getReferenceType(obj, context) {
const { node } = parseReference(obj, context);
return node;
}
function resolveReference(obj, context) {
if (!isReference(obj)) return obj;
return context.$refs.get(obj.$ref);
}
function resolveReferenceDetails(obj, context) {
const pointer = context.$refs._resolve(obj.$ref, "");
const cwd = context.options.cwd || process.cwd();
return {
id: pointer.path,
path: path.relative(cwd, pointer.$ref.path),
schema: pointer.value
};
}
function isReference(obj) {
return typeof obj === "object" && !!obj && "$ref" in obj;
}
async function createContext(schema, options) {
const sanitize = (cwd) => cwd.endsWith("/") ? cwd : cwd + "/";
const cwd = sanitize(options.cwd || process.cwd());
return {
$refs: await $RefParser.resolve(cwd, schema, {}),
schema,
refs: {},
parseReference: defaultParseReference,
imports: [],
aliases: [],
options,
names: {}
};
}
function createRefContext(ref, context) {
if (!ref.path) return context;
const refPrefix = !context.refPrefix || context.refPrefix === ref.path ? ref.path : path.join(path.dirname(context.refPrefix), ref.path);
return {
...context,
refPrefix
};
}
function getSchemaName(schema, path) {
if (schema.title) return pascalCase(schema.title);
if (schema.$id) return pascalCase(schema.$id.replace(/.+\//, "").replace(/\.(json)|(ya?ml)$/, ""));
if (!path) throw new Error("Can't determine Schema name");
return pascalCase(path.replace(/.+\//, "").replace(/\.(json)|(ya?ml)$/, "").replace(/#$/, ""));
}
function getAnyType(context) {
return context.options.avoidAny ? core.keywordType.unknown : core.keywordType.any;
}
function isNullable(schema) {
return !!(schema && schema.nullable);
}
function pascalCase(name) {
return name.match(/[a-z0-9]+/gi)?.map((word) => word.charAt(0).toUpperCase() + word.substr(1)).join("") ?? "";
}
function addOrUpdateImport(importPath, ref, context) {
const importNamedBinding = getSchemaName(ref.schema, ref.$ref);
const importDeclaration = context.imports.find((i) => core.getString(i.moduleSpecifier) === importPath);
if (importDeclaration && importDeclaration.importClause?.namedBindings && ts.isNamedImports(importDeclaration.importClause.namedBindings)) {
const elements = importDeclaration.importClause.namedBindings.elements;
if (elements.some((e) => e.name.text === importNamedBinding)) return;
const newImportDeclaration = ts.factory.updateImportDeclaration(importDeclaration, importDeclaration.modifiers, ts.factory.updateImportClause(importDeclaration.importClause, importDeclaration.importClause.phaseModifier, importDeclaration.importClause.name, ts.factory.updateNamedImports(importDeclaration.importClause.namedBindings, ts.factory.createNodeArray([...elements, core.createImportSpecifier(importNamedBinding)]))), importDeclaration.moduleSpecifier, void 0);
context.imports.splice(context.imports.indexOf(importDeclaration), 1, newImportDeclaration);
} else context.imports.push(core.createNamedImportDeclaration({
isTypeOnly: true,
moduleSpecifier: importPath,
bindings: [importNamedBinding]
}));
}
function getImportFromRef(ref) {
let [importPath] = ref.split("#");
if (!importPath) return;
importPath = importPath.replace(/(\.json)|(\.ya?ml)$/, "");
if (!importPath.startsWith("./")) importPath = "./" + importPath;
return importPath;
}
function applyRefPrefix(ref, context) {
if (!context.refPrefix) return ref;
if (ref.startsWith("http")) return ref;
if (ref.startsWith("#")) return context.refPrefix + ref;
return path.join(path.dirname(context.refPrefix), ref);
}
//#endregion
//#region src/lib/schema-parser.ts
async function parseSchemaFile(file, options = {}) {
const schema = await $RefParser.parse(file);
return parseSchema(schema, {
name: getSchemaName(schema, file),
cwd: path.resolve(path.dirname(file)) + "/",
...options
});
}
async function parseSchema(schema, options = {}) {
const context = await createContext(schema, options);
const type = getTypeFromSchema(context.schema, context);
parseDefinitions(context.schema, context);
const res = [...context.imports, ...context.aliases];
if ((type === core.keywordType.any || type === core.keywordType.unknown) && !context.schema.type && context.schema.definitions) return res;
let decla = core.createTypeOrInterfaceDeclaration({
modifiers: [core.modifier.export],
name: options.name || getSchemaName(context.schema),
type
});
if (schema.description) decla = core.addComment(decla, schema.description);
return [...res, decla];
}
//#endregion
export { resolveReferenceDetails as _, getAnyType as a, getSchemaName as c, isNullable as d, isReference as f, resolveReference as g, pascalCase as h, createRefContext as i, getTypeFromProperties as l, parseReference as m, parseSchemaFile as n, getBaseTypeFromSchema as o, parseDefinitions as p, createContext as r, getReferenceType as s, parseSchema as t, getTypeFromSchema as u };