fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #2116 from hey-api/fix/enum-inline-duplicate

fix(typescript): handle duplicate inline enum names

authored by

Lubos and committed by
GitHub
5553892e 870e0358

+572 -155
+5
.changeset/large-penguins-admire.md
··· 1 + --- 2 + '@hey-api/openapi-ts': patch 3 + --- 4 + 5 + fix(typescript): ensure generated enum uses unique namespace to avoid conflicts with non-enum declarations
+5
.changeset/sharp-plums-join.md
··· 1 + --- 2 + '@hey-api/openapi-ts': patch 3 + --- 4 + 5 + fix(typescript): handle duplicate inline enum names
+4 -4
packages/openapi-ts-tests/test/3.1.x.test.ts
··· 210 210 }, 211 211 { 212 212 config: createConfig({ 213 - input: 'enum-inline.json', 213 + input: 'enum-inline.yaml', 214 214 output: 'enum-inline', 215 215 plugins: [ 216 216 { ··· 223 223 }, 224 224 { 225 225 config: createConfig({ 226 - input: 'enum-inline.json', 226 + input: 'enum-inline.yaml', 227 227 output: 'enum-inline-javascript', 228 228 plugins: [ 229 229 { ··· 237 237 }, 238 238 { 239 239 config: createConfig({ 240 - input: 'enum-inline.json', 240 + input: 'enum-inline.yaml', 241 241 output: 'enum-inline-typescript', 242 242 plugins: [ 243 243 { ··· 251 251 }, 252 252 { 253 253 config: createConfig({ 254 - input: 'enum-inline.json', 254 + input: 'enum-inline.yaml', 255 255 output: 'enum-inline-typescript-namespace', 256 256 plugins: [ 257 257 {
+49
packages/openapi-ts-tests/test/__snapshots__/3.1.x/enum-inline-javascript/types.gen.ts
··· 11 11 type?: 'foo' | 'bar'; 12 12 }; 13 13 14 + export type GetFooData = { 15 + body?: never; 16 + path?: never; 17 + query?: never; 18 + url: '/foo'; 19 + }; 20 + 21 + export type Foo2 = 'foo' | 'bar'; 22 + 23 + export const Foo = { 24 + FOO: 'foo', 25 + BAR: 'bar' 26 + } as const; 27 + 28 + export type GetFooResponses = { 29 + /** 30 + * OK 31 + */ 32 + 200: { 33 + foo?: 'foo' | 'bar'; 34 + }; 35 + }; 36 + 37 + export type GetFooResponse = GetFooResponses[keyof GetFooResponses]; 38 + 39 + export type PostFooData = { 40 + body?: never; 41 + path?: never; 42 + query?: never; 43 + url: '/foo'; 44 + }; 45 + 46 + export type Foo3 = 'baz'; 47 + 48 + export const Foo2 = { 49 + BAZ: 'baz' 50 + } as const; 51 + 52 + export type PostFooResponses = { 53 + /** 54 + * OK 55 + */ 56 + 200: { 57 + foo?: 'baz'; 58 + }; 59 + }; 60 + 61 + export type PostFooResponse = PostFooResponses[keyof PostFooResponses]; 62 + 14 63 export type ClientOptions = { 15 64 baseUrl: `${string}://${string}` | (string & {}); 16 65 };
+36
packages/openapi-ts-tests/test/__snapshots__/3.1.x/enum-inline-typescript-namespace/types.gen.ts
··· 11 11 type?: 'foo' | 'bar'; 12 12 }; 13 13 14 + export type GetFooData = { 15 + body?: never; 16 + path?: never; 17 + query?: never; 18 + url: '/foo'; 19 + }; 20 + 21 + export type GetFooResponses = { 22 + /** 23 + * OK 24 + */ 25 + 200: { 26 + foo?: 'foo' | 'bar'; 27 + }; 28 + }; 29 + 30 + export type GetFooResponse = GetFooResponses[keyof GetFooResponses]; 31 + 32 + export type PostFooData = { 33 + body?: never; 34 + path?: never; 35 + query?: never; 36 + url: '/foo'; 37 + }; 38 + 39 + export type PostFooResponses = { 40 + /** 41 + * OK 42 + */ 43 + 200: { 44 + foo?: 'baz'; 45 + }; 46 + }; 47 + 48 + export type PostFooResponse = PostFooResponses[keyof PostFooResponses]; 49 + 14 50 export type ClientOptions = { 15 51 baseUrl: `${string}://${string}` | (string & {}); 16 52 };
+45
packages/openapi-ts-tests/test/__snapshots__/3.1.x/enum-inline-typescript/types.gen.ts
··· 9 9 type?: 'foo' | 'bar'; 10 10 }; 11 11 12 + export type GetFooData = { 13 + body?: never; 14 + path?: never; 15 + query?: never; 16 + url: '/foo'; 17 + }; 18 + 19 + export enum Foo2 { 20 + FOO = 'foo', 21 + BAR = 'bar' 22 + } 23 + 24 + export type GetFooResponses = { 25 + /** 26 + * OK 27 + */ 28 + 200: { 29 + foo?: 'foo' | 'bar'; 30 + }; 31 + }; 32 + 33 + export type GetFooResponse = GetFooResponses[keyof GetFooResponses]; 34 + 35 + export type PostFooData = { 36 + body?: never; 37 + path?: never; 38 + query?: never; 39 + url: '/foo'; 40 + }; 41 + 42 + export enum Foo3 { 43 + BAZ = 'baz' 44 + } 45 + 46 + export type PostFooResponses = { 47 + /** 48 + * OK 49 + */ 50 + 200: { 51 + foo?: 'baz'; 52 + }; 53 + }; 54 + 55 + export type PostFooResponse = PostFooResponses[keyof PostFooResponses]; 56 + 12 57 export type ClientOptions = { 13 58 baseUrl: `${string}://${string}` | (string & {}); 14 59 };
+40
packages/openapi-ts-tests/test/__snapshots__/3.1.x/enum-inline/types.gen.ts
··· 6 6 type?: 'foo' | 'bar'; 7 7 }; 8 8 9 + export type GetFooData = { 10 + body?: never; 11 + path?: never; 12 + query?: never; 13 + url: '/foo'; 14 + }; 15 + 16 + export type Foo2 = 'foo' | 'bar'; 17 + 18 + export type GetFooResponses = { 19 + /** 20 + * OK 21 + */ 22 + 200: { 23 + foo?: 'foo' | 'bar'; 24 + }; 25 + }; 26 + 27 + export type GetFooResponse = GetFooResponses[keyof GetFooResponses]; 28 + 29 + export type PostFooData = { 30 + body?: never; 31 + path?: never; 32 + query?: never; 33 + url: '/foo'; 34 + }; 35 + 36 + export type Foo3 = 'baz'; 37 + 38 + export type PostFooResponses = { 39 + /** 40 + * OK 41 + */ 42 + 200: { 43 + foo?: 'baz'; 44 + }; 45 + }; 46 + 47 + export type PostFooResponse = PostFooResponses[keyof PostFooResponses]; 48 + 9 49 export type ClientOptions = { 10 50 baseUrl: `${string}://${string}` | (string & {}); 11 51 };
+7 -8
packages/openapi-ts-tests/test/openapi-ts.config.ts
··· 51 51 // 'invalid', 52 52 // 'servers-entry.yaml', 53 53 // ), 54 - path: path.resolve(__dirname, 'spec', '3.1.x', 'full.json'), 54 + path: path.resolve(__dirname, 'spec', '3.1.x', 'type-format.yaml'), 55 55 // path: 'http://localhost:4000/', 56 56 // path: 'https://get.heyapi.dev/', 57 57 // path: 'https://get.heyapi.dev/hey-api/backend?branch=main&version=1.0.0', ··· 111 111 // throwOnError: true, 112 112 // transformer: '@hey-api/transformers', 113 113 // transformer: true, 114 - // validator: 'zod', 114 + validator: 'zod', 115 115 }, 116 116 { 117 117 // bigInt: true, ··· 119 119 // name: '@hey-api/transformers', 120 120 }, 121 121 { 122 - // enums: 'typescript+namespace', 123 - enums: 'javascript', 124 - // enumsCase: 'camelCase', 122 + enums: 'typescript', 123 + enumsCase: 'PascalCase', 125 124 // enumsConstantsIgnoreNull: true, 126 125 // exportInlineEnums: true, 127 126 // identifierCase: 'snake_case', ··· 136 135 }, 137 136 { 138 137 exportFromIndex: true, 139 - name: '@tanstack/vue-query', 138 + // name: '@tanstack/vue-query', 140 139 }, 141 140 { 142 141 // comments: false, 143 142 // exportFromIndex: true, 144 - name: 'valibot', 143 + // name: 'valibot', 145 144 }, 146 145 { 147 146 // comments: false, 148 147 // exportFromIndex: true, 149 - name: 'zod', 148 + // name: 'zod', 150 149 }, 151 150 ], 152 151 // useOptions: false,
-20
packages/openapi-ts-tests/test/spec/3.1.x/enum-inline.json
··· 1 - { 2 - "openapi": "3.1.0", 3 - "info": { 4 - "title": "OpenAPI 3.1.0 enum inline example", 5 - "version": "1" 6 - }, 7 - "components": { 8 - "schemas": { 9 - "Foo": { 10 - "properties": { 11 - "type": { 12 - "enum": ["foo", "bar"], 13 - "type": "string" 14 - } 15 - }, 16 - "type": "object" 17 - } 18 - } 19 - } 20 - }
+43
packages/openapi-ts-tests/test/spec/3.1.x/enum-inline.yaml
··· 1 + openapi: 3.1.0 2 + info: 3 + title: OpenAPI 3.1.0 enum inline example 4 + version: '1' 5 + paths: 6 + /foo: 7 + get: 8 + responses: 9 + '200': 10 + description: OK 11 + content: 12 + application/json: 13 + schema: 14 + type: object 15 + properties: 16 + foo: 17 + type: string 18 + enum: 19 + - foo 20 + - bar 21 + post: 22 + responses: 23 + '200': 24 + description: 'OK' 25 + content: 26 + application/json: 27 + schema: 28 + type: object 29 + properties: 30 + foo: 31 + type: string 32 + enum: 33 + - baz 34 + components: 35 + schemas: 36 + Foo: 37 + properties: 38 + type: 39 + enum: 40 + - foo 41 + - bar 42 + type: string 43 + type: object
+157 -40
packages/openapi-ts/src/generate/__tests__/files.test.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 2 3 + import type { Identifiers } from '../files'; 3 4 import { _test } from '../files'; 4 5 5 - const { ensureUniqueIdentifier, parseRefPath, splitNameAndExtension } = _test; 6 + const { ensureUniqueIdentifier, parseRef, splitNameAndExtension } = _test; 6 7 7 - describe('parseRefPath', () => { 8 + describe('parseRef', () => { 8 9 it('should parse simple ref without properties', () => { 9 10 const ref = '#/components/schemas/User'; 10 - const result = parseRefPath(ref); 11 + const result = parseRef(ref); 11 12 expect(result).toEqual({ 12 - baseRef: '#/components/schemas/User', 13 13 name: 'User', 14 14 properties: [], 15 + ref: '#/components/schemas/User', 15 16 }); 16 17 }); 17 18 18 19 it('should parse ref with single property', () => { 19 20 const ref = '#/components/schemas/User/properties/name'; 20 - const result = parseRefPath(ref); 21 + const result = parseRef(ref); 21 22 expect(result).toEqual({ 22 - baseRef: '#/components/schemas/User', 23 23 name: 'User', 24 24 properties: ['name'], 25 + ref: '#/components/schemas/User', 25 26 }); 26 27 }); 27 28 28 29 it('should parse ref with multiple properties', () => { 29 30 const ref = '#/components/schemas/User/properties/address/properties/city'; 30 - const result = parseRefPath(ref); 31 + const result = parseRef(ref); 31 32 expect(result).toEqual({ 32 - baseRef: '#/components/schemas/User', 33 33 name: 'User', 34 34 properties: ['address', 'city'], 35 + ref: '#/components/schemas/User', 35 36 }); 36 37 }); 37 38 38 39 it('should handle ref with empty name', () => { 39 40 const ref = '#/components/schemas/'; 40 - const result = parseRefPath(ref); 41 + const result = parseRef(ref); 41 42 expect(result).toEqual({ 42 - baseRef: '#/components/schemas/', 43 43 name: '', 44 44 properties: [], 45 + ref: '#/components/schemas/', 45 46 }); 46 47 }); 47 48 48 49 it('should throw error for invalid ref with empty property', () => { 49 50 const ref = '#/components/schemas/User/properties/'; 50 - expect(() => parseRefPath(ref)).toThrow('Invalid $ref: ' + ref); 51 + expect(() => parseRef(ref)).toThrow('Invalid $ref: ' + ref); 51 52 }); 52 53 }); 53 54 ··· 85 86 86 87 describe('ensureUniqueIdentifier', () => { 87 88 it('returns empty name when no name is parsed from ref', () => { 89 + const identifiers: Identifiers = {}; 90 + 88 91 const result = ensureUniqueIdentifier({ 89 92 $ref: '#/components/', 90 93 case: 'camelCase', 91 - namespace: {}, 94 + identifiers, 95 + namespace: 'type', 92 96 }); 93 97 94 98 expect(result).toEqual({ ··· 98 102 }); 99 103 100 104 it('returns existing name from namespace when ref exists', () => { 101 - const namespace = { 102 - '#/components/User': { $ref: '#/components/User', name: 'User' }, 105 + const identifiers: Identifiers = { 106 + user: { 107 + type: { 108 + '#/components/User': { $ref: '#/components/User', name: 'User' }, 109 + }, 110 + }, 103 111 }; 104 112 105 113 const result = ensureUniqueIdentifier({ 106 114 $ref: '#/components/User', 107 115 case: 'camelCase', 108 - namespace, 116 + identifiers, 117 + namespace: 'type', 109 118 }); 110 119 111 120 expect(result).toEqual({ ··· 115 124 }); 116 125 117 126 it('handles nested properties in ref', () => { 118 - const namespace = { 119 - '#/components/User': { $ref: '#/components/User', name: 'User' }, 127 + const identifiers: Identifiers = { 128 + user: { 129 + type: { 130 + '#/components/User': { $ref: '#/components/User', name: 'User' }, 131 + }, 132 + }, 120 133 }; 121 134 122 135 const result = ensureUniqueIdentifier({ 123 136 $ref: '#/components/User/properties/id', 124 137 case: 'camelCase', 125 - namespace, 138 + identifiers, 139 + namespace: 'type', 126 140 }); 127 141 128 142 expect(result).toEqual({ ··· 132 146 }); 133 147 134 148 it('applies nameTransformer and case transformation', () => { 135 - const namespace = {}; 136 149 const nameTransformer = (name: string) => `prefix${name}`; 150 + const identifiers: Identifiers = { 151 + user: { 152 + type: {}, 153 + }, 154 + }; 137 155 138 156 const result = ensureUniqueIdentifier({ 139 157 $ref: '#/components/User', 140 158 case: 'camelCase', 141 159 create: true, 160 + identifiers, 142 161 nameTransformer, 143 - namespace, 162 + namespace: 'type', 144 163 }); 145 164 146 165 expect(result).toEqual({ 147 166 created: true, 148 167 name: 'prefixUser', 149 168 }); 150 - expect(namespace).toHaveProperty('prefixUser', { 151 - $ref: '#/components/User', 152 - name: 'prefixUser', 169 + expect(identifiers).toHaveProperty('user', { 170 + type: { 171 + '#/components/User': { 172 + $ref: '#/components/User', 173 + name: 'prefixUser', 174 + }, 175 + prefixUser: { 176 + $ref: '#/components/User', 177 + name: 'prefixUser', 178 + }, 179 + }, 153 180 }); 154 181 }); 155 182 156 183 it('resolves naming conflicts by appending count', () => { 157 - const namespace = { 158 - user: { $ref: '#/components/Other', name: 'user' }, 184 + const identifiers: Identifiers = { 185 + user: { 186 + type: { 187 + user: { $ref: '#/components/Other', name: 'user' }, 188 + }, 189 + }, 159 190 }; 160 191 161 192 const result = ensureUniqueIdentifier({ 162 193 $ref: '#/components/User', 163 194 case: 'camelCase', 164 195 create: true, 165 - namespace, 196 + identifiers, 197 + namespace: 'type', 166 198 }); 167 199 168 200 expect(result).toEqual({ 169 201 created: true, 170 202 name: 'user2', 171 203 }); 172 - expect(namespace).toHaveProperty('user2', { 173 - $ref: '#/components/User', 174 - name: 'user2', 204 + expect(identifiers).toHaveProperty('user2', { 205 + type: { 206 + '#/components/User': { 207 + $ref: '#/components/User', 208 + name: 'user2', 209 + }, 210 + user2: { 211 + $ref: '#/components/User', 212 + name: 'user2', 213 + }, 214 + }, 175 215 }); 176 216 }); 177 217 178 218 it('returns existing name when ref matches in namespace', () => { 179 - const namespace = { 180 - '#/components/User': { $ref: '#/components/User', name: 'user' }, 181 - user: { $ref: '#/components/User', name: 'user' }, 219 + const identifiers: Identifiers = { 220 + user: { 221 + type: { 222 + '#/components/User': { $ref: '#/components/User', name: 'user' }, 223 + user: { $ref: '#/components/User', name: 'user' }, 224 + }, 225 + }, 182 226 }; 183 227 184 228 const result = ensureUniqueIdentifier({ 185 229 $ref: '#/components/User', 186 230 case: 'camelCase', 187 - namespace, 231 + identifiers, 232 + namespace: 'type', 188 233 }); 189 234 190 235 expect(result).toEqual({ ··· 194 239 }); 195 240 196 241 it('does not create new entry when create is false', () => { 197 - const namespace = {}; 242 + const identifiers: Identifiers = {}; 198 243 199 244 const result = ensureUniqueIdentifier({ 200 245 $ref: '#/components/User', 201 246 case: 'camelCase', 202 247 create: false, 203 - namespace, 248 + identifiers, 249 + namespace: 'type', 204 250 }); 205 251 206 252 expect(result).toEqual({ 207 253 created: false, 208 254 name: '', 209 255 }); 210 - expect(namespace).toEqual({}); 256 + expect(identifiers).toEqual({ 257 + user: {}, 258 + }); 211 259 }); 212 260 213 - it('returns existing identifier if name collision matches same baseRef', () => { 214 - const namespace: any = { 215 - User: { $ref: '#/components/schemas/User', name: 'User' }, 261 + it('returns existing identifier if name collision matches same ref', () => { 262 + const identifiers: Identifiers = { 263 + user: { 264 + type: { 265 + User: { $ref: '#/components/schemas/User', name: 'User' }, 266 + }, 267 + }, 216 268 }; 217 269 218 270 const result = ensureUniqueIdentifier({ 219 271 $ref: '#/components/schemas/User', 220 272 case: 'PascalCase', 221 273 create: true, 222 - namespace, 274 + identifiers, 275 + namespace: 'type', 223 276 }); 224 277 225 278 expect(result).toEqual({ created: false, name: 'User' }); 279 + }); 280 + 281 + it('creates a new identifier for enum if name collision matches non-enum', () => { 282 + const identifiers: Identifiers = { 283 + user: { 284 + type: { 285 + User: { $ref: '#/components/schemas/User', name: 'User' }, 286 + }, 287 + }, 288 + }; 289 + 290 + const result = ensureUniqueIdentifier({ 291 + $ref: '#/components/schemas/User', 292 + case: 'PascalCase', 293 + create: true, 294 + identifiers, 295 + namespace: 'enum', 296 + }); 297 + 298 + expect(result).toEqual({ created: true, name: 'User2' }); 299 + expect(identifiers).toHaveProperty('user2', { 300 + enum: { 301 + '#/components/schemas/User': { 302 + $ref: '#/components/schemas/User', 303 + name: 'User2', 304 + }, 305 + User2: { 306 + $ref: '#/components/schemas/User', 307 + name: 'User2', 308 + }, 309 + }, 310 + }); 311 + }); 312 + 313 + it('creates a new identifier for non-enum if name collision matches enum', () => { 314 + const identifiers: Identifiers = { 315 + user: { 316 + enum: { 317 + User: { $ref: '#/components/schemas/User', name: 'User' }, 318 + }, 319 + }, 320 + }; 321 + 322 + const result = ensureUniqueIdentifier({ 323 + $ref: '#/components/schemas/User', 324 + case: 'PascalCase', 325 + create: true, 326 + identifiers, 327 + namespace: 'type', 328 + }); 329 + 330 + expect(result).toEqual({ created: true, name: 'User2' }); 331 + expect(identifiers).toHaveProperty('user2', { 332 + type: { 333 + '#/components/schemas/User': { 334 + $ref: '#/components/schemas/User', 335 + name: 'User2', 336 + }, 337 + User2: { 338 + $ref: '#/components/schemas/User', 339 + name: 'User2', 340 + }, 341 + }, 342 + }); 226 343 }); 227 344 });
+126 -60
packages/openapi-ts/src/generate/files.ts
··· 22 22 name: string | false; 23 23 } 24 24 25 - type Namespace = Record< 25 + type NamespaceEntry = Pick<Identifier, 'name'> & { 26 + /** 27 + * Ref to the type in OpenAPI specification. 28 + */ 29 + $ref: string; 30 + }; 31 + 32 + export type Identifiers = Record< 26 33 string, 27 - Pick<Identifier, 'name'> & { 34 + { 28 35 /** 29 - * Ref to the type in OpenAPI specification. 36 + * TypeScript enum only namespace. 37 + * 38 + * @example 39 + * ```ts 40 + * export enum Foo = { 41 + * FOO = 'foo' 42 + * } 43 + * ``` 30 44 */ 31 - $ref: string; 45 + enum?: Record<string, NamespaceEntry>; 46 + /** 47 + * Type namespace. Types, interfaces, and type aliases exist here. 48 + * 49 + * @example 50 + * ```ts 51 + * export type Foo = string; 52 + * ``` 53 + */ 54 + type?: Record<string, NamespaceEntry>; 55 + /** 56 + * Value namespace. Variables, functions, classes, and constants exist here. 57 + * 58 + * @example 59 + * ```js 60 + * export const foo = ''; 61 + * ``` 62 + */ 63 + value?: Record<string, NamespaceEntry>; 32 64 } 33 65 >; 34 66 35 - interface Namespaces { 36 - /** 37 - * Type namespace. Types, interfaces, and type aliases exist here. 38 - * @example 39 - * ```ts 40 - * export type Foo = string; 41 - * ``` 42 - */ 43 - type: Namespace; 44 - /** 45 - * Value namespace. Variables, functions, classes, and constants exist here. 46 - * @example 47 - * ```js 48 - * export const foo = ''; 49 - * ``` 50 - */ 51 - value: Namespace; 52 - } 67 + type Namespace = keyof Identifiers[keyof Identifiers]; 53 68 54 69 export type FileImportResult = Pick<ImportExportItemObject, 'asType' | 'name'>; 55 70 ··· 66 81 private _name: string; 67 82 private _path: string; 68 83 69 - public namespaces: Namespaces = { 70 - type: {}, 71 - value: {}, 72 - }; 84 + public identifiers: Identifiers = {}; 85 + 73 86 /** 74 87 * Path relative to the client output root. 75 88 */ ··· 126 139 $ref, 127 140 namespace, 128 141 }: Pick<EnsureUniqueIdentifierData, '$ref'> & { 129 - namespace: keyof Namespaces; 142 + namespace: Namespace; 130 143 }): Identifier { 131 - const refValue = this.namespaces[namespace][$ref]; 144 + const { name, ref } = parseRef($ref); 145 + const refValue = 146 + this.identifiers[name.toLocaleLowerCase()]?.[namespace]?.[ref]; 132 147 if (!refValue) { 133 148 throw new Error( 134 149 `Identifier for $ref ${$ref} in namespace ${namespace} not found`, ··· 151 166 return this._id; 152 167 } 153 168 154 - public identifier({ 155 - namespace, 156 - ...args 157 - }: Omit<EnsureUniqueIdentifierData, 'case' | 'namespace'> & { 158 - namespace: keyof Namespaces; 159 - }): Identifier { 169 + public identifier( 170 + args: Pick< 171 + EnsureUniqueIdentifierData, 172 + '$ref' | 'count' | 'create' | 'nameTransformer' 173 + > & { 174 + namespace: Namespace; 175 + }, 176 + ): Identifier { 160 177 return ensureUniqueIdentifier({ 161 178 case: this._identifierCase, 162 - namespace: this.namespaces[namespace], 179 + identifiers: this.identifiers, 163 180 ...args, 164 181 }); 165 182 } ··· 340 357 } 341 358 } 342 359 343 - function parseRefPath(ref: string): { 344 - baseRef: string; 360 + const parseRef = ( 361 + $ref: string, 362 + ): { 363 + /** 364 + * Extracted name from `$ref`, equal to the last part or property name. 365 + */ 345 366 name: string; 346 - properties: string[]; 347 - } { 348 - let baseRef = ref; 367 + /** 368 + * List of properties extracted from `$ref`, if any. 369 + */ 370 + properties: ReadonlyArray<string>; 371 + /** 372 + * `$ref` without properties if they're included in `$ref`, otherwise 373 + * `ref` is equal to `$ref`. 374 + */ 375 + ref: string; 376 + } => { 377 + let ref = $ref; 349 378 const properties: string[] = []; 350 379 351 - const parts = baseRef.split('/'); 380 + const parts = ref.split('/'); 352 381 let name = parts[parts.length - 1] || ''; 353 382 354 383 let propIndex = parts.indexOf('properties'); 355 384 356 385 if (propIndex !== -1) { 357 - baseRef = parts.slice(0, propIndex).join('/'); 386 + ref = parts.slice(0, propIndex).join('/'); 358 387 name = parts[propIndex - 1] || ''; 359 388 360 389 while (propIndex + 1 < parts.length) { 361 390 const prop = parts[propIndex + 1]; 362 391 if (!prop) { 363 - throw new Error(`Invalid $ref: ${ref}`); 392 + throw new Error(`Invalid $ref: ${$ref}`); 364 393 } 365 394 properties.push(prop); 366 395 propIndex += 2; ··· 368 397 } 369 398 370 399 return { 371 - baseRef, 372 400 name, 373 401 properties, 402 + ref, 374 403 }; 375 - } 404 + }; 376 405 377 406 interface EnsureUniqueIdentifierData { 378 407 $ref: string; 379 408 case: StringCase | undefined; 380 409 count?: number; 381 410 create?: boolean; 411 + identifiers: Identifiers; 382 412 /** 383 413 * Transforms name obtained from `$ref` before it's passed to `stringCase()`. 384 414 */ ··· 391 421 case: identifierCase, 392 422 count = 1, 393 423 create = false, 424 + identifiers, 394 425 nameTransformer, 395 426 namespace, 396 427 }: EnsureUniqueIdentifierData): Identifier => { 397 - const { baseRef, name, properties } = parseRefPath($ref); 428 + const { name, properties, ref } = parseRef($ref); 398 429 399 430 if (!name) { 400 431 return { ··· 403 434 }; 404 435 } 405 436 406 - const refValue = namespace[baseRef]; 437 + let nameWithCasing = stringCase({ 438 + case: identifierCase, 439 + value: name, 440 + }); 441 + if (count > 1) { 442 + nameWithCasing = `${nameWithCasing}${count}`; 443 + } 444 + const lowercaseName = nameWithCasing.toLocaleLowerCase(); 445 + if (!identifiers[lowercaseName]) { 446 + identifiers[lowercaseName] = {}; 447 + } 448 + const identifier = identifiers[lowercaseName]; 449 + 450 + // Enum declarations can only merge with namespace or other enum 451 + // declarations, so we need to ensure we don't mix them up. 452 + if ( 453 + (namespace === 'enum' && (identifier.type || identifier.value)) || 454 + (namespace !== 'enum' && identifier.enum) 455 + ) { 456 + return ensureUniqueIdentifier({ 457 + $ref: ref, 458 + case: identifierCase, 459 + count: count + 1, 460 + create, 461 + identifiers, 462 + nameTransformer, 463 + namespace, 464 + }); 465 + } 466 + 467 + if (!identifier[namespace]) { 468 + identifier[namespace] = {}; 469 + } 470 + const id = identifier[namespace]; 471 + 472 + const refValue = id[ref]; 407 473 if (refValue) { 408 474 let name = refValue.name; 409 475 if (properties.length) { ··· 415 481 }; 416 482 } 417 483 418 - const nameWithTransform = nameTransformer?.(name) ?? name; 419 - let nameWithCasing = stringCase({ 484 + let nameWithCasingAndTransformer = stringCase({ 420 485 case: identifierCase, 421 - value: nameWithTransform, 486 + value: nameTransformer?.(name) ?? name, 422 487 }); 423 - 424 488 if (count > 1) { 425 - nameWithCasing = `${nameWithCasing}${count}`; 489 + nameWithCasingAndTransformer = `${nameWithCasingAndTransformer}${count}`; 426 490 } 427 491 428 - let nameValue = namespace[nameWithCasing]; 492 + let nameValue = id[nameWithCasingAndTransformer]; 429 493 if (nameValue) { 430 - if (nameValue.$ref === baseRef) { 494 + if (nameValue.$ref === ref) { 431 495 return { 432 496 created: false, 433 497 name: nameValue.name, ··· 435 499 } 436 500 437 501 return ensureUniqueIdentifier({ 438 - $ref: baseRef, 502 + $ref: ref, 439 503 case: identifierCase, 440 504 count: count + 1, 441 505 create, 506 + identifiers, 442 507 nameTransformer, 443 508 namespace, 444 509 }); 445 510 } 446 511 447 512 if (!create) { 513 + delete identifier[namespace]; 448 514 return { 449 515 created: false, 450 516 name: '', ··· 452 518 } 453 519 454 520 nameValue = { 455 - $ref: baseRef, 456 - name: ensureValidIdentifier(nameWithCasing), 521 + $ref: ref, 522 + name: ensureValidIdentifier(nameWithCasingAndTransformer), 457 523 }; 458 - namespace[nameWithCasing] = nameValue; 459 - namespace[nameValue.$ref] = nameValue; 524 + id[nameWithCasingAndTransformer] = nameValue; 525 + id[nameValue.$ref] = nameValue; 460 526 461 527 return { 462 528 created: true, ··· 476 542 477 543 export const _test = { 478 544 ensureUniqueIdentifier, 479 - parseRefPath, 545 + parseRef, 480 546 splitNameAndExtension, 481 547 };
+55 -23
packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts
··· 27 27 * strip the other type. 28 28 */ 29 29 accessScope?: 'read' | 'write'; 30 + /** 31 + * Path to the currently processed field. This can be used to generate 32 + * deduplicated inline types. For example, if two schemas define a different 33 + * enum `foo`, we want to generate two unique types instead of one. 34 + */ 35 + path: ReadonlyArray<string>; 30 36 } 31 37 32 38 const scopeToRef = ({ ··· 272 278 schema: SchemaWithType<'enum'>; 273 279 state: State | undefined; 274 280 }) => { 275 - const file = context.file({ id: typesId })!; 276 - const identifier = file.identifier({ 277 - $ref, 278 - create: true, 279 - namespace: 'value', 280 - }); 281 - 282 - // TODO: parser - this is the old parser behavior where we would NOT 283 - // print nested enum identifiers if they already exist. This is a 284 - // blocker for referencing these identifiers within the file as 285 - // we cannot guarantee just because they have a duplicate identifier, 286 - // they have a duplicate value. 287 - if (!identifier.created && plugin.enums !== 'typescript+namespace') { 288 - return; 289 - } 290 - 291 281 const enumObject = schemaToEnumObject({ plugin, schema }); 292 282 293 283 // TypeScript enums support only string and number values so we need to fallback to types ··· 304 294 state, 305 295 }); 306 296 return node; 297 + } 298 + 299 + const file = context.file({ id: typesId })!; 300 + const identifier = file.identifier({ 301 + $ref, 302 + create: true, 303 + namespace: 'enum', 304 + }); 305 + 306 + // TODO: parser - this is the old parser behavior where we would NOT 307 + // print nested enum identifiers if they already exist. This is a 308 + // blocker for referencing these identifiers within the file as 309 + // we cannot guarantee just because they have a duplicate identifier, 310 + // they have a duplicate value. 311 + if (!identifier.created && plugin.enums !== 'typescript+namespace') { 312 + return; 307 313 } 308 314 309 315 const node = compiler.enumDeclaration({ ··· 554 560 isRequired, 555 561 name: fieldName({ context, name }), 556 562 type: schemaToType({ 557 - $ref: `${irRef}${name}`, 563 + $ref: state ? [...state.path, name].join('/') : `${irRef}${name}`, 558 564 context, 559 565 namespace, 560 566 plugin, ··· 918 924 schema: data, 919 925 state: 920 926 plugin.readOnlyWriteOnlyBehavior === 'off' 921 - ? undefined 927 + ? { 928 + path: [operation.method, operation.path, 'data'], 929 + } 922 930 : { 923 931 accessScope: 'write', 932 + path: [operation.method, operation.path, 'data'], 924 933 }, 925 934 }); 926 935 ··· 971 980 schema: errors, 972 981 state: 973 982 plugin.readOnlyWriteOnlyBehavior === 'off' 974 - ? undefined 983 + ? { 984 + path: [operation.method, operation.path, 'errors'], 985 + } 975 986 : { 976 987 accessScope: 'read', 988 + path: [operation.method, operation.path, 'errors'], 977 989 }, 978 990 }); 979 991 ··· 1035 1047 schema: responses, 1036 1048 state: 1037 1049 plugin.readOnlyWriteOnlyBehavior === 'off' 1038 - ? undefined 1050 + ? { 1051 + path: [operation.method, operation.path, 'responses'], 1052 + } 1039 1053 : { 1040 1054 accessScope: 'read', 1055 + path: [operation.method, operation.path, 'responses'], 1041 1056 }, 1042 1057 }); 1043 1058 ··· 1166 1181 const itemTypes: Array<ts.TypeNode> = []; 1167 1182 1168 1183 for (const item of schema.items) { 1184 + // TODO: correctly populate state.path 1169 1185 const type = schemaToType({ 1170 1186 context, 1171 1187 namespace, ··· 1183 1199 ? compiler.typeIntersectionNode({ types: itemTypes }) 1184 1200 : compiler.typeUnionNode({ types: itemTypes }); 1185 1201 } else { 1202 + // TODO: correctly populate state.path 1186 1203 type = schemaToType({ 1187 1204 context, 1188 1205 namespace, ··· 1265 1282 context, 1266 1283 plugin, 1267 1284 schema, 1268 - state: undefined, 1285 + state: { 1286 + // TODO: correctly populate state.path 1287 + path: [], 1288 + }, 1269 1289 }); 1270 1290 return; 1271 1291 } ··· 1282 1302 schema, 1283 1303 state: { 1284 1304 accessScope: 'read', 1305 + // TODO: correctly populate state.path 1306 + path: [], 1285 1307 }, 1286 1308 }); 1287 1309 } ··· 1298 1320 schema, 1299 1321 state: { 1300 1322 accessScope: 'write', 1323 + // TODO: correctly populate state.path 1324 + path: [], 1301 1325 }, 1302 1326 }); 1303 1327 } ··· 1309 1333 context, 1310 1334 plugin, 1311 1335 schema: parameter.schema, 1312 - state: undefined, 1336 + state: { 1337 + // TODO: correctly populate state.path 1338 + path: [], 1339 + }, 1313 1340 }); 1314 1341 }); 1315 1342 ··· 1321 1348 schema: requestBody.schema, 1322 1349 state: 1323 1350 plugin.readOnlyWriteOnlyBehavior === 'off' 1324 - ? undefined 1351 + ? { 1352 + // TODO: correctly populate state.path 1353 + path: [], 1354 + } 1325 1355 : { 1326 1356 accessScope: 'write', 1357 + // TODO: correctly populate state.path 1358 + path: [], 1327 1359 }, 1328 1360 }); 1329 1361 });