openapi-ts-json-schema
Version:
Generate TypeScript-first JSON schemas from OpenAPI definitions
256 lines (255 loc) • 11.2 kB
JavaScript
import { existsSync } from 'fs';
import path from 'node:path';
import { $RefParser } from '@apidevtools/json-schema-ref-parser';
import get from 'lodash.get';
import { SCHEMA_ID_SYMBOL, addSchemaToMetaData, clearFolder, convertOpenApiDocumentDefinitionsToJsonSchema, convertOpenApiPathsParameters, formatTypeScript, makeId, makeRelativeImportPath, makeSchemaFileContents, parseSingleItemPath, patchJsonSchemaDefinitions, refToId, saveFile, saveSchemaFiles, } from './utils/index.js';
/**
* @package openapi‑ts-json-schema
*
* Convert OpenAPI definitions to TypeScript JSON Schema modules.
*
* @remarks
* This function resolves and dereferences `$ref`s in an OpenAPI document, converts OpenAPI schema objects
* to JSON Schema, and writes out `.ts` modules exporting `as const` JSON schemas. It mirrors your OpenAPI
* structure in the output and supports plugins (e.g. Fastify integration).
*
* @param options - Configuration for the conversion
* @param options.openApiDocument - Path to an OpenAPI document (JSON or YAML)
* @param options.targets - OpenAPI definition paths to generate JSON Schemas from
* @param options.targets.collections - Array of paths pointing to objects/records of definitions, where each entry will be generated (eg: `["components.schemas"]`)
* @param options.targets.single - Array of paths pointing to individual definitions to generate (eg: `["paths./users/{id}"]`)
* @param options.refHandling - Strategy for `$ref` processing (`"import"` | `"inline"` | `"keep"`) (default: `"import"`)
* @param options.moduleSystem - Controls how import specifiers are written in generated artifacts. Configure this option based on whether the consuming project is using CommonJS or ECMAScript modules
* @param options.outputPath - Directory where generated schemas will be written. (default: `schemas-autogenerated`)
* @param options.silent - If `true`, suppress logging output (default: `false`)
*
* @param options.idMapper - Optional function to map internal schema id strings to custom `$id` or import names
* @param options.schemaPatcher - Hook called for every generated schema node, allowing programmatic mutation before output
* @param options.plugins - Optional list of plugins for custom generation behavior
*
* @returns A Promise resolving to an object with:
* - `outputPath`: the folder path where schemas were generated
* - `metaData`: a map of schema identities to metadata (ids, file paths, import paths, etc.)
*
* @example
* ```ts
* import { openapiToTsJsonSchema } from "openapi‑ts-json-schema";
*
* await openapiToTsJsonSchema({
* openApiDocument: "path/to/spec.yaml",
* targets: {
* collections: ["paths"],
* },
* });
* ```
*/
export async function openapiToTsJsonSchema(options) {
const optionsWithDefaults = {
refHandling: 'import',
moduleSystem: 'esm',
idMapper: ({ id }) => id,
plugins: [],
...options,
targets: {
collections: options.targets.collections ?? [],
single: options.targets.single ?? [],
},
};
const { plugins } = optionsWithDefaults;
// Execute plugins onInit method
for (const { onInit } of plugins) {
if (onInit) {
await onInit({
options: optionsWithDefaults,
});
}
}
const { openApiDocument: openApiDocumentRelative, schemaPatcher, outputPath: providedOutputPath, silent, refHandling, moduleSystem, idMapper, targets, } = optionsWithDefaults;
if (targets.collections.length === 0 &&
targets.single.length === 0 &&
!silent) {
console.log(`[openapi-ts-json-schema] ⚠️ No schemas will be generated since targets option is empty`);
}
[...targets.collections, ...targets.single].forEach((targetPath) => {
if (path.isAbsolute(targetPath)) {
throw new Error(`[openapi-ts-json-schema] "targets" must define relative paths. "${targetPath}" found.`);
}
});
const openApiDocumentPath = path.resolve(openApiDocumentRelative);
if (!existsSync(openApiDocumentPath)) {
throw new Error(`[openapi-ts-json-schema] Provided OpenAPI definition path doesn't exist: ${openApiDocumentPath}`);
}
const outputPath = providedOutputPath ??
path.resolve(path.dirname(openApiDocumentPath), 'schemas-autogenerated');
await clearFolder(outputPath);
const openApiParser = new $RefParser();
const jsonSchemaParser = new $RefParser();
// Resolve and inline external $ref definitions
// @ts-expect-error @apidevtools/json-schema-ref-parser is meant for JSON schemas but here we use it with OAS documents
const bundledOpenApiDocument = await openApiParser.bundle(openApiDocumentPath);
// Convert oas definitions to JSON schema (excluding paths and parameter objects)
const openApiDocumentWithJsonSchemaDefinitions = convertOpenApiDocumentDefinitionsToJsonSchema(bundledOpenApiDocument);
const patchedOpenApiDocument = patchJsonSchemaDefinitions(openApiDocumentWithJsonSchemaDefinitions, schemaPatcher);
const inlinedRefs = new Map();
// Inline and collect internal $ref definitions
// @ts-expect-error @apidevtools/json-schema-ref-parser is meant for JSON schemas but here we use it with OAS documents
const dereferencedOpenApiDocument = await jsonSchemaParser.dereference(patchedOpenApiDocument, {
dereference: {
// @ts-expect-error onDereference seems not to be properly typed
onDereference: (ref, inlinedSchema) => {
const id = refToId(ref);
// Keep track of inlined refs
if (!inlinedRefs.has(id)) {
// Shallow copy the ref schema to avoid the mutations below
inlinedRefs.set(id, {
// @ts-expect-error Spread types may only be created from object types
openApiDefinition: openApiParser.$refs.get(ref),
jsonSchema: {
// @ts-expect-error Spread types may only be created from object types
...jsonSchemaParser.$refs.get(ref),
},
});
}
/**
* mark inlined ref objects with a "SCHEMA_ID_SYMBOL"
* to retrieve their id once inlined
*/
inlinedSchema[SCHEMA_ID_SYMBOL] = id;
/**
* "inline" refHandling support:
* add a $ref comment to each inlined schema with the original ref value.
* See: https://github.com/kaelzhang/node-comment-json
*/
if (refHandling === 'inline') {
inlinedSchema[Symbol.for('before')] = [
{
type: 'LineComment',
value: ` $ref: "${ref}"`,
},
];
}
},
},
});
const jsonSchema = convertOpenApiPathsParameters(dereferencedOpenApiDocument);
const schemaMetaDataMap = new Map();
/**
* Create meta data for each target
*/
for (const path of targets.single) {
const jsonSchemaDefinition = get(jsonSchema, path);
const openApiDefinition = get(bundledOpenApiDocument, path);
if (!openApiDefinition) {
throw new Error(`[openapi-ts-json-schema] target not found in OAS definition: "${path}"`);
}
// Handle single definition path
const { schemaName, schemaRelativeDirName } = parseSingleItemPath(path);
const id = makeId({
schemaRelativeDirName,
schemaName,
});
addSchemaToMetaData({
id,
$id: idMapper({ id }),
schemaMetaDataMap,
openApiDefinition,
jsonSchema: jsonSchemaDefinition,
outputPath,
isRef: inlinedRefs.has(id),
shouldBeGenerated: true,
});
}
for (const path of targets.collections) {
const jsonSchemaDefinitions = get(jsonSchema, path);
const openApiDefinitions = get(bundledOpenApiDocument, path);
if (!openApiDefinitions) {
throw new Error(`[openapi-ts-json-schema] target not found in OAS definition: "${path}"`);
}
// Handle collection definition path
for (const schemaName in jsonSchemaDefinitions) {
const id = makeId({
schemaRelativeDirName: path,
schemaName,
});
addSchemaToMetaData({
id,
$id: idMapper({ id }),
schemaMetaDataMap,
openApiDefinition: openApiDefinitions[schemaName],
jsonSchema: jsonSchemaDefinitions[schemaName],
outputPath,
isRef: inlinedRefs.has(id),
shouldBeGenerated: true,
});
}
}
/**
* Create meta data for each $ref schemas which have been previously dereferenced.
*/
for (const [id, { openApiDefinition, jsonSchema }] of inlinedRefs) {
/**
* In "inline" mode $ref schemas not explicitly marked for generation
* should not be generated
*
* All the other "refHandling" modes generate all $ref schemas
*/
let shouldBeGenerated = true;
if (refHandling === 'inline' && !schemaMetaDataMap.has(id)) {
shouldBeGenerated = false;
}
addSchemaToMetaData({
id,
$id: idMapper({ id }),
schemaMetaDataMap,
openApiDefinition,
jsonSchema,
outputPath,
isRef: true,
shouldBeGenerated,
});
}
const returnPayload = {
outputPath,
metaData: { schemas: schemaMetaDataMap },
};
// Execute plugins onBeforeGeneration method
for (const { onBeforeGeneration } of plugins) {
if (onBeforeGeneration) {
await onBeforeGeneration({
...returnPayload,
options: optionsWithDefaults,
utils: {
makeRelativeImportPath,
formatTypeScript,
saveFile,
},
});
}
}
// Generate schemas
await makeSchemaFileContents({
refHandling,
schemaMetaDataMap,
idMapper,
moduleSystem,
});
// Execute plugins onBeforeGeneration method
for (const { onBeforeSaveFile } of plugins) {
if (onBeforeSaveFile) {
await onBeforeSaveFile({
...returnPayload,
options: optionsWithDefaults,
utils: {
makeRelativeImportPath,
formatTypeScript,
saveFile,
},
});
}
}
await saveSchemaFiles({ schemaMetaDataMap });
if (!silent) {
console.log(`[openapi-ts-json-schema] ✅ JSON schema models generated at ${outputPath}`);
}
return returnPayload;
}