Skip to content

feat(openapi-typescript): Optional Export Root Type Aliases #1876

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Sep 3, 2024
7 changes: 4 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,22 @@ The following flags are supported in the CLI:
| `--help` | | | Display inline help message and exit |
| `--version` | | | Display this library’s version and exit |
| `--output [location]` | `-o` | (stdout) | Where should the output file be saved? |
| `--redocly [location]` | | | Path to a `redocly.yaml` file (see [Multiple schemas](#multiple-schemas)) |
| `--redocly [location]` | | | Path to a `redocly.yaml` file (see [Multiple schemas](#multiple-schemas)) |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️ Thanks for fixing little things like this! It means a lot

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your welcome! Gotta keep things consistent 🙂

| `--additional-properties` | | `false` | Allow arbitrary properties for all schema objects without `additionalProperties: false` |
| `--alphabetize` | | `false` | Sort types alphabetically |
| `--array-length` | | `false` | Generate tuples using array `minItems` / `maxItems` |
| `--default-non-nullable` | | `true` | Treat schema objects with default values as non-nullable (with the exception of parameters) |
| `--default-non-nullable` | | `true` | Treat schema objects with default values as non-nullable (with the exception of parameters) |
| `--properties-required-by-default` | | `false` | Treat schema objects without `required` as having all properties required. |
| `--empty-objects-unknown` | | `false` | Allow arbitrary properties for schema objects with no specified properties, and no specified `additionalProperties` |
| `--enum` | | `false` | Generate true [TS enums](https://www.typescriptlang.org/docs/handbook/enums.html) rather than string unions. |
| `--enum-values` | | `false` | Export enum values as arrays. |
| `--dedupe-enums` | | `false` | Dedupe enum types when `--enum=true` is set |
| `--check` | | `false` | Check that the generated types are up-to-date. |
| `--check` | | `false` | Check that the generated types are up-to-date. |
| `--exclude-deprecated` | | `false` | Exclude deprecated fields from types |
| `--export-type` | `-t` | `false` | Export `type` instead of `interface` |
| `--immutable` | | `false` | Generates immutable types (readonly properties and readonly array) |
| `--path-params-as-types` | | `false` | Allow dynamic string lookups on the `paths` object |
| `--root-types` | | `false` | Exports types from `components` as root level type aliases |

### pathParamsAsTypes

Expand Down
3 changes: 3 additions & 0 deletions packages/openapi-typescript/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Options
--path-params-as-types Convert paths to template literal types
--alphabetize Sort object keys alphabetically
--exclude-deprecated Exclude deprecated types
--root-types (optional) Export schemas types at root level
`;

const OUTPUT_FILE = "FILE";
Expand Down Expand Up @@ -74,6 +75,7 @@ const flags = parser(args, {
"help",
"immutable",
"pathParamsAsTypes",
"rootTypes",
],
string: ["output", "redocly"],
alias: {
Expand Down Expand Up @@ -133,6 +135,7 @@ async function generateSchema(schema, { redocly, silent = false }) {
exportType: flags.exportType,
immutable: flags.immutable,
pathParamsAsTypes: flags.pathParamsAsTypes,
rootTypes: flags.rootTypes,
redocly,
silent,
}),
Expand Down
118,762 changes: 118,762 additions & 0 deletions packages/openapi-typescript/examples/github-api-root-types.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/openapi-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"dependencies": {
"@redocly/openapi-core": "^1.16.0",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.1.0",
"supports-color": "^9.4.0",
"yargs-parser": "^21.1.1"
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/scripts/update-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async function generateSchemas() {
"-o",
`./examples/${name}-required.ts`,
]);
args.push([`./examples/${name}${ext}`, "--root-types", "-o", `./examples/${name}-root-types.ts`]);
}

await Promise.all(args.map((a) => execa("./bin/cli.js", a, { cwd })));
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default async function openapiTS(
excludeDeprecated: options.excludeDeprecated ?? false,
exportType: options.exportType ?? false,
immutable: options.immutable ?? false,
rootTypes: options.rootTypes ?? false,
injectFooter: [],
pathParamsAsTypes: options.pathParamsAsTypes ?? false,
postTransform: typeof options.postTransform === "function" ? options.postTransform : undefined,
Expand Down
43 changes: 41 additions & 2 deletions packages/openapi-typescript/src/transform/components-object.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ts from "typescript";
import * as changeCase from "change-case";
import { performance } from "node:perf_hooks";
import { NEVER, QUESTION_TOKEN, addJSDocComment, tsModifiers, tsPropertyIndex } from "../lib/ts.js";
import { createRef, debug, getEntries } from "../lib/utils.js";
Expand All @@ -25,8 +26,9 @@ const transformers: Record<ComponentTransforms, (node: any, options: TransformNo
* Transform the ComponentsObject (4.8.7)
* @see https://spec.openapis.org/oas/latest.html#components-object
*/
export default function transformComponentsObject(componentsObject: ComponentsObject, ctx: GlobalContext): ts.TypeNode {
export default function transformComponentsObject(componentsObject: ComponentsObject, ctx: GlobalContext): ts.Node[] {
const type: ts.TypeElement[] = [];
const rootTypeAliases: { [key: string]: ts.TypeAliasDeclaration } = {};

for (const key of Object.keys(transformers) as ComponentTransforms[]) {
const componentT = performance.now();
Expand Down Expand Up @@ -63,6 +65,24 @@ export default function transformComponentsObject(componentsObject: ComponentsOb
);
addJSDocComment(item as unknown as any, property);
items.push(property);

if (ctx.rootTypes) {
let aliasName = changeCase.pascalCase(singularizeComponentKey(key)) + changeCase.pascalCase(name)
// Add counter suffix (e.g. "_2") if conflict in name
let conflictCounter = 1
while (rootTypeAliases[aliasName] !== undefined) {
conflictCounter++
aliasName = changeCase.pascalCase(singularizeComponentKey(key)) + changeCase.pascalCase(name) + '_' + conflictCounter
}
const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`);
const typeAlias = ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ aliasName,
/* typeParameters */ undefined,
/* type */ ref,
);
rootTypeAliases[aliasName] = typeAlias;
}
}
}
type.push(
Expand All @@ -77,5 +97,24 @@ export default function transformComponentsObject(componentsObject: ComponentsOb
debug(`Transformed components → ${key}`, "ts", performance.now() - componentT);
}

return ts.factory.createTypeLiteralNode(type);
// Extract root types
let rootTypes: ts.TypeAliasDeclaration[] = [];
if (ctx.rootTypes) {
rootTypes = Object.keys(rootTypeAliases).map((k) => rootTypeAliases[k])
}

return [ts.factory.createTypeLiteralNode(type), ...rootTypes];
}

export function singularizeComponentKey(
key: `x-${string}` | "schemas" | "responses" | "parameters" | "requestBodies" | "headers" | "pathItems",
): string {
switch (key) {
// Handle special singular case
case "requestBodies":
return "requestBody";
// Default to removing the "s"
default:
return key.slice(0, -1);
}
}
55 changes: 32 additions & 23 deletions packages/openapi-typescript/src/transform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import transformWebhooksObject from "./webhooks-object.js";

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

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

if (schema[root] && typeof schema[root] === "object") {
const rootT = performance.now();
const subType = transformers[root](schema[root], ctx);
if ((subType as ts.TypeLiteralNode).members?.length) {
type.push(
ctx.exportType
? ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ root,
/* typeParameters */ undefined,
/* type */ subType,
)
: ts.factory.createInterfaceDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ root,
/* typeParameters */ undefined,
/* heritageClauses */ undefined,
/* members */ (subType as TypeLiteralNode).members,
),
);
debug(`${root} done`, "ts", performance.now() - rootT);
} else {
type.push(emptyObj);
debug(`${root} done (skipped)`, "ts", 0);
const subTypes = ([] as ts.Node[]).concat(transformers[root](schema[root], ctx));
for (const subType of subTypes) {
if (ts.isTypeNode(subType)) {
if ((subType as ts.TypeLiteralNode).members?.length) {
type.push(
ctx.exportType
? ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ root,
/* typeParameters */ undefined,
/* type */ subType,
)
: ts.factory.createInterfaceDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ root,
/* typeParameters */ undefined,
/* heritageClauses */ undefined,
/* members */ (subType as TypeLiteralNode).members,
),
);
debug(`${root} done`, "ts", performance.now() - rootT);
} else {
type.push(emptyObj);
debug(`${root} done (skipped)`, "ts", 0);
}
} else if (ts.isTypeAliasDeclaration(subType)) {
type.push(subType);
} else {
type.push(emptyObj);
debug(`${root} done (skipped)`, "ts", 0);
}
}
} else {
type.push(emptyObj);
Expand Down
3 changes: 3 additions & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,8 @@ export interface OpenAPITSOptions {
pathParamsAsTypes?: boolean;
/** Treat all objects as if they have \`required\` set to all properties by default (default: false) */
propertiesRequiredByDefault?: boolean;
/** (optional) Generate schema types at root level */
rootTypes?: boolean;
/**
* Configure Redocly for validation, schema fetching, and bundling
* @see https://redocly.com/docs/cli/configuration/
Expand Down Expand Up @@ -688,6 +690,7 @@ export interface GlobalContext {
pathParamsAsTypes: boolean;
postTransform: OpenAPITSOptions["postTransform"];
propertiesRequiredByDefault: boolean;
rootTypes: boolean;
redoc: RedoclyConfig;
silent: boolean;
transform: OpenAPITSOptions["transform"];
Expand Down
8 changes: 8 additions & 0 deletions packages/openapi-typescript/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ describe("CLI", () => {
ci: { timeout: TIMEOUT },
},
],
[
"snapshot > GitHub API (root types)",
{
given: ["./examples/github-api.yaml", "--root-types"],
want: new URL("./examples/github-api-root-types.ts", root),
ci: { timeout: TIMEOUT },
},
],
[
"snapshot > GitHub API (next)",
{
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/test/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const DEFAULT_CTX: GlobalContext = {
pathParamsAsTypes: false,
postTransform: undefined,
propertiesRequiredByDefault: false,
rootTypes: false,
redoc: await createConfig({}, { extends: ["minimal"] }),
resolve($ref) {
return resolveRef({}, $ref, { silent: false });
Expand Down
Loading
Loading