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
30 changes: 28 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 rootType: ts.TypeAliasDeclaration[] = [];

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

if (ctx.rootTypes) {
const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`);
const typeAlias = ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ changeCase.pascalCase(name) + changeCase.pascalCase(singularizeComponentKey(key)),
/* typeParameters */ undefined,
/* type */ ref,
);
rootType.push(typeAlias);
}
}
}
type.push(
Expand All @@ -77,5 +90,18 @@ export default function transformComponentsObject(componentsObject: ComponentsOb
debug(`Transformed components → ${key}`, "ts", performance.now() - componentT);
}

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

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
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,37 @@ describe("transformComponentsObject", () => {
options: { ...DEFAULT_OPTIONS, excludeDeprecated: true },
},
],
[
"options > rootTypes: true",
{
given: {
schemas: {
SomeType: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
},
},
},
},
want: `{
schemas: {
SomeType: {
name?: string;
url?: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type SomeTypeSchema = components['schemas']['SomeType'];`,
options: { ...DEFAULT_OPTIONS, rootTypes: true },
Copy link
Contributor

@drwpow drwpow Aug 30, 2024

Choose a reason for hiding this comment

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

So this is a great start to testing this, and this is exactly what we need! But we will need a lot more tests to handle all of the scenarios from my original requirements:

  1. Conflicts: test that #/components/schemas/Schema-Foo and #/components/schemas/Schemafoo don’t conflict (and many combinations including hyphens and invalid JS characters like leading numbers!). We’ll need to handle:
  • Case sensitivity edit: actually on second thought, it’s fine if a user gets case-sensitive conflicts; that’s on them
  • Invalid characters: -, ., and / are the most common. Beyond that IMO not too important.
  • Leading numbers (e.g. 1schema)
  1. ALL of components object, not just #/components/schemas:
  • #/components/schemas
  • #/components/responses
  • #/components/parameters
  • #/components/requestBodies
  • ⚠️ Make sure that #/components/schemas/Foo doesn’t conflict with #/components/responses/Foo! This is a common one.

We will need all of this to happen in the same PR, because it’s very likely that supporting all this could lead to refactoring and churn based on the implementation (see the last point of #2—conflicts are very likely between all the collections). As-is I like the path you’re on and don’t see any problems in your implementation! But we need to have all of these present and tested before we merge this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed point 1 with d40a7fd. All conflicting names are suffixed by a _2, _3, etc. as per the original discussion in the previous PR you linked. This behavior is up for discussion. Not sure this is a great developer experience, but at the same time don't feel it warrants an error given the OpenAPI spec permits these things. Given that, figured this should be the compliant portion and work around the limitations of JS to comply to the OpenAPI spec as closely as possible. Maybe possible to just print warnings about conflicts and automatic suffixing if detected as a happy compromise?

Filled out the test to cover the other components properties in 7edf333 and tested for conflicts across them in f64e0e6

Copy link
Contributor

@drwpow drwpow Sep 3, 2024

Choose a reason for hiding this comment

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

Given that, figured this should be the compliant portion and work around the limitations of JS to comply to the OpenAPI spec as closely as possible.

+1

Maybe possible to just print warnings about conflicts and automatic suffixing if detected as a happy compromise?

Yeah I’m open to that, but don’t feel strongly. I think developers will realize if they’re importing the wrong things quickly. The main thing to handle was types silently missing from rootTypes. That would have people raising bug reports. But both warnings, and an awkward _2 let people know “hey your schema has a conflict—you may not have even realized it before” (which is very easy to do in multi-file schemas, which a lot of teams rely on)

},
],
[
"transform > with transform object",
{
Expand Down
20 changes: 9 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.