Skip to content

Commit 930a8d2

Browse files
authored
Merge pull request #2020 from 0xfurai/fix/refs-with-properties
2 parents fd296a9 + 27cb409 commit 930a8d2

File tree

3 files changed

+282
-7
lines changed

3 files changed

+282
-7
lines changed

.changeset/rude-bats-knock.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/openapi-ts": patch
3+
---
4+
5+
fix: handle references to properties
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { _test } from '../files';
4+
5+
const { ensureUniqueIdentifier, parseRefPath, splitNameAndExtension } = _test;
6+
7+
describe('parseRefPath', () => {
8+
it('should parse simple ref without properties', () => {
9+
const ref = '#/components/schemas/User';
10+
const result = parseRefPath(ref);
11+
expect(result).toEqual({
12+
baseRef: '#/components/schemas/User',
13+
name: 'User',
14+
properties: [],
15+
});
16+
});
17+
18+
it('should parse ref with single property', () => {
19+
const ref = '#/components/schemas/User/properties/name';
20+
const result = parseRefPath(ref);
21+
expect(result).toEqual({
22+
baseRef: '#/components/schemas/User',
23+
name: 'User',
24+
properties: ['name'],
25+
});
26+
});
27+
28+
it('should parse ref with multiple properties', () => {
29+
const ref = '#/components/schemas/User/properties/address/properties/city';
30+
const result = parseRefPath(ref);
31+
expect(result).toEqual({
32+
baseRef: '#/components/schemas/User',
33+
name: 'User',
34+
properties: ['address', 'city'],
35+
});
36+
});
37+
38+
it('should handle ref with empty name', () => {
39+
const ref = '#/components/schemas/';
40+
const result = parseRefPath(ref);
41+
expect(result).toEqual({
42+
baseRef: '#/components/schemas/',
43+
name: '',
44+
properties: [],
45+
});
46+
});
47+
48+
it('should throw error for invalid ref with empty property', () => {
49+
const ref = '#/components/schemas/User/properties/';
50+
expect(() => parseRefPath(ref)).toThrow('Invalid $ref: ' + ref);
51+
});
52+
});
53+
54+
describe('splitNameAndExtension', () => {
55+
it('should split filename with extension correctly', () => {
56+
const result = splitNameAndExtension('document.pdf');
57+
expect(result).toEqual({ extension: 'pdf', name: 'document' });
58+
});
59+
60+
it('should handle filename without extension', () => {
61+
const result = splitNameAndExtension('README');
62+
expect(result).toEqual({ extension: '', name: 'README' });
63+
});
64+
65+
it('should handle filename with multiple dots', () => {
66+
const result = splitNameAndExtension('my.file.name.txt');
67+
expect(result).toEqual({ extension: 'txt', name: 'my.file.name' });
68+
});
69+
70+
it('should handle empty string', () => {
71+
const result = splitNameAndExtension('');
72+
expect(result).toEqual({ extension: '', name: '' });
73+
});
74+
75+
it('should handle filename with uppercase extension', () => {
76+
const result = splitNameAndExtension('image.PNG');
77+
expect(result).toEqual({ extension: 'PNG', name: 'image' });
78+
});
79+
80+
it('should handle extension with numbers', () => {
81+
const result = splitNameAndExtension('video.mp4');
82+
expect(result).toEqual({ extension: 'mp4', name: 'video' });
83+
});
84+
});
85+
86+
describe('ensureUniqueIdentifier', () => {
87+
it('returns empty name when no name is parsed from ref', () => {
88+
const result = ensureUniqueIdentifier({
89+
$ref: '#/components/',
90+
case: 'camelCase',
91+
namespace: {},
92+
});
93+
94+
expect(result).toEqual({
95+
created: false,
96+
name: '',
97+
});
98+
});
99+
100+
it('returns existing name from namespace when ref exists', () => {
101+
const namespace = {
102+
'#/components/User': { $ref: '#/components/User', name: 'User' },
103+
};
104+
105+
const result = ensureUniqueIdentifier({
106+
$ref: '#/components/User',
107+
case: 'camelCase',
108+
namespace,
109+
});
110+
111+
expect(result).toEqual({
112+
created: false,
113+
name: 'User',
114+
});
115+
});
116+
117+
it('handles nested properties in ref', () => {
118+
const namespace = {
119+
'#/components/User': { $ref: '#/components/User', name: 'User' },
120+
};
121+
122+
const result = ensureUniqueIdentifier({
123+
$ref: '#/components/User/properties/id',
124+
case: 'camelCase',
125+
namespace,
126+
});
127+
128+
expect(result).toEqual({
129+
created: false,
130+
name: "User['id']",
131+
});
132+
});
133+
134+
it('applies nameTransformer and case transformation', () => {
135+
const namespace = {};
136+
const nameTransformer = (name: string) => `prefix${name}`;
137+
138+
const result = ensureUniqueIdentifier({
139+
$ref: '#/components/User',
140+
case: 'camelCase',
141+
create: true,
142+
nameTransformer,
143+
namespace,
144+
});
145+
146+
expect(result).toEqual({
147+
created: true,
148+
name: 'prefixUser',
149+
});
150+
expect(namespace).toHaveProperty('prefixUser', {
151+
$ref: '#/components/User',
152+
name: 'prefixUser',
153+
});
154+
});
155+
156+
it('resolves naming conflicts by appending count', () => {
157+
const namespace = {
158+
user: { $ref: '#/components/Other', name: 'user' },
159+
};
160+
161+
const result = ensureUniqueIdentifier({
162+
$ref: '#/components/User',
163+
case: 'camelCase',
164+
create: true,
165+
namespace,
166+
});
167+
168+
expect(result).toEqual({
169+
created: true,
170+
name: 'user2',
171+
});
172+
expect(namespace).toHaveProperty('user2', {
173+
$ref: '#/components/User',
174+
name: 'user2',
175+
});
176+
});
177+
178+
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' },
182+
};
183+
184+
const result = ensureUniqueIdentifier({
185+
$ref: '#/components/User',
186+
case: 'camelCase',
187+
namespace,
188+
});
189+
190+
expect(result).toEqual({
191+
created: false,
192+
name: 'user',
193+
});
194+
});
195+
196+
it('does not create new entry when create is false', () => {
197+
const namespace = {};
198+
199+
const result = ensureUniqueIdentifier({
200+
$ref: '#/components/User',
201+
case: 'camelCase',
202+
create: false,
203+
namespace,
204+
});
205+
206+
expect(result).toEqual({
207+
created: false,
208+
name: '',
209+
});
210+
expect(namespace).toEqual({});
211+
});
212+
213+
it('returns existing identifier if name collision matches same baseRef', () => {
214+
const namespace: any = {
215+
User: { $ref: '#/components/schemas/User', name: 'User' },
216+
};
217+
218+
const result = ensureUniqueIdentifier({
219+
$ref: '#/components/schemas/User',
220+
case: 'PascalCase',
221+
create: true,
222+
namespace,
223+
});
224+
225+
expect(result).toEqual({ created: false, name: 'User' });
226+
});
227+
});

packages/openapi-ts/src/generate/files.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,40 @@ export class TypeScriptFile {
340340
}
341341
}
342342

343+
function parseRefPath(ref: string): {
344+
baseRef: string;
345+
name: string;
346+
properties: string[];
347+
} {
348+
let baseRef = ref;
349+
const properties: string[] = [];
350+
351+
const parts = baseRef.split('/');
352+
let name = parts[parts.length - 1] || '';
353+
354+
let propIndex = parts.indexOf('properties');
355+
356+
if (propIndex !== -1) {
357+
baseRef = parts.slice(0, propIndex).join('/');
358+
name = parts[propIndex - 1] || '';
359+
360+
while (propIndex + 1 < parts.length) {
361+
const prop = parts[propIndex + 1];
362+
if (!prop) {
363+
throw new Error(`Invalid $ref: ${ref}`);
364+
}
365+
properties.push(prop);
366+
propIndex += 2;
367+
}
368+
}
369+
370+
return {
371+
baseRef,
372+
name,
373+
properties,
374+
};
375+
}
376+
343377
interface EnsureUniqueIdentifierData {
344378
$ref: string;
345379
case: StringCase | undefined;
@@ -360,8 +394,7 @@ const ensureUniqueIdentifier = ({
360394
nameTransformer,
361395
namespace,
362396
}: EnsureUniqueIdentifierData): Identifier => {
363-
const parts = $ref.split('/');
364-
const name = parts[parts.length - 1] || '';
397+
const { baseRef, name, properties } = parseRefPath($ref);
365398

366399
if (!name) {
367400
return {
@@ -370,11 +403,15 @@ const ensureUniqueIdentifier = ({
370403
};
371404
}
372405

373-
const refValue = namespace[$ref];
406+
const refValue = namespace[baseRef];
374407
if (refValue) {
408+
let name = refValue.name;
409+
if (properties.length) {
410+
name += properties.map((property) => `['${property}']`).join('');
411+
}
375412
return {
376413
created: false,
377-
name: refValue.name,
414+
name: name as string,
378415
};
379416
}
380417

@@ -390,15 +427,15 @@ const ensureUniqueIdentifier = ({
390427

391428
let nameValue = namespace[nameWithCasing];
392429
if (nameValue) {
393-
if (nameValue.$ref === $ref) {
430+
if (nameValue.$ref === baseRef) {
394431
return {
395432
created: false,
396433
name: nameValue.name,
397434
};
398435
}
399436

400437
return ensureUniqueIdentifier({
401-
$ref,
438+
$ref: baseRef,
402439
case: identifierCase,
403440
count: count + 1,
404441
create,
@@ -415,7 +452,7 @@ const ensureUniqueIdentifier = ({
415452
}
416453

417454
nameValue = {
418-
$ref,
455+
$ref: baseRef,
419456
name: ensureValidIdentifier(nameWithCasing),
420457
};
421458
namespace[nameWithCasing] = nameValue;
@@ -436,3 +473,9 @@ const splitNameAndExtension = (fileName: string) => {
436473
);
437474
return { extension, name };
438475
};
476+
477+
export const _test = {
478+
ensureUniqueIdentifier,
479+
parseRefPath,
480+
splitNameAndExtension,
481+
};

0 commit comments

Comments
 (0)