Skip to content

Commit 996a4dd

Browse files
authored
fix(mock): generate tuple values for OpenAPI 3.1 prefixItems (#3697)
1 parent 1134ba5 commit 996a4dd

8 files changed

Lines changed: 293 additions & 0 deletions

File tree

packages/mock/src/faker/getters/scalar.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,44 @@ export function getMockScalar({
313313
}
314314

315315
case 'array': {
316+
// OpenAPI 3.1 tuples carry their positional element schemas in
317+
// `prefixItems` (not `items`). Mock each fixed position so the emitted
318+
// literal is assignable to the generated `[T0, T1, ...]` tuple type.
319+
// Emitting only the fixed positions stays type-safe even when `items`
320+
// also defines a rest element, because the generated type is
321+
// `[...prefix, ...Additional[]]` and the rest permits zero elements.
322+
const prefixItems = item.prefixItems as MockSchema[] | undefined;
323+
if (prefixItems && prefixItems.length > 0) {
324+
const tupleImports: GeneratorImport[] = [];
325+
const tupleValues = prefixItems.map((prefixItem, index) => {
326+
const { value: elementValue, imports: elementImports } =
327+
resolveMockValue({
328+
schema: {
329+
...prefixItem,
330+
name: item.name,
331+
parentName: item.parentName,
332+
path: item.path ? `${item.path}.[${index}]` : `#.[${index}]`,
333+
},
334+
combine,
335+
mockOptions,
336+
operationId,
337+
tags,
338+
context,
339+
imports,
340+
existingReferencedProperties,
341+
existingReferencedAllOfRefs,
342+
splitMockImplementations,
343+
});
344+
tupleImports.push(...elementImports);
345+
return elementValue;
346+
});
347+
return {
348+
value: `[${tupleValues.join(', ')}]`,
349+
imports: tupleImports,
350+
name: item.name,
351+
};
352+
}
353+
316354
if (!item.items) {
317355
return { value: '[]', imports: [], name: item.name };
318356
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Generated by orval v8.20.0 🍺
3+
* Do not edit manually.
4+
* Issue 3691 - tuple prefixItems mock
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
import axios from 'axios';
8+
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
9+
10+
import type { Example } from './model';
11+
12+
import { faker } from '@faker-js/faker';
13+
14+
import { HttpResponse, http } from 'msw';
15+
import type { RequestHandlerOptions } from 'msw';
16+
17+
export const getIssue3691TuplePrefixItemsMock = (
18+
axiosInstance: AxiosInstance = axios,
19+
) => {
20+
const getExample = (
21+
options?: AxiosRequestConfig,
22+
): Promise<AxiosResponse<Example>> => {
23+
return axiosInstance.get(`/example`, options);
24+
};
25+
26+
return { getExample };
27+
};
28+
export type GetExampleResult = AxiosResponse<Example>;
29+
30+
export const getGetExampleResponseMock = (
31+
overrideResponse: Partial<Extract<Example, object>> = {},
32+
): Example => ({
33+
point: faker.helpers.arrayElement([
34+
faker.helpers.arrayElement([
35+
[
36+
faker.number.float({ fractionDigits: 2 }),
37+
faker.number.float({ fractionDigits: 2 }),
38+
],
39+
null,
40+
]),
41+
undefined,
42+
]),
43+
plainPoint: [
44+
faker.number.float({ fractionDigits: 2 }),
45+
faker.string.alpha({ length: { min: 10, max: 20 } }),
46+
],
47+
objTuple: [
48+
{
49+
id: faker.helpers.arrayElement([
50+
faker.string.alpha({ length: { min: 10, max: 20 } }),
51+
undefined,
52+
]),
53+
},
54+
faker.string.alpha({ length: { min: 10, max: 20 } }),
55+
],
56+
restTuple: [
57+
faker.string.alpha({ length: { min: 10, max: 20 } }),
58+
faker.number.float({ fractionDigits: 2 }),
59+
],
60+
emptyElemTuple: [faker.string.alpha({ length: { min: 10, max: 20 } }), {}],
61+
...overrideResponse,
62+
});
63+
64+
export const getGetExampleMockHandler = (
65+
overrideResponse?:
66+
| Example
67+
| ((
68+
info: Parameters<Parameters<typeof http.get>[1]>[0],
69+
) => Promise<Example> | Example),
70+
options?: RequestHandlerOptions,
71+
) => {
72+
return http.get(
73+
'*/example',
74+
async (info: Parameters<Parameters<typeof http.get>[1]>[0]) => {
75+
return HttpResponse.json(
76+
overrideResponse !== undefined
77+
? typeof overrideResponse === 'function'
78+
? await overrideResponse(info)
79+
: overrideResponse
80+
: getGetExampleResponseMock(),
81+
{ status: 200 },
82+
);
83+
},
84+
options,
85+
);
86+
};
87+
export const getIssue3691TuplePrefixItemsMockMock = () => [
88+
getGetExampleMockHandler(),
89+
];
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Generated by orval v8.20.0 🍺
3+
* Do not edit manually.
4+
* Issue 3691 - tuple prefixItems mock
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
import type { ExampleObjTupleItem0 } from './exampleObjTupleItem0';
8+
9+
export interface Example {
10+
point?: [number, number] | null;
11+
/**
12+
* @minItems 2
13+
* @maxItems 2
14+
*/
15+
plainPoint: [number, string];
16+
/**
17+
* @minItems 2
18+
* @maxItems 2
19+
*/
20+
objTuple: [ExampleObjTupleItem0, string];
21+
/** @minItems 2 */
22+
restTuple: [string, number, ...string[]];
23+
/**
24+
* @minItems 2
25+
* @maxItems 2
26+
*/
27+
emptyElemTuple: [string, unknown];
28+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Generated by orval v8.20.0 🍺
3+
* Do not edit manually.
4+
* Issue 3691 - tuple prefixItems mock
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export type ExampleObjTupleItem0 = {
9+
id?: string;
10+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Generated by orval v8.20.0 🍺
3+
* Do not edit manually.
4+
* Issue 3691 - tuple prefixItems mock
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export * from './example';
9+
export * from './exampleObjTupleItem0';

tests/api-generation.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,3 +1733,32 @@ test('mock issue-3656 oneOf branch MSW mock imports the enum as a value', async
17331733
/import \{[^}]*\bReasonEnum\b[^}]*\} from '\.\/model'/,
17341734
);
17351735
});
1736+
1737+
test('mock issue-3691 tuple prefixItems mock values match the generated tuple type', async () => {
1738+
const content = await readFile(
1739+
generated('mock', 'issue-3691', 'endpoints.ts'),
1740+
'utf8',
1741+
);
1742+
1743+
// Each `prefixItems` position must be mocked so the literal is assignable to
1744+
// the generated tuple type — the old behaviour emitted an empty `[]`.
1745+
expect(content).not.toContain('plainPoint: []');
1746+
expect(content).not.toContain('objTuple: []');
1747+
expect(content).toMatch(
1748+
/plainPoint: \[\s*faker\.number\.float\([^)]*\),\s*faker\.string\.alpha/,
1749+
);
1750+
// Object element keeps its object shape (combine wrapping must not misfire).
1751+
expect(content).toMatch(/objTuple: \[\s*\{\s*id:/);
1752+
// `prefixItems` + `items` (tuple-with-rest) emits the fixed positions as a
1753+
// tuple literal rather than falling through to the `Array.from(...)` path.
1754+
expect(content).toMatch(
1755+
/restTuple: \[\s*faker\.string\.alpha\([^)]*\),\s*faker\.number\.float/,
1756+
);
1757+
// An empty-schema (`{}`) element must emit a real value (`{}`), never an
1758+
// empty slot that would produce invalid `[, ...]` output.
1759+
expect(content).not.toMatch(/emptyElemTuple: \[\s*,/);
1760+
expect(content).toMatch(/emptyElemTuple: \[\s*faker\.string\.alpha/);
1761+
// The nullable tuple (`anyOf: [tuple, null]`) is the issue's exact shape and
1762+
// must not regress to the original empty `[]`.
1763+
expect(content).not.toContain('point: []');
1764+
});

tests/configs/mock.config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,4 +1085,22 @@ export default defineConfig({
10851085
target: '../specifications/issue-3656.yaml',
10861086
},
10871087
},
1088+
// Regression for https://github.com/orval-labs/orval/issues/3691:
1089+
// OpenAPI 3.1 tuples (`prefixItems`) must mock each positional element so
1090+
// the generated mock is assignable to the generated `[T0, T1, ...]` type.
1091+
'issue-3691': {
1092+
output: {
1093+
target: '../generated/mock/issue-3691/endpoints.ts',
1094+
schemas: '../generated/mock/issue-3691/model',
1095+
client: 'axios',
1096+
mock: {
1097+
generators: [{ type: 'msw' }],
1098+
},
1099+
clean: true,
1100+
formatter: 'prettier',
1101+
},
1102+
input: {
1103+
target: '../specifications/issue-3691.yaml',
1104+
},
1105+
},
10881106
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Issue 3691 - tuple prefixItems mock
4+
version: 1.0.0
5+
paths:
6+
/example:
7+
get:
8+
operationId: getExample
9+
responses:
10+
'200':
11+
description: OK
12+
content:
13+
application/json:
14+
schema:
15+
$ref: '#/components/schemas/Example'
16+
components:
17+
schemas:
18+
Example:
19+
type: object
20+
required:
21+
- plainPoint
22+
- objTuple
23+
- restTuple
24+
- emptyElemTuple
25+
properties:
26+
# Issue's exact shape: optional tuple wrapped in `anyOf: [tuple, null]`.
27+
point:
28+
anyOf:
29+
- type: array
30+
prefixItems:
31+
- type: number
32+
- type: number
33+
minItems: 2
34+
maxItems: 2
35+
- type: 'null'
36+
# Plain (non-nullable) tuple isolates the `case 'array'` mock path.
37+
plainPoint:
38+
type: array
39+
prefixItems:
40+
- type: number
41+
- type: string
42+
minItems: 2
43+
maxItems: 2
44+
# Object element pins the `combine` propagation decision.
45+
objTuple:
46+
type: array
47+
prefixItems:
48+
- type: object
49+
properties:
50+
id:
51+
type: string
52+
- type: string
53+
minItems: 2
54+
maxItems: 2
55+
# prefixItems + items: tuple-with-rest stays type-safe with fixed positions only.
56+
restTuple:
57+
type: array
58+
prefixItems:
59+
- type: string
60+
- type: number
61+
items:
62+
type: string
63+
minItems: 2
64+
# Empty-schema (`{}`, i.e. `unknown`) element must emit a real value, not
65+
# an empty slot that would break the `[a, b]` literal.
66+
emptyElemTuple:
67+
type: array
68+
prefixItems:
69+
- type: string
70+
- {}
71+
minItems: 2
72+
maxItems: 2

0 commit comments

Comments
 (0)