Skip to content

Commit be3bce1

Browse files
feat(openapi-typescript): Optional Export Root Type Aliases (openapi-ts#1876)
* Add root component type aliases * Add tests for root component types * Add root types CLI option docs * Add unit test for components root types * Add schema name conflict detection * Add tests for components root types beyond schemas * Add cross-component conflict checks * Update examples * Fix biome linting issues * Add changeset --------- Co-authored-by: bdh9 <[email protected]>
1 parent 34422c2 commit be3bce1

File tree

11 files changed

+119088
-25
lines changed

11 files changed

+119088
-25
lines changed

bin/cli.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Options
3030
--path-params-as-types Convert paths to template literal types
3131
--alphabetize Sort object keys alphabetically
3232
--exclude-deprecated Exclude deprecated types
33+
--root-types (optional) Export schemas types at root level
3334
`;
3435

3536
const OUTPUT_FILE = "FILE";
@@ -74,6 +75,7 @@ const flags = parser(args, {
7475
"help",
7576
"immutable",
7677
"pathParamsAsTypes",
78+
"rootTypes",
7779
],
7880
string: ["output", "redocly"],
7981
alias: {
@@ -133,6 +135,7 @@ async function generateSchema(schema, { redocly, silent = false }) {
133135
exportType: flags.exportType,
134136
immutable: flags.immutable,
135137
pathParamsAsTypes: flags.pathParamsAsTypes,
138+
rootTypes: flags.rootTypes,
136139
redocly,
137140
silent,
138141
}),

examples/github-api-root-types.ts

Lines changed: 118762 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"dependencies": {
6363
"@redocly/openapi-core": "^1.16.0",
6464
"ansi-colors": "^4.1.3",
65+
"change-case": "^5.4.4",
6566
"parse-json": "^8.1.0",
6667
"supports-color": "^9.4.0",
6768
"yargs-parser": "^21.1.1"

scripts/update-examples.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ async function generateSchemas() {
4040
"-o",
4141
`./examples/${name}-required.ts`,
4242
]);
43+
args.push([`./examples/${name}${ext}`, "--root-types", "-o", `./examples/${name}-root-types.ts`]);
4344
}
4445

4546
await Promise.all(args.map((a) => execa("./bin/cli.js", a, { cwd })));

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export default async function openapiTS(
7979
excludeDeprecated: options.excludeDeprecated ?? false,
8080
exportType: options.exportType ?? false,
8181
immutable: options.immutable ?? false,
82+
rootTypes: options.rootTypes ?? false,
8283
injectFooter: [],
8384
pathParamsAsTypes: options.pathParamsAsTypes ?? false,
8485
postTransform: typeof options.postTransform === "function" ? options.postTransform : undefined,

src/transform/components-object.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ts from "typescript";
2+
import * as changeCase from "change-case";
23
import { performance } from "node:perf_hooks";
34
import { NEVER, QUESTION_TOKEN, addJSDocComment, tsModifiers, tsPropertyIndex } from "../lib/ts.js";
45
import { createRef, debug, getEntries } from "../lib/utils.js";
@@ -25,8 +26,9 @@ const transformers: Record<ComponentTransforms, (node: any, options: TransformNo
2526
* Transform the ComponentsObject (4.8.7)
2627
* @see https://spec.openapis.org/oas/latest.html#components-object
2728
*/
28-
export default function transformComponentsObject(componentsObject: ComponentsObject, ctx: GlobalContext): ts.TypeNode {
29+
export default function transformComponentsObject(componentsObject: ComponentsObject, ctx: GlobalContext): ts.Node[] {
2930
const type: ts.TypeElement[] = [];
31+
const rootTypeAliases: { [key: string]: ts.TypeAliasDeclaration } = {};
3032

3133
for (const key of Object.keys(transformers) as ComponentTransforms[]) {
3234
const componentT = performance.now();
@@ -63,6 +65,24 @@ export default function transformComponentsObject(componentsObject: ComponentsOb
6365
);
6466
addJSDocComment(item as unknown as any, property);
6567
items.push(property);
68+
69+
if (ctx.rootTypes) {
70+
let aliasName = changeCase.pascalCase(singularizeComponentKey(key)) + changeCase.pascalCase(name);
71+
// Add counter suffix (e.g. "_2") if conflict in name
72+
let conflictCounter = 1;
73+
while (rootTypeAliases[aliasName] !== undefined) {
74+
conflictCounter++;
75+
aliasName = `${changeCase.pascalCase(singularizeComponentKey(key))}${changeCase.pascalCase(name)}_${conflictCounter}`;
76+
}
77+
const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`);
78+
const typeAlias = ts.factory.createTypeAliasDeclaration(
79+
/* modifiers */ tsModifiers({ export: true }),
80+
/* name */ aliasName,
81+
/* typeParameters */ undefined,
82+
/* type */ ref,
83+
);
84+
rootTypeAliases[aliasName] = typeAlias;
85+
}
6686
}
6787
}
6888
type.push(
@@ -77,5 +97,24 @@ export default function transformComponentsObject(componentsObject: ComponentsOb
7797
debug(`Transformed components → ${key}`, "ts", performance.now() - componentT);
7898
}
7999

80-
return ts.factory.createTypeLiteralNode(type);
100+
// Extract root types
101+
let rootTypes: ts.TypeAliasDeclaration[] = [];
102+
if (ctx.rootTypes) {
103+
rootTypes = Object.keys(rootTypeAliases).map((k) => rootTypeAliases[k]);
104+
}
105+
106+
return [ts.factory.createTypeLiteralNode(type), ...rootTypes];
107+
}
108+
109+
export function singularizeComponentKey(
110+
key: `x-${string}` | "schemas" | "responses" | "parameters" | "requestBodies" | "headers" | "pathItems",
111+
): string {
112+
switch (key) {
113+
// Handle special singular case
114+
case "requestBodies":
115+
return "requestBody";
116+
// Default to removing the "s"
117+
default:
118+
return key.slice(0, -1);
119+
}
81120
}

src/transform/index.ts

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import transformWebhooksObject from "./webhooks-object.js";
1010

1111
type SchemaTransforms = keyof Pick<OpenAPI3, "paths" | "webhooks" | "components" | "$defs">;
1212

13-
const transformers: Record<SchemaTransforms, (node: any, options: GlobalContext) => ts.TypeNode> = {
13+
const transformers: Record<SchemaTransforms, (node: any, options: GlobalContext) => ts.Node | ts.Node[]> = {
1414
paths: transformPathsObject,
1515
webhooks: transformWebhooksObject,
1616
components: transformComponentsObject,
@@ -35,28 +35,37 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
3535

3636
if (schema[root] && typeof schema[root] === "object") {
3737
const rootT = performance.now();
38-
const subType = transformers[root](schema[root], ctx);
39-
if ((subType as ts.TypeLiteralNode).members?.length) {
40-
type.push(
41-
ctx.exportType
42-
? ts.factory.createTypeAliasDeclaration(
43-
/* modifiers */ tsModifiers({ export: true }),
44-
/* name */ root,
45-
/* typeParameters */ undefined,
46-
/* type */ subType,
47-
)
48-
: ts.factory.createInterfaceDeclaration(
49-
/* modifiers */ tsModifiers({ export: true }),
50-
/* name */ root,
51-
/* typeParameters */ undefined,
52-
/* heritageClauses */ undefined,
53-
/* members */ (subType as TypeLiteralNode).members,
54-
),
55-
);
56-
debug(`${root} done`, "ts", performance.now() - rootT);
57-
} else {
58-
type.push(emptyObj);
59-
debug(`${root} done (skipped)`, "ts", 0);
38+
const subTypes = ([] as ts.Node[]).concat(transformers[root](schema[root], ctx));
39+
for (const subType of subTypes) {
40+
if (ts.isTypeNode(subType)) {
41+
if ((subType as ts.TypeLiteralNode).members?.length) {
42+
type.push(
43+
ctx.exportType
44+
? ts.factory.createTypeAliasDeclaration(
45+
/* modifiers */ tsModifiers({ export: true }),
46+
/* name */ root,
47+
/* typeParameters */ undefined,
48+
/* type */ subType,
49+
)
50+
: ts.factory.createInterfaceDeclaration(
51+
/* modifiers */ tsModifiers({ export: true }),
52+
/* name */ root,
53+
/* typeParameters */ undefined,
54+
/* heritageClauses */ undefined,
55+
/* members */ (subType as TypeLiteralNode).members,
56+
),
57+
);
58+
debug(`${root} done`, "ts", performance.now() - rootT);
59+
} else {
60+
type.push(emptyObj);
61+
debug(`${root} done (skipped)`, "ts", 0);
62+
}
63+
} else if (ts.isTypeAliasDeclaration(subType)) {
64+
type.push(subType);
65+
} else {
66+
type.push(emptyObj);
67+
debug(`${root} done (skipped)`, "ts", 0);
68+
}
6069
}
6170
} else {
6271
type.push(emptyObj);

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,8 @@ export interface OpenAPITSOptions {
657657
pathParamsAsTypes?: boolean;
658658
/** Treat all objects as if they have \`required\` set to all properties by default (default: false) */
659659
propertiesRequiredByDefault?: boolean;
660+
/** (optional) Generate schema types at root level */
661+
rootTypes?: boolean;
660662
/**
661663
* Configure Redocly for validation, schema fetching, and bundling
662664
* @see https://redocly.com/docs/cli/configuration/
@@ -688,6 +690,7 @@ export interface GlobalContext {
688690
pathParamsAsTypes: boolean;
689691
postTransform: OpenAPITSOptions["postTransform"];
690692
propertiesRequiredByDefault: boolean;
693+
rootTypes: boolean;
691694
redoc: RedoclyConfig;
692695
silent: boolean;
693696
transform: OpenAPITSOptions["transform"];

test/cli.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ describe("CLI", () => {
3636
ci: { timeout: TIMEOUT },
3737
},
3838
],
39+
[
40+
"snapshot > GitHub API (root types)",
41+
{
42+
given: ["./examples/github-api.yaml", "--root-types"],
43+
want: new URL("./examples/github-api-root-types.ts", root),
44+
ci: { timeout: TIMEOUT },
45+
},
46+
],
3947
[
4048
"snapshot > GitHub API (next)",
4149
{

test/test-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const DEFAULT_CTX: GlobalContext = {
2323
pathParamsAsTypes: false,
2424
postTransform: undefined,
2525
propertiesRequiredByDefault: false,
26+
rootTypes: false,
2627
redoc: await createConfig({}, { extends: ["minimal"] }),
2728
resolve($ref) {
2829
return resolveRef({}, $ref, { silent: false });

0 commit comments

Comments
 (0)