Skip to content

Commit e8aab6e

Browse files
wadakatumelloware
andauthored
fix(mock): emit bare factory call for primitive-union $ref in additionalProperties (#3200) (#3504)
* fix(mock): emit bare factory call for primitive-union $ref in additionalProperties (#3200) When `schemas: true` emits per-schema faker factories, an `additionalProperties` dictionary whose value is a $ref to a primitive `oneOf`/`anyOf` (e.g. `number | string`) delegated to `get<X>Mock()` but wrapped the call in `{ ...get<X>Mock() }`. The factory returns a primitive union, which is not spreadable: the output failed to compile (TS2698) and would discard the value as `{}` at runtime. The delegation now treats a `oneOf`/`anyOf` as object-like only when every branch resolves to an object, so primitive unions emit the bare `get<X>Mock()` call while object compositions keep the spread form. Closes #3200 * fix(mock): isolate $ref cycle guard per branch in object-likeness check (#3200) Addresses review feedback on the additionalProperties dictionary fix: - The cycle-guard `Set` was shared and mutated across all `oneOf`/`anyOf` branches, so the first branch could poison its siblings: a composition like `oneOf: [{$ref: Foo}, {$ref: Foo}]` made the second branch look cyclic and return `false`, misclassifying an object-only union as non-object-like. The guard now takes a fresh copy at each `$ref` hop, so siblings sharing a `$ref` no longer trip it. - Rename `compositionResolvesToObject` -> `resolvesToObjectLike`; it also recognizes plain object schemas (`properties`/`additionalProperties`/`allOf`), not just compositions. - Return early when a `$ref` is not a string instead of using an `''` fallback key, and initialize `resolved` via an explicit if/else for clarity. - Make the regression assertions whitespace-tolerant and detect a spread regardless of brace formatting. --------- Co-authored-by: Melloware <mellowaredev@gmail.com>
1 parent 2229d99 commit e8aab6e

11 files changed

Lines changed: 342 additions & 2 deletions

File tree

packages/mock/src/faker/resolvers/value.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,11 +341,16 @@ export function resolveMockValue({
341341
// so the spread form keeps callers (combineSchemasMock, object
342342
// properties) working without other changes. For everything else
343343
// (scalars, arrays, nullables) emit the bare call.
344+
//
345+
// A `oneOf`/`anyOf` is only object-like when *every* branch resolves to
346+
// an object. A composition of primitives (e.g. `number | string`) makes
347+
// the factory return a primitive union, which is not spreadable: emitting
348+
// `{ ...get<X>Mock() }` is invalid TypeScript (TS2698) and would discard
349+
// the value as `{}` at runtime, so it must use the bare call (#3200).
344350
const isObjectLike =
345351
newSchema.type === 'object' ||
346352
!!newSchema.allOf ||
347-
!!newSchema.oneOf ||
348-
!!newSchema.anyOf;
353+
resolvesToObjectLike(newSchema, context);
349354
const callValue = isObjectLike
350355
? `{ ...${factoryName}() }`
351356
: `${factoryName}()`;
@@ -449,3 +454,62 @@ function getType(schema: MockSchema) {
449454
(schema.properties ? 'object' : schema.items ? 'array' : undefined)
450455
);
451456
}
457+
458+
// Whether a schema (or a `$ref` to one) ultimately produces an object mock.
459+
// Used to decide if a delegated `get<X>Mock()` call may be spread into an
460+
// object literal. Object-like schemas are `type: 'object'`, `properties`,
461+
// `additionalProperties` and `allOf`; a `oneOf`/`anyOf` qualifies only when
462+
// every branch resolves to an object. A union containing a primitive
463+
// (e.g. `number | string`) does not, since spreading that union is invalid
464+
// TypeScript. `seen` carries the `$ref`s on the current resolution path to
465+
// guard against self-referential compositions. A fresh copy is taken at each
466+
// `$ref` hop so sibling branches sharing a `$ref` don't falsely trip the guard.
467+
function resolvesToObjectLike(
468+
schema: MockSchema,
469+
context: ContextSpec,
470+
seen = new Set<string>(),
471+
): boolean {
472+
let resolved: Partial<OpenApiSchemaObject> | undefined;
473+
474+
if (isReference(schema)) {
475+
// A non-string or already-visited `$ref` can't be resolved further here.
476+
if (typeof schema.$ref !== 'string' || seen.has(schema.$ref)) {
477+
return false;
478+
}
479+
seen = new Set(seen).add(schema.$ref);
480+
const { refPaths } = getRefInfo(schema.$ref, context);
481+
resolved = Array.isArray(refPaths)
482+
? (prop(
483+
context.spec,
484+
// @ts-expect-error: refPaths are not guaranteed to be valid keys of the spec
485+
...refPaths,
486+
) as Partial<OpenApiSchemaObject>)
487+
: undefined;
488+
} else {
489+
resolved = schema as Partial<OpenApiSchemaObject>;
490+
}
491+
492+
if (!resolved) {
493+
return false;
494+
}
495+
496+
if (
497+
resolved.type === 'object' ||
498+
resolved.properties ||
499+
resolved.additionalProperties ||
500+
resolved.allOf
501+
) {
502+
return true;
503+
}
504+
505+
const branches = (resolved.oneOf ?? resolved.anyOf) as
506+
| MockSchema[]
507+
| undefined;
508+
if (branches && branches.length > 0) {
509+
return branches.every((branch) =>
510+
resolvesToObjectLike(branch, context, seen),
511+
);
512+
}
513+
514+
return false;
515+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3200
5+
* Reproduces the case where an object schema used as a dictionary
6+
* (`additionalProperties: { $ref: <primitive union> }`) generates an invalid
7+
* faker schema mock under `schemas: true`. The delegated `$ref` was wrapped in
8+
* `{ ...get<X>Mock() }`, but the factory returns a primitive union
9+
* (e.g. number | string), so spreading it is invalid TypeScript (TS2698) and
10+
* yields `{}` at runtime. The generated dictionary value should be the bare
11+
* `get<X>Mock()` call instead.
12+
*
13+
* OpenAPI spec version: 1.0.0
14+
*/
15+
import axios from 'axios';
16+
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
17+
18+
import type { StringToIntegerMap, StringToNumberMap } from './model';
19+
20+
import { faker } from '@faker-js/faker';
21+
22+
import { getIntegerLikeMock, getNumberLikeMock } from './model/index.faker';
23+
24+
export const getIssue3200 = (axiosInstance: AxiosInstance = axios) => {
25+
const getIntegerMap = (
26+
options?: AxiosRequestConfig,
27+
): Promise<AxiosResponse<StringToIntegerMap>> => {
28+
return axiosInstance.get(`/integer-map`, options);
29+
};
30+
31+
const getNumberMap = (
32+
options?: AxiosRequestConfig,
33+
): Promise<AxiosResponse<StringToNumberMap>> => {
34+
return axiosInstance.get(`/number-map`, options);
35+
};
36+
37+
return { getIntegerMap, getNumberMap };
38+
};
39+
export type GetIntegerMapResult = AxiosResponse<StringToIntegerMap>;
40+
export type GetNumberMapResult = AxiosResponse<StringToNumberMap>;
41+
42+
export const getGetIntegerMapResponseMock = (): StringToIntegerMap => ({
43+
[faker.string.alphanumeric(5)]: getIntegerLikeMock(),
44+
});
45+
46+
export const getGetNumberMapResponseMock = (): StringToNumberMap => ({
47+
[faker.string.alphanumeric(5)]: getNumberLikeMock(),
48+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3200
5+
* Reproduces the case where an object schema used as a dictionary
6+
* (`additionalProperties: { $ref: <primitive union> }`) generates an invalid
7+
* faker schema mock under `schemas: true`. The delegated `$ref` was wrapped in
8+
* `{ ...get<X>Mock() }`, but the factory returns a primitive union
9+
* (e.g. number | string), so spreading it is invalid TypeScript (TS2698) and
10+
* yields `{}` at runtime. The generated dictionary value should be the bare
11+
* `get<X>Mock()` call instead.
12+
*
13+
* OpenAPI spec version: 1.0.0
14+
*/
15+
import { faker } from '@faker-js/faker';
16+
17+
import type {
18+
IntegerLike,
19+
NumberLike,
20+
StringToIntegerMap,
21+
StringToNumberMap,
22+
} from '.';
23+
24+
export const getIntegerLikeMock = (): IntegerLike =>
25+
faker.helpers.arrayElement([
26+
faker.number.int(),
27+
faker.string.alpha({ length: { min: 10, max: 20 } }),
28+
]);
29+
30+
export const getStringToIntegerMapMock = (): StringToIntegerMap => ({
31+
[faker.string.alphanumeric(5)]: getIntegerLikeMock(),
32+
});
33+
34+
export const getNumberLikeMock = (): NumberLike =>
35+
faker.helpers.arrayElement([
36+
faker.number.float({ fractionDigits: 2 }),
37+
faker.string.alpha({ length: { min: 10, max: 20 } }),
38+
]);
39+
40+
export const getStringToNumberMapMock = (): StringToNumberMap => ({
41+
[faker.string.alphanumeric(5)]: getNumberLikeMock(),
42+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3200
5+
* Reproduces the case where an object schema used as a dictionary
6+
* (`additionalProperties: { $ref: <primitive union> }`) generates an invalid
7+
* faker schema mock under `schemas: true`. The delegated `$ref` was wrapped in
8+
* `{ ...get<X>Mock() }`, but the factory returns a primitive union
9+
* (e.g. number | string), so spreading it is invalid TypeScript (TS2698) and
10+
* yields `{}` at runtime. The generated dictionary value should be the bare
11+
* `get<X>Mock()` call instead.
12+
*
13+
* OpenAPI spec version: 1.0.0
14+
*/
15+
16+
export * from './integerLike';
17+
export * from './numberLike';
18+
export * from './stringToIntegerMap';
19+
export * from './stringToNumberMap';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3200
5+
* Reproduces the case where an object schema used as a dictionary
6+
* (`additionalProperties: { $ref: <primitive union> }`) generates an invalid
7+
* faker schema mock under `schemas: true`. The delegated `$ref` was wrapped in
8+
* `{ ...get<X>Mock() }`, but the factory returns a primitive union
9+
* (e.g. number | string), so spreading it is invalid TypeScript (TS2698) and
10+
* yields `{}` at runtime. The generated dictionary value should be the bare
11+
* `get<X>Mock()` call instead.
12+
*
13+
* OpenAPI spec version: 1.0.0
14+
*/
15+
16+
export type IntegerLike = number | string;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3200
5+
* Reproduces the case where an object schema used as a dictionary
6+
* (`additionalProperties: { $ref: <primitive union> }`) generates an invalid
7+
* faker schema mock under `schemas: true`. The delegated `$ref` was wrapped in
8+
* `{ ...get<X>Mock() }`, but the factory returns a primitive union
9+
* (e.g. number | string), so spreading it is invalid TypeScript (TS2698) and
10+
* yields `{}` at runtime. The generated dictionary value should be the bare
11+
* `get<X>Mock()` call instead.
12+
*
13+
* OpenAPI spec version: 1.0.0
14+
*/
15+
16+
export type NumberLike = number | string;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3200
5+
* Reproduces the case where an object schema used as a dictionary
6+
* (`additionalProperties: { $ref: <primitive union> }`) generates an invalid
7+
* faker schema mock under `schemas: true`. The delegated `$ref` was wrapped in
8+
* `{ ...get<X>Mock() }`, but the factory returns a primitive union
9+
* (e.g. number | string), so spreading it is invalid TypeScript (TS2698) and
10+
* yields `{}` at runtime. The generated dictionary value should be the bare
11+
* `get<X>Mock()` call instead.
12+
*
13+
* OpenAPI spec version: 1.0.0
14+
*/
15+
import type { IntegerLike } from './integerLike';
16+
17+
export interface StringToIntegerMap {
18+
[key: string]: IntegerLike;
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3200
5+
* Reproduces the case where an object schema used as a dictionary
6+
* (`additionalProperties: { $ref: <primitive union> }`) generates an invalid
7+
* faker schema mock under `schemas: true`. The delegated `$ref` was wrapped in
8+
* `{ ...get<X>Mock() }`, but the factory returns a primitive union
9+
* (e.g. number | string), so spreading it is invalid TypeScript (TS2698) and
10+
* yields `{}` at runtime. The generated dictionary value should be the bare
11+
* `get<X>Mock()` call instead.
12+
*
13+
* OpenAPI spec version: 1.0.0
14+
*/
15+
import type { NumberLike } from './numberLike';
16+
17+
export interface StringToNumberMap {
18+
[key: string]: NumberLike;
19+
}

tests/api-generation.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,33 @@ test('mock issue-3484 required nullable scalars get a single null branch', async
797797
);
798798
});
799799

800+
test('mock issue-3200 dictionary values delegate to a bare factory call for primitive-union $refs', async () => {
801+
// Regression for #3200: with `schemas: true`, an `additionalProperties`
802+
// dictionary whose value is a `$ref` to a primitive `oneOf`/`anyOf`
803+
// (e.g. `number | string`) delegated to `get<X>Mock()`. The delegation
804+
// wrapped the call in `{ ...get<X>Mock() }`, but the factory returns a
805+
// primitive union which is not spreadable: that is invalid TypeScript
806+
// (TS2698, enforced by scripts/typecheck-generated.mjs) and would discard
807+
// the value as `{}` at runtime. The dictionary value must be the bare call.
808+
const content = await readFile(
809+
generated('mock', 'issue-3200', 'model', 'index.faker.ts'),
810+
'utf8',
811+
);
812+
813+
// Whitespace-tolerant so the assertion survives formatter/generator tweaks
814+
// while still pinning the behavior: the dictionary value is the bare call.
815+
expect(content).toMatch(
816+
/\[faker\.string\.alphanumeric\(5\)\]:\s*getIntegerLikeMock\(\)/,
817+
);
818+
expect(content).toMatch(
819+
/\[faker\.string\.alphanumeric\(5\)\]:\s*getNumberLikeMock\(\)/,
820+
);
821+
// The primitive-union factory call must never be spread into the object,
822+
// regardless of how the braces would be formatted.
823+
expect(content).not.toContain('...getIntegerLikeMock()');
824+
expect(content).not.toContain('...getNumberLikeMock()');
825+
});
826+
800827
test('zod issue-3171 applies required from a sibling allOf member to $ref base props', async () => {
801828
// `User`/`UserFull` define their properties in a $ref base (UserBase) and
802829
// carry `required` only in a sibling allOf member. The required array must be

tests/configs/mock.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,23 @@ export default defineConfig({
464464
target: '../specifications/faker-schemas-string-enum-ref.yaml',
465465
},
466466
},
467+
issue3200: {
468+
output: {
469+
target: '../generated/mock/issue-3200/endpoints.ts',
470+
schemas: '../generated/mock/issue-3200/model',
471+
client: 'axios',
472+
mock: {
473+
generators: [
474+
{ type: 'faker', schemas: true, operationResponses: true },
475+
],
476+
},
477+
clean: true,
478+
formatter: 'prettier',
479+
},
480+
input: {
481+
target: '../specifications/issue-3200.yaml',
482+
},
483+
},
467484
issue2465: {
468485
output: {
469486
target: '../generated/mock/issue-2465/endpoints.ts',

0 commit comments

Comments
 (0)