Skip to content
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

feat: root types #1599

Closed
Closed
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
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ The following flags are supported in the CLI:
| `--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` | Export components and paths types at root level |

### 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 @@ -33,6 +33,7 @@ Options
--path-params-as-types Convert paths to template literal types
--alphabetize Sort object keys alphabetically
--exclude-deprecated Exclude deprecated types
--root-types Export components and paths types at root level
`;

const OUTPUT_FILE = "FILE";
Expand Down Expand Up @@ -76,6 +77,7 @@ const flags = parser(args, {
"help",
"immutable",
"pathParamsAsTypes",
"rootTypes",
],
string: ["output", "redoc"],
alias: {
Expand Down Expand Up @@ -103,6 +105,7 @@ async function generateSchema(schema, { redoc, silent = false }) {
exportType: flags.exportType,
immutable: flags.immutable,
pathParamsAsTypes: flags.pathParamsAsTypes,
rootTypes: flags.rootTypes,
redoc,
silent,
}),
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"del-cli": "^5.1.0",
"esbuild": "^0.20.1",
"execa": "^7.2.0",
"scule": "^1.3.0",
Copy link
Contributor

Choose a reason for hiding this comment

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

I’m fine with adding this since it’s tiny, well-documented, and well-written. But this will need to be in dependencies if it’s required for runtime! Otherwise it won’t install, and users will get a “module not found” error

"typescript": "^5.3.3",
"vite-node": "^1.3.1",
"vitest": "^1.3.1"
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 @@ -88,6 +88,7 @@ export default async function openapiTS(
arrayLength: options.arrayLength ?? false,
transform:
typeof options.transform === "function" ? options.transform : undefined,
rootTypes: options.rootTypes ?? false,
resolve($ref) {
return resolveRef(schema, $ref, { silent: options.silent ?? false });
},
Expand Down
21 changes: 21 additions & 0 deletions packages/openapi-typescript/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,24 @@ export function warn(msg: string, silent = false) {
console.warn(c.yellow(` ⚠ ${msg}`)); // eslint-disable-line no-console
}
}

export function renameDuplicates(arr: string[]): string[] {
const count: Record<string, number> = {};
const res: string[] = [];

for (const item of arr) {
if (count[item]) {
let newName = item + count[item];
while (res.includes(newName)) {
count[item]++;
newName = item + count[item];
}
res.push(newName);
} else {
count[item] = 1;
res.push(item);
}
}

return res;
}
36 changes: 32 additions & 4 deletions packages/openapi-typescript/src/transform/components-object.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { pascalCase } from "scule";
import ts from "typescript";
import {
NEVER,
QUESTION_TOKEN,
addJSDocComment,
tsModifiers,
tsPropertyIndex,
oapiRef,
} from "../lib/ts.js";
import { createRef, debug, getEntries } from "../lib/utils.js";
import {
createRef,
debug,
getEntries,
renameDuplicates,
} from "../lib/utils.js";
import type {
ComponentsObject,
GlobalContext,
Expand Down Expand Up @@ -44,15 +51,17 @@ const transformers: Record<
export default function transformComponentsObject(
componentsObject: ComponentsObject,
ctx: GlobalContext,
): ts.TypeNode {
): [ts.TypeNode, ts.TypeAliasDeclaration[]] {
const type: ts.TypeElement[] = [];
const refs: ts.TypeAliasDeclaration[] = [];

for (const key of Object.keys(transformers) as ComponentTransforms[]) {
const componentT = performance.now();

const items: ts.TypeElement[] = [];
if (componentsObject[key]) {
for (const [name, item] of getEntries(componentsObject[key], ctx)) {
const entries = getEntries(componentsObject[key], ctx);
for (const [name, item] of entries) {
let subType = transformers[key](item, {
path: createRef(["components", key, name]),
ctx,
Expand Down Expand Up @@ -83,6 +92,25 @@ export default function transformComponentsObject(
addJSDocComment(item as unknown as any, property); // eslint-disable-line @typescript-eslint/no-explicit-any
items.push(property);
}

if (ctx.rootTypes) {
const keySingular = key.slice(0, -1);
const pascalNames = renameDuplicates(
entries.map(([name]) => pascalCase(`${keySingular}-${name}`)),
);
for (let i = 0; i < entries.length; i++) {
refs.push(
ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ pascalNames[i],
/* typeParameters */ undefined,
/* type */ oapiRef(
createRef(["components", key, entries[i][0]]),
),
),
);
}
}
}
type.push(
ts.factory.createPropertySignature(
Expand All @@ -102,5 +130,5 @@ export default function transformComponentsObject(
);
}

return ts.factory.createTypeLiteralNode(type);
return [ts.factory.createTypeLiteralNode(type), refs];
}
17 changes: 14 additions & 3 deletions packages/openapi-typescript/src/transform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ type SchemaTransforms = keyof Pick<

const transformers: Record<
SchemaTransforms,
(node: any, options: GlobalContext) => ts.TypeNode // eslint-disable-line @typescript-eslint/no-explicit-any
(
node: any, // eslint-disable-line @typescript-eslint/no-explicit-any
options: GlobalContext,
) => [ts.TypeNode, ts.TypeAliasDeclaration[]]
> = {
paths: transformPathsObject,
webhooks: transformWebhooksObject,
components: transformComponentsObject,
$defs: (node, options) =>
$defs: (node, options) => [
Copy link
Contributor

Choose a reason for hiding this comment

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

Another tuple type to remove: let’s change this to an interface instead

transformSchemaObject(node, { path: createRef(["$defs"]), ctx: options }),
[],
],
};

export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
Expand All @@ -39,7 +44,7 @@ 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);
const [subType, aliasTypes] = transformers[root](schema[root], ctx);
if ((subType as ts.TypeLiteralNode).members?.length) {
type.push(
ctx.exportType
Expand All @@ -62,6 +67,12 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
type.push(emptyObj);
debug(`${root} done (skipped)`, "ts", 0);
}

if (ctx.rootTypes) {
for (const alias of aliasTypes) {
type.push(alias);
}
}
} else {
type.push(emptyObj);
debug(`${root} done (skipped)`, "ts", 0);
Expand Down
57 changes: 48 additions & 9 deletions packages/openapi-typescript/src/transform/paths-object.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { pascalCase } from "scule";
import ts from "typescript";
import {
addJSDocComment,
Expand Down Expand Up @@ -26,8 +27,9 @@ const PATH_PARAM_RE = /\{[^}]+\}/g;
export default function transformPathsObject(
pathsObject: PathsObject,
ctx: GlobalContext,
): ts.TypeNode {
): [ts.TypeNode, ts.TypeAliasDeclaration[]] {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
): [ts.TypeNode, ts.TypeAliasDeclaration[]] {
): { node: ts.TypeNode, aliases: ts.TypeAliasDeclaration[] } {

This is a personal opinion, but let’s not return tuple types for any functions; those don’t scale well. If a function needs to return multiple things, a named object is much better (same for parameters—named objects are more maintainable than ordered params or tuples)

const type: ts.TypeElement[] = [];
const refs: ts.TypeAliasDeclaration[] = [];
for (const [url, pathItemObject] of getEntries(pathsObject, ctx)) {
if (!pathItemObject || typeof pathItemObject !== "object") {
continue;
Expand All @@ -52,7 +54,10 @@ export default function transformPathsObject(

// pathParamsAsTypes
if (ctx.pathParamsAsTypes && url.includes("{")) {
const pathParams = extractPathParams(pathItemObject, ctx);
const { parameters: pathParams = {} } = extractParams(
pathItemObject,
ctx,
);
const matches = url.match(PATH_PARAM_RE);
let rawPath = `\`${url}\``;
if (matches) {
Expand Down Expand Up @@ -106,20 +111,45 @@ export default function transformPathsObject(

debug(`Transformed path "${url}"`, "ts", performance.now() - pathT);
}

if (ctx.rootTypes) {
const { operations } = extractParams(pathItemObject, ctx);
for (const name in operations) {
refs.push(
ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ pascalCase(`request-${operations[name]}`),
/* typeParameters */ undefined,
/* type */ oapiRef(
createRef(["paths", url, name, "parameters"]),
),
),
);
}
}
}

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

function extractPathParams(pathItemObject: PathItemObject, ctx: GlobalContext) {
const params: Record<string, ParameterObject> = {};
function extractParams(pathItemObject: PathItemObject, ctx: GlobalContext) {
const params: {
parameters: Record<string, ParameterObject>;
operations: Record<string, string>;
} = {
parameters: {},
operations: {},
};
for (const p of pathItemObject.parameters ?? []) {
const resolved =
"$ref" in p && p.$ref
? ctx.resolve<ParameterObject>(p.$ref)
: (p as ParameterObject);
if (resolved && resolved.in === "path") {
params[resolved.name] = resolved;
if (resolved) {
params.parameters = {
...params.parameters,
[resolved.name]: resolved,
};
}
}
for (const method of [
Expand All @@ -146,8 +176,17 @@ function extractPathParams(pathItemObject: PathItemObject, ctx: GlobalContext) {
"$ref" in p && p.$ref
? ctx.resolve<ParameterObject>(p.$ref)
: (p as ParameterObject);
if (resolvedParam && resolvedParam.in === "path") {
params[resolvedParam.name] = resolvedParam;
if (resolvedParam) {
params.parameters = {
...params.parameters,
[resolvedParam.name]: resolvedParam,
};
if (resolvedMethod.operationId) {
params.operations = {
...params.operations,
[method]: resolvedMethod.operationId,
};
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import transformPathItemObject from "./path-item-object.js";
export default function transformWebhooksObject(
webhooksObject: WebhooksObject,
options: GlobalContext,
): ts.TypeNode {
): [ts.TypeNode, ts.TypeAliasDeclaration[]] {
const type: ts.TypeElement[] = [];

for (const [name, pathItemObject] of getEntries(webhooksObject, options)) {
Expand All @@ -26,5 +26,5 @@ export default function transformWebhooksObject(
);
}

return ts.factory.createTypeLiteralNode(type);
return [ts.factory.createTypeLiteralNode(type), []];
}
3 changes: 3 additions & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,8 @@ export interface OpenAPITSOptions {
pathParamsAsTypes?: boolean;
/** Exclude deprecated fields from types? (default: false) */
excludeDeprecated?: boolean;
/** Export components and paths 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 @@ -702,6 +704,7 @@ export interface GlobalContext {
silent: boolean;
arrayLength: boolean;
transform: OpenAPITSOptions["transform"];
rootTypes: boolean;
/** retrieve a node by $ref */
resolve<T>($ref: string): T | undefined;
}
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 @@ -28,6 +28,7 @@ export const DEFAULT_CTX: GlobalContext = {
},
silent: true,
transform: undefined,
rootTypes: false,
};

/** Generic test case */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ describe("transformComponentsObject", () => {
testName,
async () => {
const result = astToString(
transformComponentsObject(given, options ?? DEFAULT_OPTIONS),
transformComponentsObject(given, options ?? DEFAULT_OPTIONS)[0],
);
if (want instanceof URL) {
expect(result).toMatchFileSnapshot(fileURLToPath(want));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ describe("transformPathsObject", () => {
test.skipIf(ci?.skipIf)(
testName,
async () => {
const result = astToString(transformPathsObject(given, options));
const result = astToString(transformPathsObject(given, options)[0]);
if (want instanceof URL) {
expect(result).toMatchFileSnapshot(fileURLToPath(want));
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ describe("transformWebhooksObject", () => {
test.skipIf(ci?.skipIf)(
testName,
async () => {
const result = astToString(transformWebhooksObject(given, options));
const result = astToString(transformWebhooksObject(given, options)[0]);
if (want instanceof URL) {
expect(result).toMatchFileSnapshot(fileURLToPath(want));
} else {
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

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

Loading