Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/content/docs/guides/faker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,42 @@ If an operation- or tag-level `override.mock` rule targets a property of a refer

Requires `output.schemas` to be configured (the consolidated file is written into that directory).

### Array Item Factories

Set `arrayItems: true` to emit reusable mock factories for **object-like array item schemas** found in operation responses. This covers array elements that are inlined in the response body (not just entries under `components/schemas`):

```ts title="orval.config.ts"
mock: {
generators: [
{
type: 'faker',
arrayItems: true,
},
],
}
```

For a paginated list response like `{ value: TenantResponseModelDto[], count: number }`, Orval emits both the operation factory and a reusable item factory:

```ts
export const getTenantResponseModelDtoMock = (
overrideResponse: Partial<TenantResponseModelDto> = {},
): TenantResponseModelDto => ({ /* ... */, ...overrideResponse });

export const getGetTenantsByRefResponseMock = (
overrideResponse: Partial<TenantListResponse> = {},
): TenantListResponse => ({
value: Array.from(/* ... */).map(() => ({ ...getTenantResponseModelDtoMock() })),
count: faker.number.int(),
...overrideResponse,
});
```

- **`$ref` array items** → `get<SchemaName>Mock` (shared across operations referencing the same schema).
- **Inline object array items** → `get<OperationId>Response<PropertyName>ItemMock` typed as `<ResponseName><PropertyName>Item` (matching Orval's generated item type aliases).

When `schemas: true` is also enabled, `$ref` items delegate to the consolidated schema factory instead (same as today). `arrayItems` is useful when item types only appear inside response wrappers or when you want item factories without emitting every `components/schemas` entry.

## Options

Set faker-specific options on the generator entry:
Expand All @@ -132,6 +168,7 @@ mock: {
| `preferredContentType` | `string` | — | When an operation has multiple response content types, mock the one matching this MIME type. |
| `schemas` | `boolean` | `false` | Emit a consolidated `get<SchemaName>Mock` factory per `components/schemas` entry into `<schemas-dir>/index.faker.ts`. See [Schema Factories](#schema-factories). |
| `operationResponses` | `boolean` | `true` | Emit per-operation `get<OperationId>ResponseMock` factories. Set to `false` (typically with `schemas: true`) to skip operation-level factories. |
| `arrayItems` | `boolean` | `false` | Emit reusable mock factories for object-like array item schemas in operation responses. See [Array Item Factories](#array-item-factories). |

## Customizing Mock Values

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,10 @@ export interface FakerMockOptions extends CommonMockOptions {
// Defaults to `true`. Set to `false` together with `schemas: true` to get
// only the consolidated schema factories.
operationResponses?: boolean;
// Emit reusable mock factories for object-like array item schemas found in
// operation responses (e.g. `getTenantResponseModelDtoMock` for
// `value: TenantResponseModelDto[]`). Defaults to `false`.
arrayItems?: boolean;
}

export type GlobalMockOptions = MswMockOptions | FakerMockOptions;
Expand Down
160 changes: 160 additions & 0 deletions packages/mock/src/faker/getters/array-item-factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { ContextSpec } from '@orval/core';
import { describe, expect, it } from 'vitest';

import {
extractArrayItemMock,
shouldExtractArrayItemFactories,
} from './array-item-factory';

const contextWithArrayItems = {
output: {
mock: {
generators: [{ type: 'faker', arrayItems: true }],
},
override: {
components: { schemas: { suffix: '', itemSuffix: 'Item' } },
},
},
} as unknown as ContextSpec;

const contextWithoutArrayItems = {
output: {
mock: {
generators: [{ type: 'faker' }],
},
override: {
components: { schemas: { suffix: '', itemSuffix: 'Item' } },
},
},
} as unknown as ContextSpec;

describe('shouldExtractArrayItemFactories', () => {
it('returns true when arrayItems is enabled', () => {
expect(shouldExtractArrayItemFactories(contextWithArrayItems)).toBe(true);
});

it('returns false when arrayItems is not enabled', () => {
expect(shouldExtractArrayItemFactories(contextWithoutArrayItems)).toBe(
false,
);
});
});

describe('extractArrayItemMock', () => {
it('extracts a reusable factory for $ref array items', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] = [];

const call = extractArrayItemMock({
items: { $ref: '#/components/schemas/TenantResponseModelDto' },
propertyName: 'value',
operationId: 'getTenantsByRef',
mapValue:
'{id: faker.string.uuid(), name: faker.string.alpha({length: {min: 10, max: 20}})}',
context: contextWithArrayItems,
splitMockImplementations,
imports,
});

expect(call).toBe('{...getTenantResponseModelDtoMock()}');
expect(splitMockImplementations).toHaveLength(1);
expect(splitMockImplementations[0]).toContain(
'export const getTenantResponseModelDtoMock',
);
expect(splitMockImplementations[0]).toContain(
'Partial<TenantResponseModelDto>',
);
expect(imports).toEqual([{ name: 'TenantResponseModelDto' }]);
});

it('extracts a reusable factory for inline object array items', () => {
const splitMockImplementations: string[] = [];

const call = extractArrayItemMock({
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
},
propertyName: 'value',
parentName: 'GetTenants200',
operationId: 'getTenants',
mapValue:
'{id: faker.string.uuid(), name: faker.string.alpha({length: {min: 10, max: 20}})}',
context: contextWithArrayItems,
splitMockImplementations,
imports: [],
});

expect(call).toBe('{...getGetTenantsResponseValueItemMock()}');
expect(splitMockImplementations[0]).toContain(
'export const getGetTenantsResponseValueItemMock',
);
expect(splitMockImplementations[0]).toContain(
'Partial<GetTenants200ValueItem>',
);
});

it('deduplicates factories with the same name', () => {
const splitMockImplementations: string[] = [];
const mapValue =
'{id: faker.string.uuid(), name: faker.string.alpha({length: {min: 10, max: 20}})}';

extractArrayItemMock({
items: { $ref: '#/components/schemas/TenantResponseModelDto' },
propertyName: 'value',
operationId: 'getTenantsByRef',
mapValue,
context: contextWithArrayItems,
splitMockImplementations,
imports: [],
});
extractArrayItemMock({
items: { $ref: '#/components/schemas/TenantResponseModelDto' },
propertyName: 'items',
operationId: 'getTenantsByRef',
mapValue,
context: contextWithArrayItems,
splitMockImplementations,
imports: [],
});

expect(splitMockImplementations).toHaveLength(1);
});

it('skips primitive array items', () => {
const splitMockImplementations: string[] = [];

const call = extractArrayItemMock({
items: { type: 'string' },
propertyName: 'tags',
operationId: 'getTenants',
mapValue: 'faker.string.alpha({length: {min: 10, max: 20}})',
context: contextWithArrayItems,
splitMockImplementations,
imports: [],
});

expect(call).toBeUndefined();
expect(splitMockImplementations).toHaveLength(0);
});

it('skips when the value already delegates to a factory', () => {
const splitMockImplementations: string[] = [];

const call = extractArrayItemMock({
items: { $ref: '#/components/schemas/TenantResponseModelDto' },
propertyName: 'value',
operationId: 'getTenantsByRef',
mapValue: '{...getTenantResponseModelDtoMock()}',
context: contextWithArrayItems,
splitMockImplementations,
imports: [],
});

expect(call).toBeUndefined();
expect(splitMockImplementations).toHaveLength(0);
});
});
160 changes: 160 additions & 0 deletions packages/mock/src/faker/getters/array-item-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {
type ContextSpec,
type GeneratorImport,
getRefInfo,
isFunction,
isReference,
type OpenApiSchemaObject,
OutputMockType,
pascal,
} from '@orval/core';

import type { MockSchema } from '../../types';
import { overrideVarName } from './object';
import { extractItemsRef } from './scalar';

/**
* True when the active faker generator entry opts into reusable array-item
* mock factories for object-like array item schemas in operation responses.
*/
export function shouldExtractArrayItemFactories(context: ContextSpec): boolean {
const generators = context.output.mock?.generators;

Check failure on line 21 in packages/mock/src/faker/getters/array-item-factory.ts

View workflow job for this annotation

GitHub Actions / pr-checks (ubuntu-latest, 22.x)

Unnecessary optional chain on a non-nullish value
if (!generators) {

Check failure on line 22 in packages/mock/src/faker/getters/array-item-factory.ts

View workflow job for this annotation

GitHub Actions / pr-checks (ubuntu-latest, 22.x)

Unnecessary conditional, value is always falsy
return false;
}

const fakerEntry = generators.find(
(g) =>
!isFunction(g) &&
g.type === OutputMockType.FAKER &&
g.arrayItems === true,
);
return !!fakerEntry;
}

function isObjectLikeArrayItem(items: MockSchema): boolean {
if (isReference(items)) {
return true;
}

const schema = items as OpenApiSchemaObject;
if (schema.type === 'object' || schema.properties) {
return true;
}

if (schema.allOf || schema.oneOf || schema.anyOf) {
return true;
}

return false;
}

function isAlreadyFactoryCall(mapValue: string): boolean {
return /\bget\w+Mock\(\)/.test(mapValue);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface ArrayItemFactoryNames {
factoryName: string;
typeName: string;
}

function getArrayItemFactoryNames({
items,
propertyName,
parentName,
operationId,
context,
}: {
items: MockSchema;
propertyName: string;
parentName?: string;
operationId: string;
context: ContextSpec;
}): ArrayItemFactoryNames | undefined {
const itemsRef = extractItemsRef(items);
if (itemsRef) {
const { name } = getRefInfo(itemsRef, context);
const typeName = pascal(name);
return {
factoryName: `get${typeName}Mock`,
typeName,
};
}

if (!isObjectLikeArrayItem(items)) {
return undefined;
}

const itemSuffix = context.output.override.components.schemas.itemSuffix;
const typeName = parentName
? `${pascal(parentName)}${pascal(propertyName)}${itemSuffix}`
: `${pascal(operationId)}${pascal(propertyName)}${itemSuffix}`;
return {
factoryName: `get${pascal(operationId)}Response${pascal(propertyName)}ItemMock`,
typeName,
};
}

interface ExtractArrayItemMockOptions {
items: MockSchema;
propertyName: string;
parentName?: string;
operationId: string;
mapValue: string;
context: ContextSpec;
splitMockImplementations: string[];
imports: GeneratorImport[];
}

/**
* When `arrayItems: true`, lift an object-like array item mock body into a
* reusable exported factory and return the call site expression for `.map()`.
*/
export function extractArrayItemMock({
items,
propertyName,
parentName,
operationId,
mapValue,
context,
splitMockImplementations,
imports,
}: ExtractArrayItemMockOptions): string | undefined {
if (!shouldExtractArrayItemFactories(context)) {
return undefined;
}

if (!mapValue || mapValue === '[]' || isAlreadyFactoryCall(mapValue)) {
return undefined;
}

const names = getArrayItemFactoryNames({
items,
propertyName,
parentName,
operationId,
context,
});
if (!names) {
return undefined;
}

const { factoryName, typeName } = names;

if (
!splitMockImplementations.some((f) =>
f.includes(`export const ${factoryName}`),
)
) {
const args = `${overrideVarName}: Partial<${typeName}> = {}`;
const spreadPrefix = mapValue.startsWith('...') ? '' : '...';
const func =
`export const ${factoryName} = (${args}): ${typeName} => ` +
`({${spreadPrefix}${mapValue}, ...${overrideVarName}});`;
splitMockImplementations.push(func);
}

imports.push({ name: typeName });

return `{...${factoryName}()}`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions packages/mock/src/faker/getters/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export function getMockObject({
schema: {
...(prop as Record<string, unknown>),
name: key,
parentName: schemaItem.name,
path: schemaItem.path ? `${schemaItem.path}.${key}` : `#.${key}`,
},
mockOptions,
Expand Down
Loading
Loading