Skip to content

Commit cc74503

Browse files
authored
fix(core): inline header schema when $ref points outside #/components/parameters (#3408)
A header parameter referenced via a JSON Pointer $ref that targets another path's parameter (e.g. `#/paths/~1requestA/post/parameters/0`) used to be typed as `N0` and imported from a non-existent `./n0` module. The synthesized name comes from sanitizing the trailing `0` segment, but `generateParameterDefinition` only emits `export type`s for slots under `#/components/parameters/*`, so the import dangled. Gate the imports surfaced by `getParameters` on `isComponentRef(p.$ref)` so non-component refs drop the dangling import and `getQueryParams` inlines the resolved parameter's schema via its existing fallback. Mirrors the parameter-side fix to the same class of bug that #398 addressed for schema refs in `resolvers/value.ts`. Fixes #1879
1 parent b059ca6 commit cc74503

12 files changed

Lines changed: 378 additions & 1 deletion

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createTestContextSpec } from '../test-utils/context';
4+
import type { OpenApiParameterObject, OpenApiReferenceObject } from '../types';
5+
import { getParameters } from './parameters';
6+
7+
describe('getParameters', () => {
8+
it('resolves a header parameter referenced via #/components/parameters/* and surfaces its named import', () => {
9+
// Refs that target a slot under `#/components/<NAMED_COMPONENT_SECTIONS>`
10+
// have a matching `export type` emitted by `generateParameterDefinition`,
11+
// so the import must flow through to downstream getters (e.g.
12+
// `getQueryParams` renders the header type as that named import).
13+
const sharedHeader: OpenApiParameterObject = {
14+
name: 'Content-Type',
15+
in: 'header',
16+
schema: { type: 'string' },
17+
};
18+
const context = createTestContextSpec({
19+
spec: {
20+
components: { parameters: { ContentTypeHeader: sharedHeader } },
21+
},
22+
});
23+
24+
const ref: OpenApiReferenceObject = {
25+
$ref: '#/components/parameters/ContentTypeHeader',
26+
};
27+
28+
const result = getParameters({ parameters: [ref], context });
29+
30+
expect(result.header).toHaveLength(1);
31+
expect(result.header[0].parameter.name).toBe('Content-Type');
32+
expect(result.header[0].imports).toEqual([
33+
{ name: 'ContentTypeHeader', schemaName: 'ContentTypeHeader' },
34+
]);
35+
});
36+
37+
it('drops imports when a header parameter $ref targets a non-component slot (issue #1879)', () => {
38+
// Repro for #1879: JSON Pointer refs into another path's `parameters` array
39+
// (`#/paths/~1requestA/post/parameters/0`) resolve to a synthesized name
40+
// like `N0` that has no corresponding `export type`. Surfacing that import
41+
// would produce a dangling reference downstream — drop it so consumers
42+
// inline the resolved parameter's `schema` instead. Mirrors the #398 fix
43+
// applied to schema refs in `resolvers/value.ts`.
44+
const requestAHeader: OpenApiParameterObject = {
45+
name: 'Content-Type',
46+
in: 'header',
47+
schema: { type: 'string' },
48+
};
49+
const context = createTestContextSpec({
50+
spec: {
51+
paths: {
52+
'/requestA': {
53+
post: { parameters: [requestAHeader], responses: {} },
54+
},
55+
},
56+
},
57+
});
58+
59+
const ref: OpenApiReferenceObject = {
60+
$ref: '#/paths/~1requestA/post/parameters/0',
61+
};
62+
63+
const result = getParameters({ parameters: [ref], context });
64+
65+
expect(result.header).toHaveLength(1);
66+
expect(result.header[0].parameter.name).toBe('Content-Type');
67+
// The resolver synthesizes `{ name: 'N0', schemaName: '0' }` from the
68+
// numeric path segment. Without the fix this leaks downstream and types
69+
// the header as `N0` while importing it from `./n0`.
70+
expect(result.header[0].imports).toEqual([]);
71+
});
72+
});

packages/core/src/getters/parameters.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
OpenApiReferenceObject,
77
} from '../types';
88
import { isReference } from '../utils';
9+
import { isComponentRef } from './ref';
910

1011
interface GetParametersOptions {
1112
parameters: (OpenApiReferenceObject | OpenApiParameterObject)[];
@@ -28,7 +29,13 @@ export function getParameters({
2829
location === 'query' ||
2930
location === 'header'
3031
) {
31-
result[location].push({ parameter, imports });
32+
// Refs that don't target a named component slot (e.g. bundler-emitted
33+
// `#/paths/.../parameters/0`) have no corresponding `export type` from
34+
// `generateParameterDefinition`, so emitting a named import would
35+
// dangle. Inline the resolved parameter's schema instead. Mirrors the
36+
// #398 fix in `resolvers/value.ts`. See issue #1879.
37+
const safeImports = p.$ref && isComponentRef(p.$ref) ? imports : [];
38+
result[location].push({ parameter, imports: safeImports });
3239
}
3340
} else {
3441
if (p.in === 'query' || p.in === 'path' || p.in === 'header') {

packages/core/src/getters/query-params.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,4 +500,60 @@ describe('getQueryParams getter', () => {
500500
expect(result?.nonPrimitiveKeys).toBeUndefined();
501501
});
502502
});
503+
504+
// Locks the contract that getParameters/issue-1879 fix relies on: when the
505+
// caller surfaces an import (i.e. the parameter resolved to a named
506+
// `#/components/parameters/*` slot), the type must be that import name, and
507+
// when it does not, the parameter's `schema` must be inlined as `string`.
508+
describe('parameter import handling', () => {
509+
it('renders the import name when a non-empty import is supplied', () => {
510+
const result = getQueryParams({
511+
queryParams: [
512+
{
513+
parameter: {
514+
name: 'Content-Type',
515+
in: 'header',
516+
schema: { type: 'string' },
517+
},
518+
imports: [
519+
{ name: 'ContentTypeHeader', schemaName: 'ContentTypeHeader' },
520+
],
521+
},
522+
],
523+
operationName: '',
524+
context,
525+
suffix: 'headers',
526+
});
527+
528+
expect(result?.schema.model.trim()).toBe(
529+
`export type Headers = {\n'Content-Type'?: ContentTypeHeader;\n};`,
530+
);
531+
});
532+
533+
it('inlines the resolved schema as `string` when no import is surfaced (issue #1879)', () => {
534+
// Mirrors what getParameters now feeds in for header refs like
535+
// `#/paths/~1requestA/post/parameters/0`: the resolved parameter object
536+
// with empty imports. Without the upstream fix the resolver would have
537+
// emitted `{ name: 'N0', ... }` here, producing a dangling `N0` type.
538+
const result = getQueryParams({
539+
queryParams: [
540+
{
541+
parameter: {
542+
name: 'Content-Type',
543+
in: 'header',
544+
schema: { type: 'string' },
545+
},
546+
imports: [],
547+
},
548+
],
549+
operationName: '',
550+
context,
551+
suffix: 'headers',
552+
});
553+
554+
expect(result?.schema.model.trim()).toBe(
555+
`export type Headers = {\n'Content-Type'?: string;\n};`,
556+
);
557+
});
558+
});
503559
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Generated by orval v8.11.0 🍺
3+
* Do not edit manually.
4+
* Issue 1879 - Header $ref to another path's parameter
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
import type {
8+
RequestABody,
9+
RequestAHeaders,
10+
RequestBBody,
11+
RequestBHeaders,
12+
} from './model';
13+
14+
export type requestAResponse200 = {
15+
data: void;
16+
status: 200;
17+
};
18+
19+
export type requestAResponseSuccess = requestAResponse200 & {
20+
headers: Headers;
21+
};
22+
export type requestAResponse = requestAResponseSuccess;
23+
24+
export const getRequestAUrl = () => {
25+
return `/requestA`;
26+
};
27+
28+
/**
29+
* @summary Request A
30+
*/
31+
export const requestA = async (
32+
requestABody?: RequestABody,
33+
headers?: RequestAHeaders,
34+
options?: RequestInit,
35+
): Promise<requestAResponse> => {
36+
const res = await fetch(getRequestAUrl(), {
37+
...options,
38+
method: 'POST',
39+
headers: {
40+
'Content-Type': 'application/json',
41+
...headers,
42+
...options?.headers,
43+
},
44+
body: JSON.stringify(requestABody),
45+
});
46+
47+
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
48+
49+
const data: requestAResponse['data'] = body ? JSON.parse(body) : undefined;
50+
return { data, status: res.status, headers: res.headers } as requestAResponse;
51+
};
52+
53+
export type requestBResponse200 = {
54+
data: void;
55+
status: 200;
56+
};
57+
58+
export type requestBResponseSuccess = requestBResponse200 & {
59+
headers: Headers;
60+
};
61+
export type requestBResponse = requestBResponseSuccess;
62+
63+
export const getRequestBUrl = () => {
64+
return `/requestB`;
65+
};
66+
67+
/**
68+
* @summary Request B
69+
*/
70+
export const requestB = async (
71+
requestBBody?: RequestBBody,
72+
headers?: RequestBHeaders,
73+
options?: RequestInit,
74+
): Promise<requestBResponse> => {
75+
const res = await fetch(getRequestBUrl(), {
76+
...options,
77+
method: 'POST',
78+
headers: {
79+
'Content-Type': 'application/json',
80+
...headers,
81+
...options?.headers,
82+
},
83+
body: JSON.stringify(requestBBody),
84+
});
85+
86+
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
87+
88+
const data: requestBResponse['data'] = body ? JSON.parse(body) : undefined;
89+
return { data, status: res.status, headers: res.headers } as requestBResponse;
90+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Generated by orval v8.11.0 🍺
3+
* Do not edit manually.
4+
* Issue 1879 - Header $ref to another path's parameter
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export * from './requestABody';
9+
export * from './requestAHeaders';
10+
export * from './requestBBody';
11+
export * from './requestBHeaders';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Generated by orval v8.11.0 🍺
3+
* Do not edit manually.
4+
* Issue 1879 - Header $ref to another path's parameter
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export type RequestABody = {
9+
message?: string;
10+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Generated by orval v8.11.0 🍺
3+
* Do not edit manually.
4+
* Issue 1879 - Header $ref to another path's parameter
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export type RequestAHeaders = {
9+
/**
10+
* The expected content type.
11+
* @pattern ^application\/json$
12+
*/
13+
'Content-Type'?: string;
14+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Generated by orval v8.11.0 🍺
3+
* Do not edit manually.
4+
* Issue 1879 - Header $ref to another path's parameter
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export type RequestBBody = {
9+
status?: string;
10+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Generated by orval v8.11.0 🍺
3+
* Do not edit manually.
4+
* Issue 1879 - Header $ref to another path's parameter
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export type RequestBHeaders = {
9+
/**
10+
* The expected content type.
11+
* @pattern ^application\/json$
12+
*/
13+
'Content-Type'?: string;
14+
};

tests/api-generation.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,33 @@ test('react-query issue-1522 passes the enabled option into the queryOptions mut
329329
const occurrences = content.split(expectedMutatorCall).length - 1;
330330
expect(occurrences).toBe(2);
331331
});
332+
333+
test('fetch issue-1879 inlines header schema when $ref targets another path parameter', async () => {
334+
// Regression for #1879: a header parameter referenced via a JSON Pointer
335+
// `$ref` to another path's parameter
336+
// (`#/paths/~1requestA/post/parameters/0`) used to be typed as `N0` (the
337+
// sanitized last segment of the ref) and imported from a non-existent
338+
// `./n0` module, because the resolver's synthesized name leaked downstream
339+
// even though `generateParameterDefinition` only emits types for slots
340+
// under `#/components/parameters/*`. The fix gates that import on
341+
// `isComponentRef` so non-component refs inline the resolved parameter's
342+
// schema (`string`) instead. Keep this focused assertion alongside the
343+
// snapshot so #1879 fails with a targeted message rather than a full-file
344+
// snapshot diff.
345+
const headersContent = await readFile(
346+
generated('fetch', 'issue-1879', 'model', 'requestBHeaders.ts'),
347+
'utf8',
348+
);
349+
350+
// The header type must be inlined as `string` with no dangling reference.
351+
expect(headersContent).toContain("'Content-Type'?: string;");
352+
expect(headersContent).not.toMatch(/\bN0\b/);
353+
expect(headersContent).not.toContain('./n0');
354+
355+
// And no synthesized `n0` module should be emitted alongside it.
356+
const indexContent = await readFile(
357+
generated('fetch', 'issue-1879', 'model', 'index.ts'),
358+
'utf8',
359+
);
360+
expect(indexContent).not.toMatch(/\bn0\b/);
361+
});

0 commit comments

Comments
 (0)