Skip to content

Commit 1a33cb2

Browse files
authored
Merge pull request #3782 from hey-api/copilot/fix-missing-operations-filtering
fix: decode URI-encoded names in operation dependencies to prevent missing filtered operations
2 parents e415f69 + 290c14f commit 1a33cb2

158 files changed

Lines changed: 470 additions & 295 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/kind-terms-greet.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hey-api/json-schema-ref-parser": patch
3+
"@hey-api/openapi-ts": patch
4+
"@hey-api/shared": patch
5+
---
6+
7+
**parser**: fix: avoid encoding url unsafe characters

packages/json-schema-ref-parser/src/__tests__/bundle.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs';
2+
import os from 'node:os';
23
import path from 'node:path';
34
import { fileURLToPath } from 'node:url';
45

@@ -11,6 +12,10 @@ const __dirname = path.dirname(__filename);
1112
const getSnapshotsPath = () => path.join(__dirname, '__snapshots__');
1213
const getTempSnapshotsPath = () => path.join(__dirname, '.gen', 'snapshots');
1314

15+
const writeJsonFile = (filePath: string, value: unknown) => {
16+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
17+
};
18+
1419
/**
1520
* Helper function to compare a bundled schema with a snapshot file.
1621
* Handles writing the schema to a temp file and comparing with the snapshot.
@@ -46,6 +51,176 @@ describe('bundle', () => {
4651
await expectBundledSchemaToMatchSnapshot(schema, 'circular-ref-with-description.json');
4752
});
4853

54+
it('emits decoded internal refs for generic component names', async () => {
55+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-schema-ref-parser-'));
56+
57+
try {
58+
const rootPath = path.join(tempDir, 'root.json');
59+
60+
writeJsonFile(rootPath, {
61+
components: {
62+
schemas: {
63+
ClientResponse: {
64+
properties: {
65+
id: {
66+
type: 'string',
67+
},
68+
},
69+
type: 'object',
70+
},
71+
'PaginatedListItems<ClientResponse>': {
72+
properties: {
73+
items: {
74+
items: {
75+
$ref: '#/components/schemas/ClientResponse',
76+
},
77+
type: 'array',
78+
},
79+
},
80+
type: 'object',
81+
},
82+
},
83+
},
84+
info: {
85+
title: 'Test API',
86+
version: '1.0.0',
87+
},
88+
openapi: '3.0.0',
89+
paths: {
90+
'/clients': {
91+
get: {
92+
responses: {
93+
'200': {
94+
content: {
95+
'application/json': {
96+
schema: {
97+
$ref: '#/components/schemas/PaginatedListItems<ClientResponse>',
98+
},
99+
},
100+
},
101+
description: 'ok',
102+
},
103+
},
104+
},
105+
},
106+
},
107+
});
108+
109+
const refParser = new $RefParser();
110+
const schema = (await refParser.bundle({ pathOrUrlOrSchema: rootPath })) as any;
111+
112+
expect(
113+
schema.paths['/clients'].get.responses['200'].content['application/json'].schema.$ref,
114+
).toBe('#/components/schemas/PaginatedListItems<ClientResponse>');
115+
116+
const bundledJson = JSON.stringify(schema);
117+
expect(bundledJson).not.toContain('PaginatedListItems%3CClientResponse%3E');
118+
} finally {
119+
fs.rmSync(tempDir, { force: true, recursive: true });
120+
}
121+
});
122+
123+
it('emits decoded refs for external schemas with generic and unicode names', async () => {
124+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-schema-ref-parser-'));
125+
126+
try {
127+
const externalPath = path.join(tempDir, 'external.json');
128+
const rootPath = path.join(tempDir, 'root.json');
129+
130+
writeJsonFile(externalPath, {
131+
components: {
132+
schemas: {
133+
'PaginatedList<ClientItem>': {
134+
description: 'generic schema',
135+
properties: {
136+
next: {
137+
$ref: '#/components/schemas/PaginatedList<ClientItem>',
138+
},
139+
},
140+
type: 'object',
141+
},
142+
Überschrift: {
143+
description: 'unicode schema',
144+
properties: {
145+
next: {
146+
$ref: '#/components/schemas/Überschrift',
147+
},
148+
},
149+
type: 'object',
150+
},
151+
},
152+
},
153+
});
154+
155+
writeJsonFile(rootPath, {
156+
info: {
157+
title: 'Test API',
158+
version: '1.0.0',
159+
},
160+
openapi: '3.0.0',
161+
paths: {
162+
'/generic': {
163+
get: {
164+
responses: {
165+
'200': {
166+
content: {
167+
'application/json': {
168+
schema: {
169+
$ref: 'external.json#/components/schemas/PaginatedList<ClientItem>',
170+
},
171+
},
172+
},
173+
description: 'ok',
174+
},
175+
},
176+
},
177+
},
178+
'/unicode': {
179+
get: {
180+
responses: {
181+
'200': {
182+
content: {
183+
'application/json': {
184+
schema: {
185+
$ref: 'external.json#/components/schemas/Überschrift',
186+
},
187+
},
188+
},
189+
description: 'ok',
190+
},
191+
},
192+
},
193+
},
194+
},
195+
});
196+
197+
const refParser = new $RefParser();
198+
const schema = (await refParser.bundle({ pathOrUrlOrSchema: rootPath })) as any;
199+
const schemas = schema.components.schemas as Record<string, any>;
200+
201+
const findSchemaByDescription = (description: string) =>
202+
Object.entries(schemas).find(([, value]) => value.description === description);
203+
204+
const genericSchema = findSchemaByDescription('generic schema');
205+
const unicodeSchema = findSchemaByDescription('unicode schema');
206+
207+
expect(genericSchema).toBeDefined();
208+
expect(unicodeSchema).toBeDefined();
209+
210+
const [genericName, genericValue] = genericSchema!;
211+
const [unicodeName, unicodeValue] = unicodeSchema!;
212+
213+
expect(genericValue.properties.next.$ref).toBe(`#/components/schemas/${genericName}`);
214+
expect(unicodeValue.properties.next.$ref).toBe(`#/components/schemas/${unicodeName}`);
215+
216+
const bundledJson = JSON.stringify(schema);
217+
expect(bundledJson).not.toContain('PaginatedList%3CClientItem%3E');
218+
expect(bundledJson).not.toContain('%C3%9Cberschrift');
219+
} finally {
220+
fs.rmSync(tempDir, { force: true, recursive: true });
221+
}
222+
});
223+
49224
it('bundles multiple references to the same file correctly', async () => {
50225
const refParser = new $RefParser();
51226
const pathOrUrlOrSchema = path.join(

packages/json-schema-ref-parser/src/__tests__/pointer.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
11
import path from 'node:path';
22

33
import { $RefParser } from '..';
4+
import Pointer from '../pointer';
45
import { getSpecsPath } from './utils';
56

67
describe('pointer', () => {
8+
it('round-trips generic and unicode component names through join and parse', () => {
9+
const genericRef = Pointer.join('#/components/schemas', 'PaginatedListItems<ClientResponse>');
10+
const unicodeRef = Pointer.join('#/components/schemas', 'Überschrift');
11+
12+
expect(genericRef).toBe('#/components/schemas/PaginatedListItems%3CClientResponse%3E');
13+
expect(unicodeRef).toBe('#/components/schemas/%C3%9Cberschrift');
14+
15+
expect(Pointer.parse(genericRef)).toEqual([
16+
'components',
17+
'schemas',
18+
'PaginatedListItems<ClientResponse>',
19+
]);
20+
expect(Pointer.parse(unicodeRef)).toEqual(['components', 'schemas', 'Überschrift']);
21+
});
22+
23+
it('preserves JSON Pointer escaping for path-like tokens while decoding them on parse', () => {
24+
const joined = Pointer.join('#/paths', '/foo');
25+
26+
expect(joined).toBe('#/paths/~1foo');
27+
expect(Pointer.parse(joined)).toEqual(['paths', '/foo']);
28+
});
29+
730
it('inlines internal JSON Pointer refs under #/paths/ for OpenAPI bundling', async () => {
831
const refParser = new $RefParser();
932
const pathOrUrlOrSchema = path.join(

packages/json-schema-ref-parser/src/bundle.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,15 +574,15 @@ function remap(parser: $RefParser, inventory: Array<InventoryEntry>) {
574574
// preserve the original $ref rather than rewriting it to the resolved hash.
575575
if (!entry.external) {
576576
if (!entry.extended && entry.$ref && typeof entry.$ref === 'object') {
577-
entry.$ref.$ref = entry.hash;
577+
entry.$ref.$ref = decodeURI(entry.hash);
578578
}
579579
continue;
580580
}
581581

582582
// Avoid changing direct self-references; keep them internal
583583
if (entry.circular) {
584584
if (entry.$ref && typeof entry.$ref === 'object') {
585-
entry.$ref.$ref = entry.pathFromRoot;
585+
entry.$ref.$ref = decodeURI(entry.pathFromRoot);
586586
}
587587
continue;
588588
}

packages/json-schema-ref-parser/src/dereference.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ function dereference$Ref<S extends object = JSONSchema>(
228228
if (directCircular) {
229229
// The pointer is a DIRECT circular reference (i.e., it references itself).
230230
// So replace the $ref path with the absolute path from the JSON Schema root
231-
dereferencedValue.$ref = pathFromRoot;
231+
dereferencedValue.$ref = decodeURI(pathFromRoot);
232232
}
233233

234234
const dereferencedObject = {

packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/plugins/@angular/common/default-class/types.gen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,7 @@ export type AdditionalPropertiesUnknownIssueWritable = {
10431043
[key: string]: string | number;
10441044
};
10451045

1046-
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBoolean) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemString;
1046+
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBooleanWritable) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemStringWritable;
10471047

10481048
export type GenericSchemaDuplicateIssue1SystemBooleanWritable = {
10491049
item?: boolean;

packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/plugins/@angular/common/default/types.gen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,7 @@ export type AdditionalPropertiesUnknownIssueWritable = {
10431043
[key: string]: string | number;
10441044
};
10451045

1046-
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBoolean) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemString;
1046+
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBooleanWritable) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemStringWritable;
10471047

10481048
export type GenericSchemaDuplicateIssue1SystemBooleanWritable = {
10491049
item?: boolean;

packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/plugins/@hey-api/schemas/default/schemas.gen.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2356,7 +2356,7 @@ export const OneOfAllOfIssueWritableSchema = {
23562356
$ref: '#/components/schemas/ConstValue'
23572357
},
23582358
{
2359-
$ref: '#/components/schemas/Generic.Schema.Duplicate.Issue`1[System.Boolean]'
2359+
$ref: '#/components/schemas/Generic.Schema.Duplicate.Issue`1[System.Boolean]Writable'
23602360
}
23612361
]
23622362
},
@@ -2366,7 +2366,7 @@ export const OneOfAllOfIssueWritableSchema = {
23662366
]
23672367
},
23682368
{
2369-
$ref: '#/components/schemas/Generic.Schema.Duplicate.Issue`1[System.String]'
2369+
$ref: '#/components/schemas/Generic.Schema.Duplicate.Issue`1[System.String]Writable'
23702370
}
23712371
]
23722372
} as const;

packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/plugins/@hey-api/sdk/default/types.gen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,7 @@ export type AdditionalPropertiesUnknownIssueWritable = {
10431043
[key: string]: string | number;
10441044
};
10451045

1046-
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBoolean) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemString;
1046+
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBooleanWritable) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemStringWritable;
10471047

10481048
export type GenericSchemaDuplicateIssue1SystemBooleanWritable = {
10491049
item?: boolean;

packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/plugins/@hey-api/sdk/throwOnError/types.gen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,7 @@ export type AdditionalPropertiesUnknownIssueWritable = {
10431043
[key: string]: string | number;
10441044
};
10451045

1046-
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBoolean) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemString;
1046+
export type OneOfAllOfIssueWritable = ((ConstValue | GenericSchemaDuplicateIssue1SystemBooleanWritable) & _3eNum1Период) | GenericSchemaDuplicateIssue1SystemStringWritable;
10471047

10481048
export type GenericSchemaDuplicateIssue1SystemBooleanWritable = {
10491049
item?: boolean;

0 commit comments

Comments
 (0)