diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8b723fb33e..0f40fe07bf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1305,6 +1305,7 @@ export interface GeneratorMockOutput { type: OutputMockType; implementation: string; imports: GeneratorImport[]; + strictMockSchemaTypeNames?: string[]; } export interface GeneratorMockOutputFull { @@ -1315,6 +1316,7 @@ export interface GeneratorMockOutputFull { handlerName: string; }; imports: GeneratorImport[]; + strictMockSchemaTypeNames?: string[]; } export interface GeneratorTarget { @@ -1482,6 +1484,7 @@ export interface ClientMockGeneratorImplementation { export interface ClientMockGeneratorBuilder { imports: GeneratorImport[]; implementation: ClientMockGeneratorImplementation; + strictMockSchemaTypeNames?: string[]; } export type ClientMockBuilder = ( @@ -1644,6 +1647,11 @@ export type ResReqTypesValue = ScalarValue & { originalSchema?: OpenApiSchemaObject; }; +export interface FinalizeMockImplementationOptions { + mockOptions?: Pick; + strictSchemaTypeNames?: readonly string[]; +} + export interface WriteSpecBuilder { operations: GeneratorOperations; verbOptions: Record; @@ -1653,6 +1661,11 @@ export interface WriteSpecBuilder { footer: GeneratorClientFooter; imports: GeneratorClientImports; importsMock: GenerateMockImports; + /** Hoists shared strict-mock type aliases once per aggregated mock file. */ + finalizeMockImplementation?: ( + implementation: string, + options: FinalizeMockImplementationOptions, + ) => string; extraFiles: ClientFileBuilder[]; info: OpenApiInfoObject; target: string; @@ -1740,6 +1753,11 @@ export type GeneratorApiBuilder = GeneratorApiOperations & { footer: GeneratorClientFooter; imports: GeneratorClientImports; importsMock: GenerateMockImports; + /** Hoists shared strict-mock type aliases once per aggregated mock file. */ + finalizeMockImplementation?: ( + implementation: string, + options: FinalizeMockImplementationOptions, + ) => string; extraFiles: ClientFileBuilder[]; }; diff --git a/packages/core/src/writers/finalize-mock-implementation.ts b/packages/core/src/writers/finalize-mock-implementation.ts new file mode 100644 index 0000000000..cab8dad938 --- /dev/null +++ b/packages/core/src/writers/finalize-mock-implementation.ts @@ -0,0 +1,34 @@ +import type { + FinalizeMockImplementationOptions, + GeneratorMockOutput, + NormalizedOutputOptions, +} from '../types'; + +type MockOutputWithStrictNames = Pick< + GeneratorMockOutput, + 'strictMockSchemaTypeNames' +>; + +export function getFinalizeMockImplementationOptions( + output: NormalizedOutputOptions, + mockOutputs: MockOutputWithStrictNames | readonly MockOutputWithStrictNames[], +): FinalizeMockImplementationOptions { + const outputs: readonly MockOutputWithStrictNames[] = Array.isArray( + mockOutputs, + ) + ? mockOutputs + : [mockOutputs]; + const strictSchemaTypeNames = [ + ...new Set( + outputs.flatMap( + (mockOutput) => mockOutput.strictMockSchemaTypeNames ?? [], + ), + ), + ]; + + return { + mockOptions: output.override.mock, + strictSchemaTypeNames: + strictSchemaTypeNames.length > 0 ? strictSchemaTypeNames : undefined, + }; +} diff --git a/packages/core/src/writers/single-mode.ts b/packages/core/src/writers/single-mode.ts index f1e7e5accb..0afa0f032a 100644 --- a/packages/core/src/writers/single-mode.ts +++ b/packages/core/src/writers/single-mode.ts @@ -10,6 +10,7 @@ import { } from '../utils'; import { escapeRegExp } from '../utils/string'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { collapseInlineMockOutputs } from './mock-outputs'; import { generateTarget } from './target'; @@ -56,6 +57,12 @@ export async function writeSingleMode({ const implementationMock = mockOutputs .map((m) => m.implementation) .join('\n\n'); + const finalizedImplementationMock = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation( + implementationMock, + getFinalizeMockImplementationOptions(output, mockOutputs), + ) + : implementationMock; // Aggregate imports across all mock entries for the value-import promotion // pass below. const importsMock = mockOutputs.flatMap((m) => m.imports); @@ -222,7 +229,7 @@ export async function writeSingleMode({ if (mockOutputs.length > 0) { data += '\n\n'; - data += implementationMock; + data += finalizedImplementationMock; } await writeGeneratedFile(path, data); diff --git a/packages/core/src/writers/split-mode.ts b/packages/core/src/writers/split-mode.ts index d94ed6853a..338b0848e1 100644 --- a/packages/core/src/writers/split-mode.ts +++ b/packages/core/src/writers/split-mode.ts @@ -16,6 +16,7 @@ import { } from '../utils'; import { getMockFileExtensionByTypeName } from '../utils/file-extensions'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { generateTarget } from './target'; import { getOrvalGeneratedTypes, getTypedResponse } from './types'; @@ -184,15 +185,21 @@ export async function writeSplitMode({ relativeSchemasPath, ); let mockData = header; + const finalizedMockImplementation = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation( + mockOutput.implementation, + getFinalizeMockImplementationOptions(output, mockOutput), + ) + : mockOutput.implementation; mockData += builder.importsMock({ - implementation: mockOutput.implementation, + implementation: finalizedMockImplementation, imports: importsMockForBuilder, projectName, hasSchemaDir: !!output.schemas, isAllowSyntheticDefaultImports, options: entry, }); - mockData += `\n${mockOutput.implementation}`; + mockData += `\n${finalizedMockImplementation}`; const mockPath = path.join( dirname, diff --git a/packages/core/src/writers/split-tags-mode.ts b/packages/core/src/writers/split-tags-mode.ts index f3d73262fa..697fe032f3 100644 --- a/packages/core/src/writers/split-tags-mode.ts +++ b/packages/core/src/writers/split-tags-mode.ts @@ -20,6 +20,7 @@ import { } from '../utils'; import { getMockFileExtensionByTypeName } from '../utils/file-extensions'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { generateTargetForTags } from './target-tags'; import { getOrvalGeneratedTypes, getTypedResponse } from './types'; @@ -264,16 +265,22 @@ export async function writeSplitTagsMode({ relativeSchemasPath, ); + const finalizedMockImplementation = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation( + mockOutput.implementation, + getFinalizeMockImplementationOptions(output, mockOutput), + ) + : mockOutput.implementation; let mockData = header; mockData += builder.importsMock({ - implementation: mockOutput.implementation, + implementation: finalizedMockImplementation, imports: importsMockForBuilder, projectName, hasSchemaDir: !!output.schemas, isAllowSyntheticDefaultImports, options: entry, }); - mockData += `\n${mockOutput.implementation}`; + mockData += `\n${finalizedMockImplementation}`; const mockPath = path.join( dirname, diff --git a/packages/core/src/writers/tags-mode.ts b/packages/core/src/writers/tags-mode.ts index 7fadaf117e..98beb3da45 100644 --- a/packages/core/src/writers/tags-mode.ts +++ b/packages/core/src/writers/tags-mode.ts @@ -13,6 +13,7 @@ import { } from '../utils'; import { escapeRegExp } from '../utils/string'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { collapseInlineMockOutputs } from './mock-outputs'; import { generateTargetForTags } from './target-tags'; @@ -70,6 +71,12 @@ export async function writeTagsMode({ const implementationMock = mockOutputs .map((m) => m.implementation) .join('\n\n'); + const finalizedImplementationMock = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation( + implementationMock, + getFinalizeMockImplementationOptions(output, mockOutputs), + ) + : implementationMock; let data = header; @@ -231,7 +238,7 @@ export async function writeTagsMode({ if (mockOutputs.length > 0) { data += '\n\n'; - data += implementationMock; + data += finalizedImplementationMock; } const implementationPath = path.join( diff --git a/packages/core/src/writers/target-tags.ts b/packages/core/src/writers/target-tags.ts index 9da0e2d400..4862b3147d 100644 --- a/packages/core/src/writers/target-tags.ts +++ b/packages/core/src/writers/target-tags.ts @@ -39,6 +39,7 @@ function flattenMockOutput(full: GeneratorMockOutputFull): GeneratorMockOutput { type: full.type, implementation: full.implementation.function + full.implementation.handler, imports: full.imports, + strictMockSchemaTypeNames: full.strictMockSchemaTypeNames, }; } @@ -58,6 +59,14 @@ function mergeOperationMockOutputs( result.push(acc); } acc.imports.push(...op.imports); + if (op.strictMockSchemaTypeNames?.length) { + acc.strictMockSchemaTypeNames = [ + ...new Set([ + ...(acc.strictMockSchemaTypeNames ?? []), + ...op.strictMockSchemaTypeNames, + ]), + ]; + } acc.implementation.function += op.implementation.function; acc.implementation.handler += op.implementation.handler; if (op.implementation.handlerName) { diff --git a/packages/core/src/writers/target.ts b/packages/core/src/writers/target.ts index 6ced58356b..427db64640 100644 --- a/packages/core/src/writers/target.ts +++ b/packages/core/src/writers/target.ts @@ -24,6 +24,7 @@ function flattenMockOutput(full: GeneratorMockOutputFull): GeneratorMockOutput { type: full.type, implementation: full.implementation.function + full.implementation.handler, imports: full.imports, + strictMockSchemaTypeNames: full.strictMockSchemaTypeNames, }; } @@ -70,6 +71,14 @@ export function generateTarget( target.mockOutputs.push(acc); } acc.imports.push(...opMock.imports); + if (opMock.strictMockSchemaTypeNames?.length) { + acc.strictMockSchemaTypeNames = [ + ...new Set([ + ...(acc.strictMockSchemaTypeNames ?? []), + ...opMock.strictMockSchemaTypeNames, + ]), + ]; + } acc.implementation.function += opMock.implementation.function; acc.implementation.handler += opMock.implementation.handler; if (opMock.implementation.handlerName) { diff --git a/packages/mock/src/faker/getters/array-item-factory.ts b/packages/mock/src/faker/getters/array-item-factory.ts index ad79fa4974..50dad2bd8e 100644 --- a/packages/mock/src/faker/getters/array-item-factory.ts +++ b/packages/mock/src/faker/getters/array-item-factory.ts @@ -13,6 +13,10 @@ import { resolveRef, } from '@orval/core'; +import { + formatMockFactoryDeclaration, + getMockFactorySignatureParts, +} from '../../mock-types'; import type { MockSchema } from '../../types'; import { overrideVarName } from './object'; import { extractItemsRef } from './scalar'; @@ -300,11 +304,24 @@ export function extractArrayItemMock({ ); if (!alreadyExtracted) { - const args = `${overrideVarName}: Partial<${typeName}> = {}`; + const mockOptions = context.output.override.mock; + const { param, returnType, returnCast } = getMockFactorySignatureParts( + typeName, + mockOptions, + { + isOverridable: true, + overrideType: `Partial<${typeName}>`, + }, + ); const spreadPrefix = mapValue.startsWith('...') ? '' : '...'; - const func = - `export const ${factoryName} = (${args}): ${typeName} => ` + - `({${spreadPrefix}${mapValue}, ...${overrideVarName}});`; + const func = formatMockFactoryDeclaration( + factoryName, + param, + returnType, + `{${spreadPrefix}${mapValue}, ...${overrideVarName}}`, + returnCast, + { terminateStatement: true }, + ); splitMockImplementations.push(func); fileLevelFactories.add(factoryName); } diff --git a/packages/mock/src/faker/index.test.ts b/packages/mock/src/faker/index.test.ts index 2f489fe748..283dd99b92 100644 --- a/packages/mock/src/faker/index.test.ts +++ b/packages/mock/src/faker/index.test.ts @@ -10,7 +10,12 @@ import type { import { isFakerMock, isMswMock, OutputMockType } from '@orval/core'; import { describe, expect, expectTypeOf, it } from 'vitest'; -import { generateFaker, generateFakerImports } from './index'; +import { createTestContextSpec } from '../../../core/src/test-utils/context'; +import { + generateFaker, + generateFakerForSchemas, + generateFakerImports, +} from './index'; const mockVerbOptions = { operationId: 'getUser', @@ -155,3 +160,47 @@ describe('discriminated GlobalMockOptions union', () => { expect(isFakerMock(mock)).toBe(false); }); }); + +describe('generateFakerForSchemas strict mock types (#3525)', () => { + const context = createTestContextSpec({ + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + }); + + it('emits PetMock alias and return type for schema factories', () => { + const result = generateFakerForSchemas( + [ + { + name: 'Pet', + model: 'Pet', + imports: [], + schema: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + tag: { type: 'string', nullable: true }, + }, + }, + }, + ], + context, + { type: OutputMockType.FAKER, schemas: true }, + ); + + expect(result.implementation).toContain('export type PetMock = {'); + expect(result.implementation).toContain('export type KeysWithNull'); + expect(result.implementation).toContain( + 'export const getPetMock = = {}>(overrideResponse?: O): MockWithNullableOverrides =>', + ); + expect(result.implementation).toContain( + ') as MockWithNullableOverrides;', + ); + expect(result.implementation).not.toContain(', null]'); + }); +}); diff --git a/packages/mock/src/faker/index.ts b/packages/mock/src/faker/index.ts index 7eecb72215..f88aa56143 100644 --- a/packages/mock/src/faker/index.ts +++ b/packages/mock/src/faker/index.ts @@ -12,6 +12,13 @@ import { pascal, } from '@orval/core'; +import { + formatMockFactoryDeclaration, + getMockFactorySignatureParts, + getStrictMockHelperTypeDeclarations, + getStrictMockTypeDeclaration, + isStrictMock, +} from '../mock-types'; import { generateMSW } from '../msw'; import { getMockScalar } from './getters'; @@ -70,6 +77,7 @@ export function generateFaker( handlerName: '', }, imports: result.imports, + strictMockSchemaTypeNames: result.strictMockSchemaTypeNames, }; } @@ -93,6 +101,7 @@ export function generateFakerForSchemas( options: GlobalMockOptions, ): GenerateFakerForSchemasResult { const factories: string[] = []; + const strictMockTypeNames = new Set(); const allImports: GeneratorImport[] = []; // Shared across schemas so we emit each helper (e.g. an `allOf`-discriminator // sub-factory) once even when several schemas reference the same union arm. @@ -141,10 +150,25 @@ export function generateFakerForSchemas( // emit a `Partial` signature TS can't satisfy. const typeName = pascal(name); const isOverridable = result.value.includes('overrideResponse'); - const param = isOverridable - ? `overrideResponse: Partial<${typeName}> = {}` - : ''; - const factory = `export const ${factoryName} = (${param}): ${typeName} => (${result.value});\n`; + const { param, returnType, returnCast } = getMockFactorySignatureParts( + typeName, + mockOptions, + { + isOverridable, + overrideType: `Partial<${typeName}>`, + }, + ); + const factory = formatMockFactoryDeclaration( + factoryName, + param, + returnType, + result.value, + returnCast, + ); + + if (isStrictMock(mockOptions) && isOverridable) { + strictMockTypeNames.add(typeName); + } factories.push(factory); @@ -191,7 +215,23 @@ export function generateFakerForSchemas( // Helper factories from union/discriminator handling (`splitMockImplementations`) // are emitted before the public `getMock` factories so call sites // declared after them resolve cleanly without TS hoisting concerns. - const implementation = [...splitMockImplementations, ...factories].join('\n'); + const strictHelperBlock = isStrictMock(mockOptions) + ? getStrictMockHelperTypeDeclarations() + : ''; + const strictTypeDeclarations = isStrictMock(mockOptions) + ? [...strictMockTypeNames] + .map((typeName) => getStrictMockTypeDeclaration(typeName)) + .join('\n\n') + : ''; + const strictTypeBlock = strictTypeDeclarations; + const implementation = [ + ...splitMockImplementations, + strictHelperBlock, + strictTypeBlock, + ...factories, + ] + .filter(Boolean) + .join('\n\n'); return { implementation, diff --git a/packages/mock/src/faker/resolvers/value.ts b/packages/mock/src/faker/resolvers/value.ts index 562b4633a5..ab0d046a05 100644 --- a/packages/mock/src/faker/resolvers/value.ts +++ b/packages/mock/src/faker/resolvers/value.ts @@ -11,6 +11,10 @@ import { } from '@orval/core'; import { prop } from 'remeda'; +import { + formatMockFactoryDeclaration, + getMockFactorySignatureParts, +} from '../../mock-types'; import type { MockDefinition, MockSchema, MockSchemaObject } from '../../types'; import { overrideVarName } from '../getters'; import { getMockScalar } from '../getters/scalar'; @@ -403,13 +407,27 @@ export function resolveMockValue({ | undefined; const discriminatedProperty = discriminator?.propertyName; - let type = `Partial<${newSchema.name}>`; + let overrideType = `Partial<${newSchema.name}>`; if (discriminatedProperty) { - type = `Omit<${type}, '${discriminatedProperty}'>`; + overrideType = `Omit<${overrideType}, '${discriminatedProperty}'>`; } - const args = `${overrideVarName}: ${type} = {}`; - const func = `export const ${funcName} = (${args}): ${newSchema.name} => ({${scalar.value.startsWith('...') ? '' : '...'}${scalar.value}, ...${overrideVarName}});`; + const { param, returnType, returnCast } = getMockFactorySignatureParts( + newSchema.name, + mockOptions, + { + isOverridable: true, + overrideType, + }, + ); + const func = formatMockFactoryDeclaration( + funcName, + param, + returnType, + `{${scalar.value.startsWith('...') ? '' : '...'}${scalar.value}, ...${overrideVarName}}`, + returnCast, + { terminateStatement: true }, + ); splitMockImplementations.push(func); } diff --git a/packages/mock/src/index.ts b/packages/mock/src/index.ts index 438aae8a06..ea6cdc2032 100644 --- a/packages/mock/src/index.ts +++ b/packages/mock/src/index.ts @@ -83,4 +83,9 @@ export { generateFakerForSchemas, generateFakerImports, } from './faker'; +export { + buildStrictMockTypeFileHeader, + collectStrictMockSchemaTypeNames, + dedupeStrictMockTypeDeclarations, +} from './mock-types'; export { generateMSW, generateMSWImports } from './msw'; diff --git a/packages/mock/src/mock-types.test.ts b/packages/mock/src/mock-types.test.ts new file mode 100644 index 0000000000..770ac59859 --- /dev/null +++ b/packages/mock/src/mock-types.test.ts @@ -0,0 +1,317 @@ +import type { ResReqTypesValue } from '@orval/core'; +import { describe, expect, it } from 'vitest'; + +import { + applyStrictMockReturnType, + buildStrictMockTypeFileHeader, + collectStrictMockSchemaNamesFromUsage, + collectStrictMockSchemaTypeNames, + dedupeStrictMockTypeDeclarations, + getMockFactoryReturnType, + getMockFactorySignatureParts, + getSchemaTypeNamesFromResponses, + getSimpleSchemaReturnType, + getStrictMockHelperTypeDeclarations, + getStrictMockTypeDeclaration, + getStrictMockTypeDeclarations, + getStrictMockTypeName, + isStrictMock, +} from './mock-types'; + +describe('mock-types', () => { + describe('isStrictMock', () => { + it('is true only when required and nonNullable are both true', () => { + expect(isStrictMock()).toBe(false); + expect(isStrictMock({ required: true })).toBe(false); + expect(isStrictMock({ nonNullable: true })).toBe(false); + expect(isStrictMock({ required: true, nonNullable: true })).toBe(true); + }); + }); + + describe('getStrictMockHelperTypeDeclarations', () => { + it('emits KeysWithNull and MockWithNullableOverrides helpers', () => { + expect(getStrictMockHelperTypeDeclarations()).toContain( + 'export type KeysWithNull', + ); + expect(getStrictMockHelperTypeDeclarations()).toContain( + 'export type MockWithNullableOverrides', + ); + }); + }); + + describe('getStrictMockTypeDeclarations', () => { + it('deduplicates schema names and joins mock type aliases', () => { + const result = getStrictMockTypeDeclarations(['Pet', 'Pet', 'Error']); + + expect(result).toBe( + [ + getStrictMockTypeDeclaration('Pet'), + getStrictMockTypeDeclaration('Error'), + ].join('\n\n'), + ); + expect(result.match(/export type PetMock/g)?.length).toBe(1); + expect(result.match(/export type ErrorMock/g)?.length).toBe(1); + }); + + it('returns an empty string when no schema names are provided', () => { + expect(getStrictMockTypeDeclarations([])).toBe(''); + }); + }); + + describe('getStrictMockTypeDeclaration', () => { + it('emits a Required/NonNullable mapped type alias', () => { + expect(getStrictMockTypeDeclaration('Pet')).toBe( + 'export type PetMock = {\n [K in keyof Required]: NonNullable[K]>;\n};', + ); + }); + }); + + describe('getMockFactoryReturnType', () => { + it('returns the strict mock alias when both flags are set', () => { + expect( + getMockFactoryReturnType('Pet', { + required: true, + nonNullable: true, + }), + ).toBe('PetMock'); + }); + + it('returns the original type when either flag is unset', () => { + expect(getMockFactoryReturnType('Pet')).toBe('Pet'); + expect(getMockFactoryReturnType('Pet', { required: true })).toBe('Pet'); + }); + }); + + describe('getMockFactorySignatureParts', () => { + const strictOptions = { required: true, nonNullable: true } as const; + + it('uses generic override narrowing for strict overridable factories', () => { + expect( + getMockFactorySignatureParts('Pet', strictOptions, { + isOverridable: true, + }), + ).toEqual({ + param: ' = {}>(overrideResponse?: O)', + returnType: 'MockWithNullableOverrides', + returnCast: ' as MockWithNullableOverrides', + }); + }); + + it('keeps the plain signature when strict flags are unset', () => { + expect( + getMockFactorySignatureParts('Pet', undefined, { + isOverridable: true, + }), + ).toEqual({ + param: 'overrideResponse: Partial = {}', + returnType: 'Pet', + returnCast: '', + }); + }); + + it('returns only the strict mock alias when not overridable', () => { + expect( + getMockFactorySignatureParts('Pet', strictOptions, { + isOverridable: false, + }), + ).toEqual({ + param: '', + returnType: 'PetMock', + returnCast: '', + }); + }); + }); + + describe('getSimpleSchemaReturnType', () => { + it('returns the type when it matches a known schema name', () => { + expect(getSimpleSchemaReturnType('Pet', ['Pet', 'Error'])).toBe('Pet'); + expect(getSimpleSchemaReturnType('Pet | Error', ['Pet', 'Error'])).toBe( + undefined, + ); + }); + }); + + describe('applyStrictMockReturnType', () => { + it('replaces known schema names with Mock suffixes', () => { + expect(applyStrictMockReturnType('Pet | Error', ['Pet', 'Error'])).toBe( + 'PetMock | ErrorMock', + ); + expect(applyStrictMockReturnType('Pet[]', ['Pet'])).toBe('PetMock[]'); + }); + + it('does not partially replace longer schema names', () => { + expect( + applyStrictMockReturnType('PetWithTag', ['Pet', 'PetWithTag']), + ).toBe('PetWithTagMock'); + }); + }); + + describe('dedupeStrictMockTypeDeclarations', () => { + const strictOptions = { + mockOptions: { required: true, nonNullable: true } as const, + strictSchemaTypeNames: ['Pet'], + }; + + it('hoists helpers and schema mock aliases once for concatenated operation mocks', () => { + const perOp = `${getStrictMockHelperTypeDeclarations()}\n\n${getStrictMockTypeDeclaration('Pet')}\n\nexport const getGetPetResponseMock = () => ({})`; + const duplicated = `${perOp}\n\n${perOp}\n\n${perOp}`; + + const result = dedupeStrictMockTypeDeclarations( + duplicated, + strictOptions, + ); + + expect(result.match(/export type KeysWithNull/g)?.length).toBe(1); + expect( + result.match(/export type MockWithNullableOverrides/g)?.length, + ).toBe(1); + expect(result.match(/export type PetMock/g)?.length).toBe(1); + expect(result.match(/export const getGetPetResponseMock/g)?.length).toBe( + 3, + ); + }); + + it('strips invalid strict mock aliases for factory value imports', () => { + const invalid = `export type getPetMockMock = { + [K in keyof Required]: NonNullable[K]>; +};\n\nexport const getListPetsResponseMock = () => []`; + + const result = dedupeStrictMockTypeDeclarations(invalid, strictOptions); + + expect(result).not.toContain('getPetMockMock'); + expect(result).not.toContain('Required'); + }); + + it('is a no-op for non-strict mocks even when a schema is named WidgetMock', () => { + const body = `import type { WidgetMock } from './model';\n\nexport const getGetWidgetResponseMock = (overrideResponse: Partial = {}) => ({ ...overrideResponse, id: faker.number.int() })`; + + const result = dedupeStrictMockTypeDeclarations(body); + + expect(result).toBe(body); + expect(result).not.toContain('export type KeysWithNull'); + expect(result).not.toContain('Required'); + }); + }); + + describe('getSchemaTypeNamesFromResponses', () => { + it('ignores value and schema-factory imports', () => { + const responses = [ + { + value: 'Pet[]', + imports: [ + { name: 'Pet', values: false }, + { name: 'getPetMock', values: true, schemaFactory: true }, + ], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses)).toEqual(['Pet']); + }); + + it('collects schema names from response values and imports', () => { + const responses = [ + { + value: 'Pet', + imports: [{ name: 'Pet', values: false }], + }, + { + value: 'Error[]', + imports: [{ name: 'Error', values: false }], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses).toSorted()).toEqual([ + 'Error', + 'Pet', + ]); + }); + + it('uses import aliases when present', () => { + const responses = [ + { + imports: [{ name: 'Widget', alias: 'CustomWidget', values: false }], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses)).toEqual([ + 'CustomWidget', + ]); + }); + + it('skips responses with falsy values', () => { + const responses = [ + { + value: '', + imports: [{ name: 'Ignored', values: false }], + }, + { + value: undefined, + imports: [{ name: 'AlsoIgnored', values: false }], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses)).toEqual([ + 'Ignored', + 'AlsoIgnored', + ]); + }); + }); + + describe('getStrictMockTypeName', () => { + it('appends Mock to the schema name', () => { + expect(getStrictMockTypeName('Pet')).toBe('PetMock'); + }); + }); + + describe('buildStrictMockTypeFileHeader', () => { + it('includes helpers and deduped schema mock aliases', () => { + const header = buildStrictMockTypeFileHeader(['Pet', 'Pet']); + + expect(header).toContain('export type KeysWithNull'); + expect(header.match(/export type PetMock/g)?.length).toBe(1); + }); + }); + + describe('collectStrictMockSchemaTypeNames', () => { + it('reads schema names from strict mock alias declarations', () => { + const names = collectStrictMockSchemaTypeNames( + getStrictMockTypeDeclaration('Pet'), + ); + + expect(names).toEqual(['Pet']); + }); + }); + + describe('collectStrictMockSchemaNamesFromUsage', () => { + it('collects schema names referenced by MockWithNullableOverrides factories', () => { + const names = collectStrictMockSchemaNamesFromUsage( + '(): MockWithNullableOverrides => ({}) as MockWithNullableOverrides;\nexport const getListPetsResponseMock = (): PetMock[] => []', + ); + + expect(names).toEqual(['Pet']); + }); + + it('does not treat a schema named WidgetMock as a strict alias usage', () => { + const names = collectStrictMockSchemaNamesFromUsage( + 'export const getGetWidgetResponseMock = (overrideResponse: Partial = {}) => ({})', + ); + + expect(names).toEqual([]); + }); + }); + + describe('dedupeStrictMockTypeDeclarations with usage-only mocks', () => { + it('hoists helpers when factories reference strict types without inline declarations', () => { + const body = `export const getGetPetResponseMock = (): MockWithNullableOverrides => ({}) as MockWithNullableOverrides;\nexport const getListPetsResponseMock = (): PetMock[] => []`; + + const result = dedupeStrictMockTypeDeclarations(body, { + mockOptions: { required: true, nonNullable: true }, + strictSchemaTypeNames: ['Pet'], + }); + + expect(result).toContain('export type KeysWithNull'); + expect(result).toContain('export type PetMock'); + expect(result.match(/export type PetMock/g)?.length).toBe(1); + }); + }); +}); diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts new file mode 100644 index 0000000000..7bbf81ddb1 --- /dev/null +++ b/packages/mock/src/mock-types.ts @@ -0,0 +1,273 @@ +import { + escapeRegExp, + type FinalizeMockImplementationOptions, + type MockOptions, + type ResReqTypesValue, +} from '@orval/core'; + +export function isStrictMock( + mockOptions?: Pick, +): boolean { + return Boolean( + mockOptions && mockOptions.required && mockOptions.nonNullable, + ); +} + +export function getStrictMockTypeName(typeName: string): string { + return `${typeName}Mock`; +} + +export function getStrictMockHelperTypeDeclarations(): string { + return `export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +};`; +} + +export function getStrictMockTypeDeclaration(typeName: string): string { + const mockTypeName = getStrictMockTypeName(typeName); + return `export type ${mockTypeName} = {\n [K in keyof Required<${typeName}>]: NonNullable[K]>;\n};`; +} + +export function getStrictMockTypeDeclarations( + typeNames: Iterable, +): string { + const unique = [...new Set(typeNames)]; + if (unique.length === 0) { + return ''; + } + + return unique + .map((typeName) => getStrictMockTypeDeclaration(typeName)) + .join('\n\n'); +} + +export function getMockFactoryReturnType( + typeName: string, + mockOptions?: Pick, +): string { + return isStrictMock(mockOptions) ? getStrictMockTypeName(typeName) : typeName; +} + +export interface MockFactorySignatureParts { + param: string; + returnType: string; + returnCast: string; +} + +export interface GetMockFactorySignaturePartsOptions { + isOverridable?: boolean; + overrideType?: string; +} + +export function getMockFactorySignatureParts( + typeName: string, + mockOptions?: Pick, + options: GetMockFactorySignaturePartsOptions = {}, +): MockFactorySignatureParts { + const isOverridable = options.isOverridable ?? false; + const overrideType = options.overrideType ?? `Partial<${typeName}>`; + const mockTypeName = getStrictMockTypeName(typeName); + + if (!isOverridable) { + return { + param: '', + returnType: getMockFactoryReturnType(typeName, mockOptions), + returnCast: '', + }; + } + + if (isStrictMock(mockOptions)) { + return { + param: `(overrideResponse?: O)`, + returnType: `MockWithNullableOverrides<${typeName}, O, ${mockTypeName}>`, + returnCast: ` as MockWithNullableOverrides<${typeName}, O, ${mockTypeName}>`, + }; + } + + return { + param: `overrideResponse: ${overrideType} = {}`, + returnType: typeName, + returnCast: '', + }; +} + +export function getSimpleSchemaReturnType( + returnType: string, + schemaTypeNames: string[], +): string | undefined { + const trimmed = returnType.trim(); + return schemaTypeNames.includes(trimmed) ? trimmed : undefined; +} + +export function formatMockFactoryDeclaration( + factoryName: string, + param: string, + returnType: string, + body: string, + returnCast: string, + options?: { omitReturnType?: boolean; terminateStatement?: boolean }, +): string { + const header = param + ? param.startsWith('<') + ? `export const ${factoryName} = ${param}` + : `export const ${factoryName} = (${param})` + : `export const ${factoryName} = ()`; + + const returnTypeAnnotation = + options?.omitReturnType || !returnType ? '' : `: ${returnType}`; + + const statementTerminator = + returnCast || options?.terminateStatement ? ';' : ''; + + return `${header}${returnTypeAnnotation} => (${body})${returnCast}${statementTerminator}`; +} + +export function getSchemaTypeNamesFromResponses( + responses: ResReqTypesValue[], +): string[] { + const names = new Set(); + + for (const response of responses) { + for (const imp of response.imports) { + if (imp.values || imp.schemaFactory) { + continue; + } + + const importName = imp.alias ?? imp.name; + if (/^[A-Z]\w*$/.test(importName)) { + names.add(importName); + } + } + + const { value } = response; + if (!value) { + continue; + } + + const baseType = value.endsWith('[]') ? value.slice(0, -2) : value; + if (/^[A-Z]\w*$/.test(baseType)) { + names.add(baseType); + } + } + + return [...names]; +} + +const STRICT_MOCK_SCHEMA_DECL_PATTERN = + /export type (\w+) = \{\n \[K in keyof Required<(\w+)>]: NonNullable\[K\]>;\n\};/g; + +/** Removes invalid strict-mock aliases emitted for value imports (e.g. getPetMockMock). */ +const INVALID_STRICT_MOCK_DECL_PATTERN = + /export type get\w+Mock = \{[\s\S]*?\};\n*/g; + +export function collectStrictMockSchemaTypeNames( + implementation: string, +): string[] { + const names = new Set(); + + for (const match of implementation.matchAll( + STRICT_MOCK_SCHEMA_DECL_PATTERN, + )) { + names.add(match[2]); + } + + return [...names]; +} + +export function collectStrictMockSchemaNamesFromUsage( + implementation: string, +): string[] { + const names = new Set(); + + for (const match of implementation.matchAll( + /MockWithNullableOverrides<(\w+),/g, + )) { + names.add(match[1]); + } + + return [...names]; +} + +export function buildStrictMockTypeFileHeader( + schemaTypeNames: Iterable, +): string { + const uniqueSchemaNames = [...new Set(schemaTypeNames)]; + const schemaBlock = getStrictMockTypeDeclarations(uniqueSchemaNames); + + return [getStrictMockHelperTypeDeclarations(), schemaBlock] + .filter(Boolean) + .join('\n\n'); +} + +/** + * MSW/faker operation mocks are concatenated per file with no dedup. Hoist the + * shared strict-mock helper types and each `{Schema}Mock` alias once at the top. + */ +export function dedupeStrictMockTypeDeclarations( + implementation: string, + options: FinalizeMockImplementationOptions = {}, +): string { + if (!isStrictMock(options.mockOptions)) { + return implementation; + } + + let body = implementation.replaceAll(INVALID_STRICT_MOCK_DECL_PATTERN, ''); + + const schemaTypeNames = [ + ...new Set([ + ...(options.strictSchemaTypeNames ?? []), + ...collectStrictMockSchemaTypeNames(body), + ...collectStrictMockSchemaNamesFromUsage(body), + ]), + ]; + + if ( + schemaTypeNames.length === 0 && + !body.includes('MockWithNullableOverrides<') && + !body.includes('export type KeysWithNull') + ) { + return body; + } + + const helperBlock = getStrictMockHelperTypeDeclarations(); + + body = body.replaceAll(helperBlock, ''); + + for (const typeName of schemaTypeNames) { + body = body.replaceAll(getStrictMockTypeDeclaration(typeName), ''); + } + + const trimmedBody = body.replace(/^\n+/, '').trimStart(); + const header = buildStrictMockTypeFileHeader(schemaTypeNames); + + return header ? `${header}\n\n${trimmedBody}` : trimmedBody; +} + +export function applyStrictMockReturnType( + returnType: string, + schemaTypeNames: string[], +): string { + if (schemaTypeNames.length === 0) { + return returnType; + } + + let result = returnType; + const sorted = [...schemaTypeNames].toSorted((a, b) => b.length - a.length); + + for (const name of sorted) { + result = result.replaceAll( + new RegExp(String.raw`\b${escapeRegExp(name)}\b`, 'g'), + getStrictMockTypeName(name), + ); + } + + return result; +} diff --git a/packages/mock/src/msw/index.test.ts b/packages/mock/src/msw/index.test.ts index 0a24695085..bbfb696207 100644 --- a/packages/mock/src/msw/index.test.ts +++ b/packages/mock/src/msw/index.test.ts @@ -1397,3 +1397,127 @@ describe('generateMSW', () => { }); }); }); + +describe('strict mock types (#3525)', () => { + const petResponseType = { + key: '200', + value: 'Pet', + contentType: 'application/json', + originalSchema: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + tag: { type: 'string', nullable: true }, + }, + }, + imports: [{ name: 'Pet', values: false }], + schemas: [], + type: 'object', + isEnum: false, + isRef: true, + hasReadonlyProps: false, + }; + + const petVerbOptions = { + operationId: 'getPet', + verb: 'get', + tags: [], + response: { + imports: [{ name: 'Pet', values: false }], + definition: { success: 'Pet' }, + types: { success: [petResponseType] }, + contentTypes: ['application/json'], + }, + } as unknown as GeneratorVerbOptions; + + const strictOverride = { + operations: {}, + tags: {}, + mock: { + required: true, + nonNullable: true, + }, + } as NormalizedOverrideOutput; + + const baseOptions = { + route: '/pet', + pathRoute: '/pet', + output: 'test', + override: strictOverride, + context: { + target: 'test', + workspace: '', + spec: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + output: { + target: 'test', + namingConvention: 'camelCase', + fileExtension: '.ts', + mode: 'single', + override: strictOverride, + client: 'axios-functions', + httpClient: 'fetch', + clean: false, + docs: false, + formatter: undefined, + headers: false, + indexFiles: true, + allParamsOptional: false, + urlEncodeParameters: false, + unionAddMissingProperties: false, + optionsParamRequired: false, + propertySortOrder: 'specification', + }, + }, + } as unknown as GeneratorOptions; + + it('emits PetMock return type for response mocks without per-operation type blocks', () => { + const result = generateMSW(petVerbOptions, { + ...baseOptions, + mock: { type: OutputMockType.MSW }, + }); + + expect(result.implementation.function).not.toContain('export type PetMock'); + expect(result.implementation.function).not.toContain( + 'export type KeysWithNull', + ); + expect(result.implementation.function).toContain( + 'export const getGetPetResponseMock = > = {}>(overrideResponse?: O): MockWithNullableOverrides =>', + ); + expect(result.implementation.function).toContain( + ') as MockWithNullableOverrides;', + ); + expect(result.implementation.function).not.toContain(', null]'); + }); + + it('keeps the loose return type when strict flags are unset', () => { + const looseOverride = { + operations: {}, + tags: {}, + mock: {}, + } as NormalizedOverrideOutput; + + const result = generateMSW(petVerbOptions, { + ...baseOptions, + override: looseOverride, + context: { + ...baseOptions.context, + output: { + ...baseOptions.context.output, + override: looseOverride, + }, + }, + mock: { type: OutputMockType.MSW }, + }); + + expect(result.implementation.function).not.toContain('export type PetMock'); + expect(result.implementation.function).toContain( + 'export const getGetPetResponseMock = (overrideResponse: Partial> = {}): Pet =>', + ); + }); +}); diff --git a/packages/mock/src/msw/index.ts b/packages/mock/src/msw/index.ts index 2e6b1917f2..e78c69da98 100644 --- a/packages/mock/src/msw/index.ts +++ b/packages/mock/src/msw/index.ts @@ -17,6 +17,14 @@ import { import { getDelay } from '../delay'; import { getRouteMSW, overrideVarName } from '../faker/getters'; +import { + applyStrictMockReturnType, + formatMockFactoryDeclaration, + getMockFactorySignatureParts, + getSchemaTypeNamesFromResponses, + getSimpleSchemaReturnType, + isStrictMock, +} from '../mock-types'; import { getMockDefinition, getMockOptionsDataOverride } from './mocks'; function getMSWDependencies( @@ -227,12 +235,54 @@ function generateDefinition( hasStringReturnType && mockReturnType !== 'string'; + const mockOptionsFromOverride = override.mock; + const strictMock = isStrictMock(mockOptionsFromOverride); + const schemaTypeNames = strictMock + ? getSchemaTypeNamesFromResponses(responses) + : []; + const strictMockReturnType = strictMock + ? applyStrictMockReturnType(nonVoidMockReturnType, schemaTypeNames) + : nonVoidMockReturnType; + const simpleSchemaReturnType = strictMock + ? getSimpleSchemaReturnType(nonVoidMockReturnType, schemaTypeNames) + : undefined; + + let mockFactoryParam = ''; + let mockFactoryReturnType = nonVoidMockReturnType; + let mockFactoryReturnCast = ''; + + if (isResponseOverridable) { + if (strictMock && simpleSchemaReturnType) { + const signature = getMockFactorySignatureParts( + simpleSchemaReturnType, + mockOptionsFromOverride, + { + isOverridable: true, + overrideType: overrideResponseType, + }, + ); + mockFactoryParam = signature.param; + mockFactoryReturnType = signature.returnType; + mockFactoryReturnCast = signature.returnCast; + } else { + mockFactoryParam = `overrideResponse: ${overrideResponseType} = {}`; + mockFactoryReturnType = strictMock + ? strictMockReturnType + : nonVoidMockReturnType; + } + } else if (strictMock) { + mockFactoryReturnType = strictMockReturnType; + } + const mockImplementation = isReturnHttpResponse - ? `${mockImplementations}export const ${getResponseMockFunctionName} = (${ - isResponseOverridable - ? `overrideResponse: ${overrideResponseType} = {}` - : '' - })${mockData ? '' : `: ${nonVoidMockReturnType}`} => (${value})\n\n` + ? `${mockImplementations}${formatMockFactoryDeclaration( + getResponseMockFunctionName, + mockFactoryParam, + mockFactoryReturnType, + value, + mockFactoryReturnCast, + { omitReturnType: Boolean(mockData) }, + )}\n\n` : mockImplementations; const delay = getDelay(override, isFunction(mock) ? undefined : mock); @@ -375,6 +425,8 @@ export const ${handlerName} = (overrideResponse?: ${mockReturnType} | ((${infoPa handler: handlerImplementation, }, imports: includeResponseImports, + strictMockSchemaTypeNames: + strictMock && schemaTypeNames.length > 0 ? schemaTypeNames : undefined, }; } @@ -415,6 +467,9 @@ export function generateMSW( const mockImplementations = [baseDefinition.implementation.function]; const handlerImplementations = [baseDefinition.implementation.handler]; const imports = [...baseDefinition.imports]; + const strictMockSchemaTypeNames = new Set( + baseDefinition.strictMockSchemaTypeNames, + ); if ( generatorOptions.mock && @@ -442,9 +497,14 @@ export function generateMSW( mockImplementations.push(definition.implementation.function); handlerImplementations.push(definition.implementation.handler); imports.push(...definition.imports); + for (const name of definition.strictMockSchemaTypeNames ?? []) { + strictMockSchemaTypeNames.add(name); + } } } + const aggregatedStrictNames = [...strictMockSchemaTypeNames]; + return { implementation: { function: mockImplementations.join('\n'), @@ -452,5 +512,7 @@ export function generateMSW( handler: handlerImplementations.join('\n'), }, imports: imports, + strictMockSchemaTypeNames: + aggregatedStrictNames.length > 0 ? aggregatedStrictNames : undefined, }; } diff --git a/packages/mock/src/msw/mocks.ts b/packages/mock/src/msw/mocks.ts index b0472bafdc..fa25e045c0 100644 --- a/packages/mock/src/msw/mocks.ts +++ b/packages/mock/src/msw/mocks.ts @@ -92,6 +92,7 @@ function getMockWithoutFunc( numberMin: override?.mock?.numberMin, numberMax: override?.mock?.numberMax, required: override?.mock?.required, + nonNullable: override?.mock?.nonNullable, fractionDigits: override?.mock?.fractionDigits, ...(override?.mock?.properties ? { diff --git a/packages/orval/src/api.ts b/packages/orval/src/api.ts index a47a55f079..f77d582e98 100644 --- a/packages/orval/src/api.ts +++ b/packages/orval/src/api.ts @@ -14,7 +14,10 @@ import { type OpenApiPathItemObject, resolveRef, } from '@orval/core'; -import { generateMockImports } from '@orval/mock'; +import { + dedupeStrictMockTypeDeclarations, + generateMockImports, +} from '@orval/mock'; import { generateClientFooter, @@ -145,6 +148,7 @@ export async function getApiBuilder({ footer: generateClientFooter, imports: generateClientImports, importsMock: generateMockImports, + finalizeMockImplementation: dedupeStrictMockTypeDeclarations, extraFiles, }; } diff --git a/packages/orval/src/client.ts b/packages/orval/src/client.ts index 8d2d3a3fba..8105978ddd 100644 --- a/packages/orval/src/client.ts +++ b/packages/orval/src/client.ts @@ -304,6 +304,7 @@ export const generateOperations = ( type: isFunction(entry) ? OutputMockType.MSW : entry.type, implementation: generated.implementation, imports: generated.imports, + strictMockSchemaTypeNames: generated.strictMockSchemaTypeNames, }; }); diff --git a/tests/__snapshots__/mock/issue-3525-multi/endpoints.ts b/tests/__snapshots__/mock/issue-3525-multi/endpoints.ts new file mode 100644 index 0000000000..02028e7013 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/endpoints.ts @@ -0,0 +1,235 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +import { getPetMock } from './model/index.faker'; + +export type getPetResponse200 = { + data: Pet; + status: 200; +}; + +export type getPetResponseSuccess = getPetResponse200 & { + headers: Headers; +}; +export type getPetResponse = getPetResponseSuccess; + +export const getGetPetUrl = () => { + return `/pet`; +}; + +export const getPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetPetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getPetResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getPetResponse; +}; + +export type createPetResponse200 = { + data: Pet; + status: 200; +}; + +export type createPetResponseSuccess = createPetResponse200 & { + headers: Headers; +}; +export type createPetResponse = createPetResponseSuccess; + +export const getCreatePetUrl = () => { + return `/pet`; +}; + +export const createPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getCreatePetUrl(), { + ...options, + method: 'POST', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: createPetResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as createPetResponse; +}; + +export type listPetsResponse200 = { + data: Pet[]; + status: 200; +}; + +export type listPetsResponseSuccess = listPetsResponse200 & { + headers: Headers; +}; +export type listPetsResponse = listPetsResponseSuccess; + +export const getListPetsUrl = () => { + return `/pets`; +}; + +export const listPets = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getListPetsUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: listPetsResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as listPetsResponse; +}; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetPetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getCreatePetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getListPetsResponseMock = (): PetMock[] => + Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => ({ ...getPetMock() })); + +export const getGetPetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetPetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; + +export const getCreatePetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.post( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getCreatePetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; + +export const getListPetsMockHandler = ( + overrideResponse?: + | Pet[] + | (( + info: Parameters[1]>[0], + ) => Promise | Pet[]), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pets', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getListPetsResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525StrictMockMultiOperationOpenAPI30Mock = () => [ + getGetPetMockHandler(), + getCreatePetMockHandler(), + getListPetsMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-multi/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525-multi/model/index.faker.ts new file mode 100644 index 0000000000..08b0dbb3f8 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/model/index.faker.ts @@ -0,0 +1,40 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import { faker } from '@faker-js/faker'; + +import type { Pet } from '.'; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getPetMock = = {}>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; diff --git a/tests/__snapshots__/mock/issue-3525-multi/model/index.ts b/tests/__snapshots__/mock/issue-3525-multi/model/index.ts new file mode 100644 index 0000000000..98091da191 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export * from './pet'; diff --git a/tests/__snapshots__/mock/issue-3525-multi/model/pet.ts b/tests/__snapshots__/mock/issue-3525-multi/model/pet.ts new file mode 100644 index 0000000000..d6978d6e77 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/model/pet.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export interface Pet { + id: number; + name: string; + /** @nullable */ + tag?: string | null; + /** @nullable */ + birthDate?: string | null; + /** @nullable */ + photoUrls?: string[] | null; +} diff --git a/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts new file mode 100644 index 0000000000..74a00f5c96 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts @@ -0,0 +1,100 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getPetResponse200 = { + data: Pet; + status: 200; +}; + +export type getPetResponseSuccess = getPetResponse200 & { + headers: Headers; +}; +export type getPetResponse = getPetResponseSuccess; + +export const getGetPetUrl = () => { + return `/pet`; +}; + +export const getPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetPetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getPetResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getPetResponse; +}; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetPetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getGetPetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetPetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525StrictMockReturnTypesOpenAPI31Mock = () => [ + getGetPetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts new file mode 100644 index 0000000000..ccd579cfa6 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts @@ -0,0 +1,40 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ +import { faker } from '@faker-js/faker'; + +import type { Pet } from '.'; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getPetMock = = {}>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/index.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/index.ts new file mode 100644 index 0000000000..d561b47063 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ + +export * from './pet'; diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/pet.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/pet.ts new file mode 100644 index 0000000000..a8c7807f52 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/pet.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ + +export interface Pet { + id: number; + name: string; + /** @nullable */ + tag?: string | null; + /** @nullable */ + birthDate?: string | null; + /** @nullable */ + photoUrls?: string[] | null; +} diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock-strict/endpoints.ts b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/endpoints.ts new file mode 100644 index 0000000000..5bce73852d --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/endpoints.ts @@ -0,0 +1,98 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ +import type { WidgetMock } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getWidgetResponse200 = { + data: WidgetMock; + status: 200; +}; + +export type getWidgetResponseSuccess = getWidgetResponse200 & { + headers: Headers; +}; +export type getWidgetResponse = getWidgetResponseSuccess; + +export const getGetWidgetUrl = () => { + return `/widget`; +}; + +export const getWidget = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetWidgetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getWidgetResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as getWidgetResponse; +}; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type WidgetMockMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetWidgetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + label: faker.string.alpha({ length: { min: 10, max: 20 } }), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getGetWidgetMockHandler = ( + overrideResponse?: + | WidgetMock + | (( + info: Parameters[1]>[0], + ) => Promise | WidgetMock), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/widget', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetWidgetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525SchemaNamedWidgetMockMock = () => [ + getGetWidgetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/index.ts b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/index.ts new file mode 100644 index 0000000000..c912ca3e86 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export * from './widgetMock'; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/widgetMock.ts b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/widgetMock.ts new file mode 100644 index 0000000000..1ec5a67752 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/widgetMock.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export interface WidgetMock { + id: number; + /** @nullable */ + label?: string | null; +} diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock/endpoints.ts b/tests/__snapshots__/mock/issue-3525-widget-mock/endpoints.ts new file mode 100644 index 0000000000..e85f5f8826 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock/endpoints.ts @@ -0,0 +1,85 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ +import type { WidgetMock } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getWidgetResponse200 = { + data: WidgetMock; + status: 200; +}; + +export type getWidgetResponseSuccess = getWidgetResponse200 & { + headers: Headers; +}; +export type getWidgetResponse = getWidgetResponseSuccess; + +export const getGetWidgetUrl = () => { + return `/widget`; +}; + +export const getWidget = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetWidgetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getWidgetResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as getWidgetResponse; +}; + +export const getGetWidgetResponseMock = ( + overrideResponse: Partial> = {}, +): WidgetMock => ({ + id: faker.number.int(), + label: faker.helpers.arrayElement([ + faker.helpers.arrayElement([ + faker.string.alpha({ length: { min: 10, max: 20 } }), + null, + ]), + undefined, + ]), + ...overrideResponse, +}); + +export const getGetWidgetMockHandler = ( + overrideResponse?: + | WidgetMock + | (( + info: Parameters[1]>[0], + ) => Promise | WidgetMock), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/widget', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetWidgetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525SchemaNamedWidgetMockMock = () => [ + getGetWidgetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock/model/index.ts b/tests/__snapshots__/mock/issue-3525-widget-mock/model/index.ts new file mode 100644 index 0000000000..c912ca3e86 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export * from './widgetMock'; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock/model/widgetMock.ts b/tests/__snapshots__/mock/issue-3525-widget-mock/model/widgetMock.ts new file mode 100644 index 0000000000..1ec5a67752 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock/model/widgetMock.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export interface WidgetMock { + id: number; + /** @nullable */ + label?: string | null; +} diff --git a/tests/__snapshots__/mock/issue-3525/endpoints.ts b/tests/__snapshots__/mock/issue-3525/endpoints.ts new file mode 100644 index 0000000000..88a44d7f0c --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/endpoints.ts @@ -0,0 +1,100 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getPetResponse200 = { + data: Pet; + status: 200; +}; + +export type getPetResponseSuccess = getPetResponse200 & { + headers: Headers; +}; +export type getPetResponse = getPetResponseSuccess; + +export const getGetPetUrl = () => { + return `/pet`; +}; + +export const getPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetPetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getPetResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getPetResponse; +}; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetPetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getGetPetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetPetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525StrictMockReturnTypesOpenAPI30Mock = () => [ + getGetPetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts new file mode 100644 index 0000000000..4315ea49e7 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts @@ -0,0 +1,40 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import { faker } from '@faker-js/faker'; + +import type { Pet } from '.'; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getPetMock = = {}>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; diff --git a/tests/__snapshots__/mock/issue-3525/model/index.ts b/tests/__snapshots__/mock/issue-3525/model/index.ts new file mode 100644 index 0000000000..ba8c3f545e --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export * from './pet'; diff --git a/tests/__snapshots__/mock/issue-3525/model/pet.ts b/tests/__snapshots__/mock/issue-3525/model/pet.ts new file mode 100644 index 0000000000..7f8f5c3a7b --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/model/pet.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export interface Pet { + id: number; + name: string; + /** @nullable */ + tag?: string | null; + /** @nullable */ + birthDate?: string | null; + /** @nullable */ + photoUrls?: string[] | null; +} diff --git a/tests/configs/mock.config.ts b/tests/configs/mock.config.ts index b63e76bf13..6460c5006d 100644 --- a/tests/configs/mock.config.ts +++ b/tests/configs/mock.config.ts @@ -516,6 +516,114 @@ export default defineConfig({ target: '../specifications/issue-3484.yaml', }, }, + issue3525: { + output: { + target: '../generated/mock/issue-3525/endpoints.ts', + schemas: '../generated/mock/issue-3525/model', + client: 'fetch', + mock: { + generators: [ + { type: 'msw' }, + { type: 'faker', schemas: true, operationResponses: true }, + ], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525.yaml', + }, + }, + issue3525Multi: { + output: { + target: '../generated/mock/issue-3525-multi/endpoints.ts', + schemas: '../generated/mock/issue-3525-multi/model', + client: 'fetch', + mock: { + generators: [ + { type: 'msw' }, + { type: 'faker', schemas: true, operationResponses: true }, + ], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-multi.yaml', + }, + }, + issue3525Oas31: { + output: { + target: '../generated/mock/issue-3525-oas31/endpoints.ts', + schemas: '../generated/mock/issue-3525-oas31/model', + client: 'fetch', + mock: { + generators: [ + { type: 'msw' }, + { type: 'faker', schemas: true, operationResponses: true }, + ], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-oas31.yaml', + }, + }, + issue3525WidgetMock: { + output: { + target: '../generated/mock/issue-3525-widget-mock/endpoints.ts', + schemas: '../generated/mock/issue-3525-widget-mock/model', + client: 'fetch', + mock: { + generators: [{ type: 'msw' }], + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-widget-mock.yaml', + }, + }, + issue3525WidgetMockStrict: { + output: { + target: '../generated/mock/issue-3525-widget-mock-strict/endpoints.ts', + schemas: '../generated/mock/issue-3525-widget-mock-strict/model', + client: 'fetch', + mock: { + generators: [{ type: 'msw' }], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-widget-mock.yaml', + }, + }, fakerArrayItems: { output: { target: '../generated/mock/faker-array-items/endpoints.ts', diff --git a/tests/specifications/issue-3525-multi.yaml b/tests/specifications/issue-3525-multi.yaml new file mode 100644 index 0000000000..b1358077d8 --- /dev/null +++ b/tests/specifications/issue-3525-multi.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + license: + name: MIT + +# Regression for duplicate strict-mock type declarations when several +# operations share one endpoints file (GET/POST Pet + GET Pet[]). + +paths: + /pet: + get: + operationId: getPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + post: + operationId: createPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + /pets: + get: + operationId: listPets + responses: + '200': + description: ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + nullable: true + birthDate: + type: string + format: date-time + nullable: true + photoUrls: + type: array + items: + type: string + nullable: true diff --git a/tests/specifications/issue-3525-oas31.yaml b/tests/specifications/issue-3525-oas31.yaml new file mode 100644 index 0000000000..0edf118fab --- /dev/null +++ b/tests/specifications/issue-3525-oas31.yaml @@ -0,0 +1,49 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: Issue 3525 - strict mock return types (OpenAPI 3.1) + license: + name: MIT + +# Same contract as issue-3525.yaml but with OpenAPI 3.1 nullable unions. + +paths: + /pet: + get: + operationId: getPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: + - string + - 'null' + birthDate: + type: + - string + - 'null' + format: date-time + photoUrls: + type: + - array + - 'null' + items: + type: string diff --git a/tests/specifications/issue-3525-widget-mock.yaml b/tests/specifications/issue-3525-widget-mock.yaml new file mode 100644 index 0000000000..31d3adf6ce --- /dev/null +++ b/tests/specifications/issue-3525-widget-mock.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Issue 3525 - schema named WidgetMock + license: + name: MIT + +# Regression spec: a schema whose name ends in "Mock" must not trigger +# strict-mock dedupe/hoisting when override.mock.required/nonNullable are off. + +paths: + /widget: + get: + operationId: getWidget + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/WidgetMock' + +components: + schemas: + WidgetMock: + type: object + required: + - id + properties: + id: + type: integer + label: + type: string + nullable: true diff --git a/tests/specifications/issue-3525.yaml b/tests/specifications/issue-3525.yaml new file mode 100644 index 0000000000..cd713d8ba4 --- /dev/null +++ b/tests/specifications/issue-3525.yaml @@ -0,0 +1,49 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Issue 3525 - strict mock return types (OpenAPI 3.0) + license: + name: MIT + +# Regression spec for https://github.com/orval-labs/orval/issues/3525 +# With override.mock.required and override.mock.nonNullable both true, +# schema and operation mock factories should return a strict PetMock alias +# while still accepting Partial overrides (including null). + +paths: + /pet: + get: + operationId: getPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + nullable: true + birthDate: + type: string + format: date-time + nullable: true + photoUrls: + type: array + items: + type: string + nullable: true