Skip to content

Commit 4408094

Browse files
committed
fix(orval): decode escaped JSON Pointer tokens in external $refs (#3380)
1 parent 93fc812 commit 4408094

7 files changed

Lines changed: 194 additions & 1 deletion

File tree

packages/orval/src/import-specs.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,75 @@ describe('dereferenceExternalRefs', () => {
756756
expect(result).not.toHaveProperty('x-ext');
757757
});
758758

759+
it('should resolve external path-item refs with escaped JSON Pointer tokens (#3380)', () => {
760+
// A cross-file path-item `$ref` (e.g. `common.yaml#/paths/~1pets`) is
761+
// bundled into an x-ext ref whose pointer keeps the JSON Pointer escape
762+
// `~1` (for `/`) and percent-encoding (`%7B`/`%7D` for `{`/`}` in
763+
// templated paths). Both must be decoded before walking the external doc.
764+
const input = {
765+
openapi: '3.0.0',
766+
info: { version: '1.0.0', title: 'API' },
767+
paths: {
768+
'/pets': {
769+
$ref: '#/x-ext/abc1234/paths/~1pets',
770+
},
771+
'/pets/{petId}': {
772+
$ref: '#/x-ext/abc1234/paths/~1pets~1%7BpetId%7D',
773+
},
774+
},
775+
'x-ext': {
776+
abc1234: {
777+
paths: {
778+
'/pets': {
779+
get: {
780+
operationId: 'listPets',
781+
responses: { '200': { description: 'ok' } },
782+
},
783+
},
784+
'/pets/{petId}': {
785+
get: {
786+
operationId: 'getPet',
787+
parameters: [
788+
{
789+
name: 'petId',
790+
in: 'path',
791+
required: true,
792+
schema: { type: 'string' },
793+
},
794+
],
795+
responses: { '200': { description: 'ok' } },
796+
},
797+
},
798+
},
799+
},
800+
},
801+
};
802+
803+
const result = dereferenceExternalRef(input) as OpenApiDocument;
804+
805+
expect(result.paths?.['/pets']).toEqual({
806+
get: {
807+
operationId: 'listPets',
808+
responses: { '200': { description: 'ok' } },
809+
},
810+
});
811+
expect(result.paths?.['/pets/{petId}']).toEqual({
812+
get: {
813+
operationId: 'getPet',
814+
parameters: [
815+
{
816+
name: 'petId',
817+
in: 'path',
818+
required: true,
819+
schema: { type: 'string' },
820+
},
821+
],
822+
responses: { '200': { description: 'ok' } },
823+
},
824+
});
825+
expect(result).not.toHaveProperty('x-ext');
826+
});
827+
759828
it('should dereference external doc schemas with internal refs', () => {
760829
const input = {
761830
openapi: '3.0.3',

packages/orval/src/import-specs.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,26 @@ function updateInternalRefs(
344344
return obj;
345345
}
346346

347+
/**
348+
* Decode a single JSON Pointer reference token taken from an x-ext `$ref`.
349+
*
350+
* The token carries two layers of encoding: it sits in a URI fragment, so it
351+
* may be percent-encoded (e.g. `%7B` for `{` in templated paths), and it is a
352+
* JSON Pointer token, so `~1`/`~0` stand for `/`/`~` (RFC 6901). Percent-
353+
* encoding is the outer layer and is removed first; a malformed sequence is
354+
* left as-is rather than throwing. Without this, tokens such as `~1pets`
355+
* never match the real `/pets` key and the external `$ref` fails to resolve.
356+
*/
357+
function decodeRefToken(token: string): string {
358+
let decoded = token;
359+
try {
360+
decoded = decodeURIComponent(token);
361+
} catch {
362+
// Malformed percent-encoding — fall back to the raw token.
363+
}
364+
return decoded.replaceAll('~1', '/').replaceAll('~0', '~');
365+
}
366+
347367
/**
348368
* Replace x-ext refs with standard component refs, or inline the content.
349369
* `inliningRefs` tracks the inline chain to break cycles in recursive
@@ -402,7 +422,8 @@ function replaceXExtRefs(
402422

403423
const extDoc = extensions[extKey];
404424
let refObj: unknown = extDoc;
405-
for (const p of parts) {
425+
for (const rawPart of parts) {
426+
const p = decodeRefToken(rawPart);
406427
if (
407428
refObj &&
408429
(isObject(refObj) || Array.isArray(refObj)) &&
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Generated by orval v8.11.0 🍺
3+
* Do not edit manually.
4+
* Issue 3380 - external path-item $ref
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
import axios from 'axios';
8+
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
9+
10+
export const listPets = (
11+
options?: AxiosRequestConfig,
12+
): Promise<AxiosResponse<string[]>> => {
13+
return axios.get(`/pets`, options);
14+
};
15+
16+
export const getPet = (
17+
petId: string,
18+
options?: AxiosRequestConfig,
19+
): Promise<AxiosResponse<string>> => {
20+
return axios.get(`/pets/${petId}`, {
21+
...options,
22+
});
23+
};
24+
25+
export type ListPetsResult = AxiosResponse<string[]>;
26+
export type GetPetResult = AxiosResponse<string>;

tests/api-generation.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,22 @@ test('default issue-1107 emits exports for schemas defined via cross-file $ref',
233233
expect(pets).toContain("import type { Pet } from './pet';");
234234
expect(pets).toContain('export type Pets = Pet[];');
235235
});
236+
237+
test('default issue-3380 resolves external path-item $refs with escaped pointers', async () => {
238+
// Regression for #3380: path items defined via a cross-file `$ref`
239+
// (`#/paths/~1pets` and `#/paths/~1pets~1%7BpetId%7D`) used to abort
240+
// generation with "Can't resolve reference" because the JSON Pointer escape
241+
// `~1` and percent-encoding `%7B`/`%7D` were not decoded before resolving
242+
// the external document. Both operations must now be generated.
243+
// Keep this focused assertion alongside the snapshot so #3380 fails with a
244+
// targeted message instead of a full-file snapshot diff.
245+
const content = await readFile(
246+
generated('default', 'issue-3380-external-path-ref', 'endpoints.ts'),
247+
'utf8',
248+
);
249+
250+
expect(content).toContain('export const listPets = (');
251+
expect(content).toContain('export const getPet = (');
252+
// The templated path ref (`~1pets~1%7BpetId%7D`) decodes to `/pets/{petId}`.
253+
expect(content).toContain('`/pets/${petId}`');
254+
});

tests/configs/default.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,16 @@ export default defineConfig({
692692
target: '../specifications/issue-1107/issue-1107.yaml',
693693
},
694694
},
695+
'issue-3380-external-path-ref': {
696+
output: {
697+
target: '../generated/default/issue-3380-external-path-ref/endpoints.ts',
698+
clean: true,
699+
formatter: 'prettier',
700+
},
701+
input: {
702+
target: '../specifications/issue-3380/issue-3380.yaml',
703+
},
704+
},
695705
'boolean-discriminator': {
696706
output: {
697707
target: '../generated/default/boolean-discriminator/endpoints.ts',
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Issue 3380 - external paths
4+
version: 1.0.0
5+
paths:
6+
/pets:
7+
get:
8+
operationId: listPets
9+
responses:
10+
'200':
11+
description: A list of pet names
12+
content:
13+
application/json:
14+
schema:
15+
type: array
16+
items:
17+
type: string
18+
/pets/{petId}:
19+
get:
20+
operationId: getPet
21+
parameters:
22+
- name: petId
23+
in: path
24+
required: true
25+
schema:
26+
type: string
27+
responses:
28+
'200':
29+
description: A pet name
30+
content:
31+
application/json:
32+
schema:
33+
type: string
34+
components: {}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Issue 3380 - external path-item $ref
4+
version: 1.0.0
5+
paths:
6+
# Regression for #3380: path items defined via a cross-file `$ref`. The
7+
# JSON Pointer escape `~1` (for `/`) and percent-encoding (`%7B`/`%7D` for
8+
# `{`/`}` in templated paths) in these refs must be decoded for the external
9+
# paths to resolve.
10+
/pets:
11+
$ref: './issue-3380-common.yaml#/paths/~1pets'
12+
/pets/{petId}:
13+
$ref: './issue-3380-common.yaml#/paths/~1pets~1%7BpetId%7D'
14+
components: {}

0 commit comments

Comments
 (0)