Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions docs/content/docs/guides/faker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export const getGetTenantsByRefResponseMock = (

Orval only extracts factories for shapes it can name and mock reliably. The following fall back to inline `.map()` bodies (same as `arrayItems: false`): `$ref` to scalar schemas, `oneOf` / `anyOf` item compositions, nullable object items, and nested arrays whose parent context is not the generated response wrapper (e.g. two `items` properties under `outer` and `inner` in the same operation). Plain object items, `$ref`-to-object items, and inline `allOf` items are supported.

Top-level array responses (the array itself, not a wrapper object) reuse the same generated element alias as the schema output — a `$ref`'d array schema `CatalogItems` produces item factories typed `CatalogItemsItem`, and an inline top-level array reuses its generated `<OperationName><Status>Item` alias. Shapes where that alias cannot be derived with certainty (e.g. `$ref` array items composed via a multi-schema `allOf` with no direct properties) are inlined instead.

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. With both options enabled, `$ref` items are not re-exported from the operation mock file — import `get<SchemaName>Mock` from `<schemas-dir>/index.faker.ts` instead.

## Options
Expand Down
247 changes: 247 additions & 0 deletions packages/mock/src/faker/getters/array-item-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,253 @@ describe('extractArrayItemMock', () => {
expect(splitMockImplementations).toHaveLength(1);
});

describe('top-level array responses (no parentName)', () => {
it('extracts a factory for a $ref array response, aliasing to <RefName><itemSuffix>', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] = [];

const call = extractArrayItemMock({
items: {
type: 'object',
properties: {
sku: { type: 'string' },
price: { type: 'number' },
},
},
propertyName: 'CatalogItems',
operationId: 'getCatalogItems',
tags: [],
mapValue: '{sku: faker.string.alpha(), price: faker.number.float()}',
context: createContextWithArrayItems(),
splitMockImplementations,
imports,
});

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

it('reproduces the reported shape: $ref array response named Items on operation getCatalogItemsShort', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] = [];

const call = extractArrayItemMock({
items: {
type: 'object',
properties: { id: { type: 'string' } },
},
propertyName: 'Items',
operationId: 'getCatalogItemsShort',
tags: [],
mapValue: '{id: faker.string.uuid()}',
context: createContextWithArrayItems(),
splitMockImplementations,
imports,
});

expect(call).toBe('{...getGetCatalogItemsShortResponseItemsItemMock()}');
expect(imports).toEqual([{ name: 'ItemsItem' }]);
expect(splitMockImplementations[0]).toContain('Partial<ItemsItem>');
});

it('extracts a factory for an inline top-level array response, reusing the emitted element alias', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] = [];

const call = extractArrayItemMock({
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string' },
},
},
propertyName: 'GetCatalogItemsInline200Item[]',
operationId: 'getCatalogItemsInline',
tags: [],
mapValue: '{id: faker.string.uuid(), label: faker.string.alpha()}',
context: createContextWithArrayItems(),
splitMockImplementations,
imports,
});

expect(call).toBe(
'{...getGetCatalogItemsInlineResponseGetCatalogItemsInline200ItemItemMock()}',
);
expect(imports).toEqual([{ name: 'GetCatalogItemsInline200Item' }]);
expect(splitMockImplementations[0]).toContain(
'Partial<GetCatalogItemsInline200Item>',
);
});

it('bails out (inlines) when the array-expression base is not a plain identifier', () => {
const splitMockImplementationsUnion: string[] = [];
const importsUnion: Parameters<
typeof extractArrayItemMock
>[0]['imports'] = [];

const callUnion = extractArrayItemMock({
items: {
oneOf: [{ type: 'object', properties: { a: { type: 'string' } } }],
},
propertyName: '(Cat | Dog)[]',
operationId: 'getThings',
tags: [],
mapValue: '{a: faker.string.alpha()}',
context: createContextWithArrayItems(),
splitMockImplementations: splitMockImplementationsUnion,
imports: importsUnion,
});

expect(callUnion).toBeUndefined();
expect(splitMockImplementationsUnion).toHaveLength(0);
expect(importsUnion).toHaveLength(0);

const splitMockImplementationsReadonly: string[] = [];
const importsReadonly: Parameters<
typeof extractArrayItemMock
>[0]['imports'] = [];

const callReadonly = extractArrayItemMock({
items: {
type: 'object',
properties: { a: { type: 'string' } },
},
propertyName: 'readonly Foo[]',
operationId: 'getFoo',
tags: [],
mapValue: '{a: faker.string.alpha()}',
context: createContextWithArrayItems(),
splitMockImplementations: splitMockImplementationsReadonly,
imports: importsReadonly,
});

expect(callReadonly).toBeUndefined();
expect(splitMockImplementationsReadonly).toHaveLength(0);
expect(importsReadonly).toHaveLength(0);
});

it('bails out (inlines) for a bare ref-name response whose items are a multi-ref allOf without direct properties', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] = [];

const call = extractArrayItemMock({
items: {
allOf: [
{ $ref: '#/components/schemas/A' },
{ $ref: '#/components/schemas/B' },
],
},
propertyName: 'ComposedItems',
operationId: 'getComposed',
tags: [],
mapValue: '{...getAMock(), ...getBMock()}',
context: createContextWithArrayItems(),
splitMockImplementations,
imports,
});

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

describe('nullable top-level array responses', () => {
it('strips the " | null" suffix for an inline nullable array response, aliasing to <PropertyName><itemSuffix>', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] =
[];

const call = extractArrayItemMock({
items: {
type: 'object',
properties: {
sku: { type: 'string' },
price: { type: 'number' },
},
},
propertyName: 'CatalogItems | null',
operationId: 'getNullableCatalogItems',
tags: [],
mapValue: '{sku: faker.string.alpha(), price: faker.number.float()}',
context: createContextWithArrayItems(),
splitMockImplementations,
imports,
});

expect(call).toBe(
'{...getGetNullableCatalogItemsResponseCatalogItemsNullItemMock()}',
);
expect(imports).toEqual([{ name: 'CatalogItemsItem' }]);
expect(splitMockImplementations[0]).toContain(
'Partial<CatalogItemsItem>',
);
expect(splitMockImplementations[0]).toContain('): CatalogItemsItem');
});

it('strips the " | null" suffix for a nullable $ref array response, reusing the emitted element alias', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] =
[];

const call = extractArrayItemMock({
items: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
propertyName: 'GetFoo200Item[] | null',
operationId: 'getFoo',
tags: [],
mapValue: '{id: faker.string.uuid()}',
context: createContextWithArrayItems(),
splitMockImplementations,
imports,
});

expect(call).toBe('{...getGetFooResponseGetFoo200ItemNullItemMock()}');
expect(imports).toEqual([{ name: 'GetFoo200Item' }]);
expect(splitMockImplementations[0]).toContain('Partial<GetFoo200Item>');
expect(splitMockImplementations[0]).toContain('): GetFoo200Item');
});

it('bails out (inlines) when the stripped bare-ref name is not a plain identifier', () => {
const splitMockImplementations: string[] = [];
const imports: Parameters<typeof extractArrayItemMock>[0]['imports'] =
[];

const call = extractArrayItemMock({
items: {
type: 'object',
properties: { a: { type: 'string' } },
},
propertyName: '(Cat | Dog) | null',
operationId: 'getThings',
tags: [],
mapValue: '{a: faker.string.alpha()}',
context: createContextWithArrayItems(),
splitMockImplementations,
imports,
});

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

it('skips $ref components/schemas items when schemas: true emits consolidated factories', () => {
const splitMockImplementations: string[] = [];
const contextWithSchemas = {
Expand Down
57 changes: 54 additions & 3 deletions packages/mock/src/faker/getters/array-item-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,60 @@ function getArrayItemFactoryNames({
}

const itemSuffix = context.output.override.components.schemas.itemSuffix;
const typeName = parentName
? `${pascal(parentName)}${pascal(propertyName)}${itemSuffix}`
: `${pascal(operationId)}${pascal(propertyName)}${itemSuffix}`;

let typeName: string;
if (parentName) {
typeName = `${pascal(parentName)}${pascal(propertyName)}${itemSuffix}`;
} else {
// No `parentName`: the array IS the top-level response schema, and
// `propertyName` here is the response definition string produced by
// `getResReqTypes` (core/getters/res-req-types.ts) rather than a nested
// property key. Two shapes reach this point:
// - inline top-level array responses, where `propertyName` is the
// response type expression with a trailing `[]`; the part before
// `[]` is the element alias core already emitted via
// `createTypeAliasIfNeeded` (core/resolvers/object.ts), when that
// part is a bare identifier;
// - `$ref`'d array schemas (`items` here is the array's resolved,
// non-`$ref` items schema), where `propertyName` is the bare ref
// name and core aliases the array's items as
// `${pascal(refName)}${itemSuffix}` (core/getters/array.ts).
// Nullable top-level arrays reach this branch too: core's scalar getter
// appends a trailing ` | null` to either shape above (e.g.
// `CatalogItems | null` or `GetFoo200Item[] | null`), so that suffix is
// stripped before testing/deriving the type name below. `factoryName`
// still keys off the original, unstripped `propertyName` — outputs on
// the nullable path never compiled before this fix, so factory naming
// there is not a compatibility surface.
// If neither shape holds with certainty, bail (`undefined`) so the call
// site keeps the pre-#3514 inline item body, which is always
// type-correct, instead of referencing a name core never emitted (#3706).
const nullableSuffix = ' | null';
const workingName = propertyName.endsWith(nullableSuffix)
? propertyName.slice(0, -nullableSuffix.length)
: propertyName;

if (workingName.endsWith('[]')) {
const base = workingName.slice(0, -2);
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(base)) {
return undefined;
}
typeName = base;
} else {
const schema = items as OpenApiSchemaObject;
if (schema.allOf && !schema.properties && schema.type !== 'object') {
return undefined;
}
// Defense-in-depth: `workingName` should be a bare ref name here, but
// guard against anything that isn't a valid identifier (e.g. a
// malformed union expression) rather than emitting a phantom type.
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(workingName)) {
return undefined;
}
typeName = `${pascal(workingName)}${itemSuffix}`;
}
}

return {
factoryName: `get${pascal(operationId)}Response${pascal(propertyName)}ItemMock`,
typeName,
Expand Down
Loading
Loading