Skip to content

Commit c68c022

Browse files
authored
Fix duplicate enum/type alias exports in types-plugin (#53)
* Fix duplicate type alias generation for enum declarations * Fix doc
1 parent dc59dda commit c68c022

File tree

7 files changed

+256
-13
lines changed

7 files changed

+256
-13
lines changed

docs/src/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,4 @@ Array of plugins to use. See [Using Plugins](./using-plugins) for more details.
6666
Options passed to OpenAPI TypeScript for type generation. You normally should
6767
not have to change them.
6868

69-
[!ref target="blank" text="See OpenAPI TypeScript options"](https://openapi-ts.pages.dev/cli#flags)
69+
[!ref target="blank" text="See OpenAPI TypeScript options"](https://openapi-ts.dev/cli#flags)

packages/create-schemas/src/plugins/types-plugin.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,26 @@ export function typesPlugin(): Plugin {
1919

2020
const ast = stringToAST(code) as ts.Node[];
2121

22+
// Collect names of all enum declarations to avoid generating duplicate type aliases
23+
const existingEnums = new Set(
24+
ast
25+
.filter(node => ts.isEnumDeclaration(node))
26+
.map(node => (node as ts.EnumDeclaration).name.text)
27+
);
28+
2229
const componentsDeclaration = ast.find(isComponentsInterfaceDeclaration);
2330

2431
assert(componentsDeclaration, "Missing components declaration");
2532
const schema = componentsDeclaration.members.find(isComponentsSchema);
2633
assert(schema, "Missing components declaration");
2734
assert(schema.type && ts.isTypeLiteralNode(schema.type), "Invalid schema type");
28-
35+
2936
const typeNodes = schema.type.members
3037
.map(member => member.name)
3138
.filter(name => name !== undefined)
3239
.filter(name => ts.isStringLiteral(name) || ts.isIdentifier(name))
3340
.map(name => name.text)
41+
.filter(name => !existingEnums.has(toSafeName(name)))
3442
.map(name => {
3543
if (RESERVED_IDENTIFIERS.has(name)) {
3644
throw new Error(`Invalid schema name: ${name}`);
@@ -68,33 +76,51 @@ export function isComponentsSchema(node: ts.Node): node is ts.PropertySignature
6876
* OpenAPI field names must match `^[a-zA-Z0-9\.\-_]+$` which allows names that
6977
* are not valid JavaScript/TypeScript identifiers. This function converts an
7078
* unsafe name into a safe name that can be used as a JavaScript/TypeScript
71-
* identifier.
79+
* identifier using PascalCase transformation to match openapi-typescript's enum naming.
7280
*/
7381
export function toSafeName(unsafeName: string): string {
7482
let safeName = "";
83+
let capitalizeNext = true;
84+
7585
for (const char of unsafeName) {
7686
const charCode = char.charCodeAt(0);
7787

88+
// Special characters that should trigger capitalization of next char
89+
if (char === "-" || char === "." || char === " ") {
90+
capitalizeNext = true;
91+
continue;
92+
}
93+
7894
// A-Z
7995
if (charCode >= 65 && charCode <= 90) {
8096
safeName += char;
97+
capitalizeNext = false;
98+
continue;
8199
}
82100

83101
// a-z
84102
if (charCode >= 97 && charCode <= 122) {
85-
safeName += char;
103+
safeName += capitalizeNext ? char.toUpperCase() : char;
104+
capitalizeNext = false;
105+
continue;
86106
}
87107

88-
if (char === "_" || char === "$") {
108+
// 0-9
109+
if (safeName.length > 0 && charCode >= 48 && charCode <= 57) {
89110
safeName += char;
111+
capitalizeNext = false;
112+
continue;
90113
}
91114

92-
// 0-9
93-
if (safeName.length > 0 && charCode >= 48 && charCode <= 57) {
115+
// _ and $ are valid identifier characters
116+
if (char === "_" || char === "$") {
94117
safeName += char;
118+
capitalizeNext = false;
119+
continue;
95120
}
96121

97-
continue;
122+
// Any other character triggers capitalization of next char but is not included
123+
capitalizeNext = true;
98124
}
99125

100126
return safeName;

packages/create-schemas/tests/__snapshots__/e2e.test.ts.snap

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,96 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`e2e > enums.yaml / enum generation without duplicate type aliases 1`] = `
4+
"/** This file has been generated by @workleap/create-schemas (https://github.com/workleap/wl-openapi-typescript). Do not modify manually. */
5+
export interface paths {
6+
"/test": {
7+
parameters: {
8+
query?: never;
9+
header?: never;
10+
path?: never;
11+
cookie?: never;
12+
};
13+
get: {
14+
parameters: {
15+
query?: never;
16+
header?: never;
17+
path?: never;
18+
cookie?: never;
19+
};
20+
requestBody?: never;
21+
responses: {
22+
/** @description Success */
23+
200: {
24+
headers: {
25+
[name: string]: unknown;
26+
};
27+
content?: never;
28+
};
29+
};
30+
};
31+
put?: never;
32+
post?: never;
33+
delete?: never;
34+
options?: never;
35+
head?: never;
36+
patch?: never;
37+
trace?: never;
38+
};
39+
}
40+
export type webhooks = Record<string, never>;
41+
export interface components {
42+
schemas: {
43+
/** @enum {string} */
44+
Status: Status;
45+
/** @enum {string} */
46+
Priority: Priority;
47+
/** @enum {string} */
48+
"user-status": UserStatus;
49+
/** @enum {string} */
50+
"task.priority": TaskPriority;
51+
Task: {
52+
id?: string;
53+
status?: components["schemas"]["Status"];
54+
priority?: components["schemas"]["Priority"];
55+
userStatus?: components["schemas"]["user-status"];
56+
taskPriority?: components["schemas"]["task.priority"];
57+
name?: string;
58+
};
59+
};
60+
responses: never;
61+
parameters: never;
62+
requestBodies: never;
63+
headers: never;
64+
pathItems: never;
65+
}
66+
export type $defs = Record<string, never>;
67+
export enum Status {
68+
active = "active",
69+
inactive = "inactive",
70+
pending = "pending"
71+
}
72+
export enum Priority {
73+
low = "low",
74+
medium = "medium",
75+
high = "high"
76+
}
77+
export enum UserStatus {
78+
online = "online",
79+
offline = "offline",
80+
away = "away"
81+
}
82+
export enum TaskPriority {
83+
urgent = "urgent",
84+
normal = "normal",
85+
low_priority = "low-priority"
86+
}
87+
export type operations = Record<string, never>;
88+
89+
export type Task = components["schemas"]["Task"];
90+
export type Endpoints = keyof paths;
91+
"
92+
`;
93+
394
exports[`e2e > officevice.yaml / file URLs 1`] = `
495
"/** This file has been generated by @workleap/create-schemas (https://github.com/workleap/wl-openapi-typescript). Do not modify manually. */
596
export interface paths {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Enum Test API
4+
version: 1.0.0
5+
paths:
6+
/test:
7+
get:
8+
responses:
9+
'200':
10+
description: Success
11+
components:
12+
schemas:
13+
Status:
14+
type: string
15+
enum:
16+
- active
17+
- inactive
18+
- pending
19+
Priority:
20+
type: string
21+
enum:
22+
- low
23+
- medium
24+
- high
25+
user-status:
26+
type: string
27+
enum:
28+
- online
29+
- offline
30+
- away
31+
task.priority:
32+
type: string
33+
enum:
34+
- urgent
35+
- normal
36+
- low-priority
37+
Task:
38+
type: object
39+
properties:
40+
id:
41+
type: string
42+
status:
43+
$ref: '#/components/schemas/Status'
44+
priority:
45+
$ref: '#/components/schemas/Priority'
46+
userStatus:
47+
$ref: '#/components/schemas/user-status'
48+
taskPriority:
49+
$ref: '#/components/schemas/task.priority'
50+
name:
51+
type: string

packages/create-schemas/tests/e2e.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe.concurrent("e2e", () => {
105105

106106
const configFile = `
107107
import { experimental_openapiFetchPlugin } from "../../../src/plugins";
108-
108+
109109
export default { plugins: [experimental_openapiFetchPlugin()] };
110110
`;
111111

@@ -124,4 +124,52 @@ describe.concurrent("e2e", () => {
124124
},
125125
timeout
126126
);
127+
128+
test(
129+
"enums.yaml / enum generation without duplicate type aliases",
130+
async ({ expect, onTestFinished }) => {
131+
const tempFolder = await createTemporaryFolder({ onTestFinished });
132+
133+
const configFile = `
134+
export default {
135+
openApiTsOptions: {
136+
enum: true
137+
}
138+
};
139+
`;
140+
141+
await writeFile(join(tempFolder, "create-schemas.config.ts"), configFile);
142+
143+
const result = await runCompiledBin({
144+
source: join(dataFolder, "enums.yaml"),
145+
outdir: join(tempFolder, "dist"),
146+
cwd: tempFolder
147+
});
148+
149+
const typesFile = result.find(file => file.filename === openapiTypeScriptFilename);
150+
assert(typesFile);
151+
152+
// Verify enums are generated (including those with special characters)
153+
expect(typesFile.code).toContain("export enum Status");
154+
expect(typesFile.code).toContain("export enum Priority");
155+
expect(typesFile.code).toContain("export enum UserStatus");
156+
expect(typesFile.code).toContain("export enum TaskPriority");
157+
158+
// Verify NO duplicate type aliases for enums
159+
const statusTypeAliasRegex = /export type Status = components\["schemas"\]\["Status"\];/;
160+
const priorityTypeAliasRegex = /export type Priority = components\["schemas"\]\["Priority"\];/;
161+
const userStatusTypeAliasRegex = /export type UserStatus = components\["schemas"\]\["user-status"\];/;
162+
const taskPriorityTypeAliasRegex = /export type TaskPriority = components\["schemas"\]\["task\.priority"\];/;
163+
expect(typesFile.code).not.toMatch(statusTypeAliasRegex);
164+
expect(typesFile.code).not.toMatch(priorityTypeAliasRegex);
165+
expect(typesFile.code).not.toMatch(userStatusTypeAliasRegex);
166+
expect(typesFile.code).not.toMatch(taskPriorityTypeAliasRegex);
167+
168+
// Verify non-enum types still get type aliases
169+
expect(typesFile.code).toContain('export type Task = components["schemas"]["Task"];');
170+
171+
expect(typesFile.code).toMatchSnapshot();
172+
},
173+
timeout
174+
);
127175
});

packages/create-schemas/tests/generate.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ describe.concurrent("generate", () => {
1919
expect(typesFile.code).toMatch("export type User");
2020
expect(typesFile.code).toMatch("export type User_1");
2121
expect(typesFile.code).toMatch("export type User_Name");
22-
expect(typesFile.code).toMatch("export type username");
23-
expect(typesFile.code).toMatch("export type myorgUser");
22+
expect(typesFile.code).toMatch("export type UserName");
23+
expect(typesFile.code).toMatch("export type MyOrgUser");
2424
});
2525

2626
test("reject ambiguous names", async ({ expect }) => {

packages/create-schemas/tests/plugins.test.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ describe.concurrent("plugins", () => {
6565
export type User = components["schemas"]["User"];
6666
export type User_1 = components["schemas"]["User_1"];
6767
export type User_Name = components["schemas"]["User_Name"];
68-
export type username = components["schemas"]["user-name"];
69-
export type myorgUser = components["schemas"]["my.org.User"];
68+
export type UserName = components["schemas"]["user-name"];
69+
export type MyOrgUser = components["schemas"]["my.org.User"];
7070
export type Endpoints = keyof paths;
7171
"
7272
`);
@@ -149,4 +149,31 @@ describe.concurrent("plugins", () => {
149149

150150
expect(emittedFile).toBeUndefined();
151151
});
152+
153+
test("typesPlugin with enums", async ({ expect }) => {
154+
const plugin = typesPlugin();
155+
assert(plugin.transform);
156+
157+
const result = await plugin.transform({
158+
config: await resolveConfig({ input: "openapi.json" }),
159+
id: openapiTypeScriptId,
160+
code: `export interface components {
161+
schemas: {
162+
Status: string;
163+
Task: { name: string };
164+
}
165+
}
166+
export enum Status {
167+
Active = "active",
168+
Inactive = "inactive"
169+
}`,
170+
filename: openapiTypeScriptFilename,
171+
emitFile: () => void 0
172+
});
173+
174+
assert(result);
175+
176+
expect(result.code).toContain('export type Task = components["schemas"]["Task"]');
177+
expect(result.code).not.toContain('export type Status = components["schemas"]["Status"]');
178+
});
152179
});

0 commit comments

Comments
 (0)