Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-cows-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-ts-json-schema": minor
---

`metaData.schemas` entry registered by id instead of `$ref`
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ if (validate(data)) {
| **definitionPathsToGenerateFrom** _(required)_ | `string[]` | OpenApi definition object paths to generate the JSON schemas from. Only matching paths will be generated. Supports dot notation: `["components.schemas"]`. | - |
| **refHandling** | | | |
| **refHandling.strategy** | `"import" \| "inline" \| "keep"` | `"import"`: generate and import `$ref` schemas.<br/>`"inline"`: inline `$ref` schemas.<br/>`"keep"`: keep `$ref` values. | `"import"` |
| **refHandling.refMapper** | `(input: {ref: string}) => string` | Customize generated `$ref` values (only `keep` strategy) | - |
| **refHandling.refMapper** | `(input: {id: string}) => string` | Customize generated `$ref` values (only `keep` strategy) | - |
| **schemaPatcher** | `(params: { schema: JSONSchema }) => void` | Dynamically patch generated JSON schemas. The provided function will be invoked against every single JSON schema node. | - |
| **outputPath** | `string` | Path where the generated schemas will be saved. Defaults to `/schemas-autogenerated` in the same directory of `openApiSchema`. | - |
| **plugins** | `ReturnType<Plugin>[]` | A set of optional plugins to generate extra custom output. See [plugins docs](./docs/plugins.md). | - |
Expand Down Expand Up @@ -112,7 +112,7 @@ Beside generating the expected schema files under `outputPath`, `openapiToTsJson
metaData: {
// Meta data of the generated schemas
schemas: Map<
// OpenAPI ref. Eg: "#/components/schemas/MySchema"
// Schema internal di. Eg: "/components/schemas/MySchema"
string,
{
id: string;
Expand Down
24 changes: 15 additions & 9 deletions docs/developer-notes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Developer's notes

## Internal schema ids

Each processed schemas is assigned with a unique internal id holding schema name and path information `/<path>/<name>`.

Eg: `/components/schemas/SchemaName`.

Internal ids are used to refer to any specific schemas and retrieve schema path and name.

## Remote $ref handling

Remote/external `$ref`s (`Pet.yaml`, `definitions.json#/Pet`) get always immediately dereferenced by fetching the specs and inlining the relevant schemas.
Expand All @@ -11,34 +19,34 @@ Remote/external `$ref`s (`Pet.yaml`, `definitions.json#/Pet`) get always immedia
At the time of writing the implementation is build around `@apidevtools/json-schema-ref-parser`'s `dereference` method options and works as follows:

1. Schemas get deferenced with `@apidevtools/json-schema-ref-parser`'s `dereference` method which inlines relevant `$ref` schemas
2. Inlined schemas get marked with a symbol property holding the original `$ref` value (`#/foo/Bar`)
2. Inlined schemas get marked with a symbol property holding the internal schema id (`/components/schemas/Bar`)

```ts
{
bar: {
[Symbol('ref')]: '#/components/schemas/Bar',
[Symbol('id')]: '/components/schemas/Bar',
// ...Inlined schema props
}
}
```

3. Inlined and dereferenced schemas get traversed and all schemas marked with `Symbol('ref')` prop get replaced with a **string placeholder** holding the original `$ref` value. Note that string placeholders can be safely stringified.
1. Inlined and dereferenced schemas get traversed and all schemas marked with `Symbol('id')` prop get replaced with a **string placeholder** holding the original internal schema id. Note that string placeholders can be safely stringified.

```ts
{
bar: '_OTJS-START_#/components/schemas/Bar_OTJS-END_';
bar: '_OTJS-START_/components/schemas/Bar_OTJS-END_';
}
```

Note: alias definitions (eg. `Foo: "#components/schemas/Bar"`) will result in a plain **string placeholder**.

```ts
'_OTJS-START_#/components/schemas/Bar_OTJS-END_';
'_OTJS-START_/components/schemas/Bar_OTJS-END_';
```

4. Inlined and dereferenced schemas get stringified and parsed to retrieve **string placeholders** and the contained original `$ref` value
1. Inlined and dereferenced schemas get stringified and parsed to retrieve **string placeholders** and their internal id value

5. For each **string placeholder** found, an import statement to the relevant `$ref` schema is prepended and the placeholder replaced with the imported schema name.
2. For each **string placeholder** found, an import statement to the relevant `$ref` schema is prepended and the placeholder replaced with the imported schema name.

```ts
import Bar from '../foo/Bar';
Expand All @@ -48,8 +56,6 @@ export default {
} as const
```

This process could be definitely shorter if `@apidevtools/json-schema-ref-parser`'s `dereference` method allowed to access the parent object holding the `$ref` value to be replaced. In that case step 2 could be skipped and the ref object could be immediately replaced with the relevant **string placeholder**.

## `refHandling`: keep

`keep` option was implemented as last, and it currently follows the same flow as the `import` except for point 5, where schemas with **string placeholders** are replaced with the an actual `$ref` value.
Expand Down
25 changes: 14 additions & 11 deletions src/openapiToTsJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import get from 'lodash.get';
import {
clearFolder,
makeTsJsonSchemaFiles,
REF_SYMBOL,
SCHEMA_ID_SYMBOL,
convertOpenApiToJsonSchema,
convertOpenApiPathsParameters,
addSchemaToMetaData,
pathToRef,
makeId,
formatTypeScript,
saveFile,
makeRelativeModulePath,
refToId,
} from './utils';
import type {
SchemaMetaDataMap,
Expand Down Expand Up @@ -82,17 +83,19 @@ export async function openapiToTsJsonSchema(
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(ref)) {
if (!inlinedRefs.has(id)) {
// Make a shallow copy of the ref schema to save it from the mutations below
inlinedRefs.set(ref, { ...inlinedSchema });
inlinedRefs.set(id, { ...inlinedSchema });

/**
* "import" refHandling support:
* mark inlined ref objects with a "REF_SYMBOL" to retrieve their
* mark inlined ref objects with a "SCHEMA_ID_SYMBOL" to retrieve their
* original $ref value once inlined
*/
inlinedSchema[REF_SYMBOL] = ref;
inlinedSchema[SCHEMA_ID_SYMBOL] = id;

/**
* "inline" refHandling support:
Expand Down Expand Up @@ -122,9 +125,9 @@ export async function openapiToTsJsonSchema(
* $ref schemas to be generated no matter of
*/
if (refHandling.strategy === 'import' || refHandling.strategy === 'keep') {
for (const [ref, schema] of inlinedRefs) {
for (const [id, schema] of inlinedRefs) {
addSchemaToMetaData({
ref,
id,
schemaMetaDataMap,
schema,
outputPath,
Expand All @@ -141,17 +144,17 @@ export async function openapiToTsJsonSchema(

for (const schemaName in definitionSchemas) {
// Create expected OpenAPI ref
const ref = pathToRef({
const id = makeId({
schemaRelativeDirName: definitionPath,
schemaName,
});

addSchemaToMetaData({
ref,
id,
schemaMetaDataMap,
schema: definitionSchemas[schemaName],
outputPath,
isRef: inlinedRefs.has(ref),
isRef: inlinedRefs.has(id),
});
}
}
Expand Down
5 changes: 2 additions & 3 deletions src/plugins/fastifyIntegrationPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Plugin } from '../types';
import { refToId } from '../utils';

const OUTPUT_FILE_NAME = 'fastify-integration.ts';
const OPEN_API_COMPONENTS_SCHEMAS_PATH = '/components/schemas/';
Expand All @@ -19,12 +18,12 @@ const fastifyIntegrationPlugin: Plugin<PluginOptions | void> = ({
// Force "keep" refHandling
options.refHandling = {
strategy: 'keep',
refMapper: ({ ref }) => {
refMapper: ({ id }) => {
/**
* Replace original $ref values with internal schema id which
* the schema is registered with via Fastify's `addSchema`
*/
return refToId(ref);
return id;
},
};
},
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type SchemaPatcher = (params: { schema: JSONSchema }) => void;
export type RefHandling =
| { strategy: 'import' }
| { strategy: 'inline' }
| { strategy: 'keep'; refMapper?: (input: { ref: string }) => string };
| { strategy: 'keep'; refMapper?: (input: { id: string }) => string };

import type {
makeRelativeModulePath,
Expand Down
17 changes: 8 additions & 9 deletions src/utils/addSchemaToMetaData.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
import path from 'node:path';
// @ts-expect-error no type defs for namify
import namify from 'namify';
import { parseRef, refToPath, filenamify, refToId } from '.';
import { filenamify, parseId } from '.';
import type { SchemaMetaDataMap, SchemaMetaData, JSONSchema } from '../types';

/*
* Just an utility function to add entries to SchemaMetaDataMap Map keyed by ref
*/
export function addSchemaToMetaData({
ref,
id,
schemaMetaDataMap,
schema,
isRef,
// Options
outputPath,
}: {
ref: string;
id: string;
schemaMetaDataMap: SchemaMetaDataMap;
schema: JSONSchema;
isRef: boolean;
outputPath: string;
}): void {
// Do not override existing meta info of inlined schemas
if (!schemaMetaDataMap.has(ref)) {
const refPath = parseRef(ref);
const { schemaRelativeDirName, schemaName } = refToPath(ref);
if (!schemaMetaDataMap.has(id)) {
const { schemaRelativeDirName, schemaName } = parseId(id);
const absoluteDirName = path.join(outputPath, schemaRelativeDirName);
const schemaFileName = filenamify(schemaName);
const absoluteImportPath = path.join(absoluteDirName, schemaFileName);

const metaInfo: SchemaMetaData = {
id: refToId(ref),
uniqueName: namify(refPath),
id,
uniqueName: namify(id),
isRef,
originalSchema: schema,

Expand All @@ -40,6 +39,6 @@ export function addSchemaToMetaData({
absolutePath: absoluteImportPath + '.ts',
};

schemaMetaDataMap.set(ref, metaInfo);
schemaMetaDataMap.set(id, metaInfo);
}
}
9 changes: 4 additions & 5 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ export { makeTsJsonSchema } from './makeTsJsonSchema';
export { convertOpenApiPathsParameters } from './convertOpenApiPathsParameters';
export { convertOpenApiToJsonSchema } from './convertOpenApiToJsonSchema';
export { makeTsJsonSchemaFiles } from './makeTsJsonSchemaFiles';
export { parseRef } from './parseRef';
export { refToPath } from './refToPath';
export { parseId } from './parseId';
export { refToId } from './refToId';
export { pathToRef } from './pathToRef';
export { makeId } from './makeId';
export {
REF_SYMBOL,
SCHEMA_ID_SYMBOL,
PLACEHOLDER_REGEX,
refToPlaceholder,
idToPlaceholder,
} from './refReplacementUtils';
export { replaceInlinedRefsWithStringPlaceholder } from './makeTsJsonSchema/replaceInlinedRefsWithStringPlaceholder';
export { replacePlaceholdersWithImportedSchemas } from './makeTsJsonSchema/replacePlaceholdersWithImportedSchemas';
Expand Down
8 changes: 4 additions & 4 deletions src/utils/pathToRef.ts → src/utils/makeId.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import path from 'node:path';
import { filenamify } from './';
import { filenamify } from '.';

/**
* Generate a local OpenAPI ref from a relative path and a schema name
* Generate a local OpenAPI ref from a schema internal id
*/
const TRALING_SLASH_REGEX = /\/$/;
export function pathToRef({
export function makeId({
schemaRelativeDirName,
schemaName,
}: {
schemaRelativeDirName: string;
schemaName: string;
}): string {
return (
'#/' +
'/' +
path
.normalize(schemaRelativeDirName)
// Supporting definitionPathsToGenerateFrom dot notation
Expand Down
15 changes: 15 additions & 0 deletions src/utils/makeTsJsonSchema/getId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SCHEMA_ID_SYMBOL, isObject } from '..';

/**
* Retrieve SCHEMA_ID_SYMBOL prop value
*/
export function getId(node: unknown): string | undefined {
if (
isObject(node) &&
SCHEMA_ID_SYMBOL in node &&
typeof node[SCHEMA_ID_SYMBOL] === 'string'
) {
return node[SCHEMA_ID_SYMBOL];
}
return undefined;
}
16 changes: 0 additions & 16 deletions src/utils/makeTsJsonSchema/getRef.ts

This file was deleted.

6 changes: 3 additions & 3 deletions src/utils/makeTsJsonSchema/makeCircularRefReplacer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getRef } from './getRef';
import { getId } from './getId';

/**
* JSON.stringify replacer
Expand All @@ -23,13 +23,13 @@ export function makeCircularRefReplacer(): (

// @NOTE Should we make recursion depth configurable?
if (ancestors.includes(value)) {
const ref = getRef(value);
const id = getId(value);
return {
// Drop an inline comment about recursion interruption
[Symbol.for('before')]: [
{
type: 'LineComment',
value: ` Circular recursion interrupted (${ref})`,
value: ` Circular recursion interrupted. Schema id: "${id}"`,
},
],
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import mapObject from 'map-obj';
import { refToPlaceholder } from '..';
import { getRef } from './getRef';
import { idToPlaceholder } from '..';
import { getId } from './getId';
import type { JSONSchema, JSONSchemaWithPlaceholders } from '../../types';

/**
* Get any JSON schema node and:
* - Return ref placeholder is the entity is an inlined ref schema objects (with REF_SYMBOL prop)
* - Return ref placeholder is the entity is an inlined ref schema objects (with SCHEMA_ID_SYMBOL prop)
* - Return provided node in all other cases
*/
function replaceInlinedSchemaWithPlaceholder<Node extends unknown>(
node: Node,
): Node | string {
const ref = getRef(node);
if (ref === undefined) {
const id = getId(node);
if (id === undefined) {
return node;
}
return refToPlaceholder(ref);
return idToPlaceholder(id);
}

/**
* Iterate a JSON schema to replace inlined ref schema objects
* (marked with a REF_SYMBOL property holding the original $ref value)
* with a string placeholder with a reference to the original $ref value ("_OTJS-START_#/ref/value_OTJS-END_")
* (marked with a SCHEMA_ID_SYMBOL property holding the original $ref value)
* with a string placeholder with a reference to the original $ref value ("_OTJS-START_/id/value_OTJS-END_")
*/
export function replaceInlinedRefsWithStringPlaceholder(
schema: JSONSchema,
): JSONSchemaWithPlaceholders {
if (getRef(schema)) {
if (getId(schema)) {
return replaceInlinedSchemaWithPlaceholder(schema);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export function replacePlaceholdersWithImportedSchemas({
const importStatements = new Set<string>();

// Replace placeholder occurrences with the relevant imported schema name
let schema = schemaAsText.replaceAll(PLACEHOLDER_REGEX, (_match, ref) => {
const importedSchema = schemaMetaDataMap.get(ref);
let schema = schemaAsText.replaceAll(PLACEHOLDER_REGEX, (_match, id) => {
const importedSchema = schemaMetaDataMap.get(id);

/* c8 ignore start */
if (!importedSchema) {
Expand Down
Loading