diff --git a/packages/adapters/nestjs/__test__/integration.assets.ts b/packages/adapters/nestjs/__test__/integration.assets.ts index 6719fc2a..cb9be063 100644 --- a/packages/adapters/nestjs/__test__/integration.assets.ts +++ b/packages/adapters/nestjs/__test__/integration.assets.ts @@ -28,13 +28,29 @@ export interface DependencyFourTokenInterface { print(): string; } +export class DependencyFive { + print(): string { + return 'dependencyFive'; + } +} + +export class DependencySix { + print(): string { + return 'dependencySix'; + } +} + @Injectable() export class ConstructorBasedInjectionClass { public constructor( private readonly dependencyOne: DependencyOne, private readonly dependencyTwo: DependencyTwo, @Inject(forwardRef(() => DependencyThree)) private readonly dependencyThree: DependencyThree, + @Inject(forwardRef(() => 'SOME_TOKEN_FROM_REF')) + private readonly dependencyFive: DependencyFive, + @Inject(forwardRef(() => DependencySix)) private readonly dependencySix: undefined, @Inject('CUSTOM_TOKEN') private readonly dependencyFour: DependencyFourTokenInterface, + @Inject('CUSTOM_TOKEN_SECOND') private readonly dependencyMissingWithToken: undefined, @Inject('ANOTHER_CUSTOM_TOKEN') private readonly dummy: DummyType, @Inject('LITERAL_VALUE_ARR') private readonly literalValueArray: string[], @Inject('LITERAL_VALUE_STR') private readonly literalValueString: string @@ -52,9 +68,18 @@ export class PropsBasedMainClass { @Inject(forwardRef(() => DependencyThree)) private readonly dependencyThree: DependencyThree; + @Inject(forwardRef(() => DependencySix)) + private readonly dependencySix: undefined; + @Inject('CUSTOM_TOKEN') public readonly dependencyFour: DependencyFourTokenInterface; + @Inject(DependencyFive) + public readonly dependencyMissingWithToken: undefined; + + @Inject('CUSTOM_TOKEN_SECOND') + public readonly dependencyUndefinedWithToken: undefined; + @Inject('LITERAL_VALUE_ARR') private readonly literalValueArray: string[]; @@ -78,3 +103,14 @@ export class ConstructorCombinedWithPropsClass { private readonly dependencyTwo: DependencyTwo ) {} } + +@Injectable() +export class ClassWithUndefinedDependency { + public constructor(private readonly dependency: undefined) {} +} + +@Injectable() +export class ClassWithUndefinedDependencyProps { + @Inject(undefined) + private readonly dependency: undefined; +} diff --git a/packages/adapters/nestjs/__test__/nestjs-adapter.integration.test.ts b/packages/adapters/nestjs/__test__/nestjs-adapter.integration.test.ts index 76fe8c29..8e445c68 100644 --- a/packages/adapters/nestjs/__test__/nestjs-adapter.integration.test.ts +++ b/packages/adapters/nestjs/__test__/nestjs-adapter.integration.test.ts @@ -1,24 +1,32 @@ import { + ClassWithUndefinedDependency, + ClassWithUndefinedDependencyProps, + ClassWithUndefinedRefDependency, + ConstructorBasedInjectionClass, + ConstructorCombinedWithPropsClass, + DependencyFive, DependencyOne, + DependencySix, DependencyThree, DependencyTwo, - ConstructorBasedInjectionClass, PropsBasedMainClass, - ConstructorCombinedWithPropsClass, } from './integration.assets'; import { ClassCtorInjectables, ClassDependenciesMap, ClassPropsInjectables, + UndefinedDependency, } from '@automock/common'; import { ParamsTokensReflector } from '../src/params-token-resolver'; import { ReflectorFactory } from '../src/class-reflector'; import { ClassPropsReflector } from '../src/class-props-reflector'; import { ClassCtorReflector } from '../src/class-ctor-reflector'; +import { Type } from '@automock/types'; +import { PropertyReflectionStrategies } from '../src/property-reflection-strategies.static'; describe('NestJS Automock Adapter Integration Test', () => { const reflectorFactory = ReflectorFactory( - ClassPropsReflector(Reflect), + ClassPropsReflector(Reflect, PropertyReflectionStrategies), ClassCtorReflector(Reflect, ParamsTokensReflector) ); @@ -30,7 +38,10 @@ describe('NestJS Automock Adapter Integration Test', () => { [DependencyOne, DependencyOne], [DependencyTwo, DependencyTwo], [DependencyThree, DependencyThree], + ['SOME_TOKEN_FROM_REF', DependencyFive], + [DependencySix, UndefinedDependency], ['CUSTOM_TOKEN', Object], + ['CUSTOM_TOKEN_SECOND', UndefinedDependency], ['ANOTHER_CUSTOM_TOKEN', String], ['LITERAL_VALUE_ARR', Array], ['LITERAL_VALUE_STR', String], @@ -58,11 +69,26 @@ describe('NestJS Automock Adapter Integration Test', () => { typeOrToken: DependencyThree, value: DependencyThree, }, + { + property: 'dependencySix', + typeOrToken: DependencySix, + value: UndefinedDependency, + }, { property: 'dependencyFour', typeOrToken: 'CUSTOM_TOKEN', value: Object, }, + { + property: 'dependencyMissingWithToken', + typeOrToken: DependencyFive, + value: DependencyFive, + }, + { + property: 'dependencyUndefinedWithToken', + typeOrToken: 'CUSTOM_TOKEN_SECOND', + value: UndefinedDependency, + }, { property: 'literalValueArray', typeOrToken: 'LITERAL_VALUE_ARR', @@ -108,4 +134,13 @@ describe('NestJS Automock Adapter Integration Test', () => { }); }); }); + + describe('reflecting classes with undefined constructor dependencies', () => { + it.each([ + [ClassWithUndefinedDependency], + [ClassWithUndefinedDependencyProps], + ])('should fail with an error indicating that the dependency is not defined', (type: Type) => { + expect(() => reflectorFactory.reflectDependencies(type)).toThrow(); + }); + }); }); diff --git a/packages/adapters/nestjs/src/class-ctor-reflector.ts b/packages/adapters/nestjs/src/class-ctor-reflector.ts index 64f79b45..f52b3ba1 100644 --- a/packages/adapters/nestjs/src/class-ctor-reflector.ts +++ b/packages/adapters/nestjs/src/class-ctor-reflector.ts @@ -11,32 +11,41 @@ export function ClassCtorReflector( paramsTokensReflector: ParamsTokensReflector ) { function reflectInjectables(targetClass: Type): ClassCtorInjectables { - const paramTypes = reflectParamTypes(targetClass)(reflector); - const paramTokens = reflectParamTokens(targetClass)(reflector); - + const paramTypes = reflectParamTypes(targetClass); + const paramTokens = reflectParamTokens(targetClass); + const tokensIndexes = paramTokens.map(({ index }) => index); const resolveParamTokenCb = paramsTokensReflector.resolveDependencyValue(paramTokens); - return paramTypes.map((typeOrUndefined, index) => { - try { - return resolveParamTokenCb(typeOrUndefined, index); - } catch (error) { - throw new Error( - `'${targetClass.name}' is missing a token for the dependency at index [${index}], did you forget to inject it using @Inject()?` - ); + return paramTypes.map((typeOrUndefined, paramIndex) => { + const isToken = tokensIndexes.includes(paramIndex); + const error = + new Error(`Automock encountered an error while attempting to detect a token or type for the dependency at index [${paramIndex}] in the class '${targetClass.name}'. + This issue is commonly caused by either improper parameter decoration or a problem during the reflection of the parameter type. + In some cases, this error may arise due to circular dependencies. If this is the case, please ensure that the circular dependency + is resolved, or consider using 'forwardRef()' to address it.`); + + if (isToken) { + try { + return resolveParamTokenCb(typeOrUndefined, paramIndex); + } catch (error) { + throw error; + } } + + if (!typeOrUndefined) { + throw error; + } + + return [typeOrUndefined, typeOrUndefined] as [Type, Type]; }); } - function reflectParamTokens(targetClass: Type): (reflector: MetadataReflector) => CustomToken[] { - return (reflector: MetadataReflector) => - reflector.getMetadata(SELF_DECLARED_DEPS_METADATA, targetClass) || []; + function reflectParamTokens(targetClass: Type): CustomToken[] { + return reflector.getMetadata(SELF_DECLARED_DEPS_METADATA, targetClass) || []; } - function reflectParamTypes( - targetClass: Type - ): (reflector: MetadataReflector) => (NestJSInjectable | undefined)[] { - return (reflector: MetadataReflector) => - reflector.getMetadata(PARAMTYPES_METADATA, targetClass) || []; + function reflectParamTypes(targetClass: Type): (NestJSInjectable | undefined)[] { + return reflector.getMetadata(PARAMTYPES_METADATA, targetClass) || []; } return { reflectInjectables }; diff --git a/packages/adapters/nestjs/src/class-props-reflector.ts b/packages/adapters/nestjs/src/class-props-reflector.ts index c74d8e34..c060d9e4 100644 --- a/packages/adapters/nestjs/src/class-props-reflector.ts +++ b/packages/adapters/nestjs/src/class-props-reflector.ts @@ -1,11 +1,15 @@ -import { MetadataReflector, NestJSInjectable, ReflectedProperty } from './types'; -import { ClassPropsInjectables, PrimitiveValue } from '@automock/common'; +import { ClassPropsInjectables } from '@automock/common'; import { Type } from '@automock/types'; import { PROPERTY_DEPS_METADATA } from '@nestjs/common/constants'; +import { MetadataReflector, NestJSInjectable, ReflectedProperty } from './types'; +import { PropertyReflectionStrategy } from './property-reflection-strategies.static'; export type ClassPropsReflector = ReturnType; -export function ClassPropsReflector(reflector: MetadataReflector) { +export function ClassPropsReflector( + reflector: MetadataReflector, + reflectionStrategies: ReadonlyArray +) { function reflectInjectables(targetClass: Type): ClassPropsInjectables { const classProperties = reflectProperties(targetClass)(reflector); const classInstance = Object.create(targetClass.prototype); @@ -17,25 +21,25 @@ export function ClassPropsReflector(reflector: MetadataReflector) { key ) as NestJSInjectable; - if (!reflectedType) { - throw new Error( - `Automock has failed to reflect '${targetClass.name}.${key}' property, did you forget to inject it using @Inject()?` - ); + if (!reflectedType && !type) { + throw new Error(` + Automock encountered an error while attempting to detect a token or type for the dependency for property '${key}' in the class '${targetClass.name}'. + This issue is commonly caused by either improper decoration of the property or a problem during the reflection of the parameter type. + In some cases, this error may arise due to circular dependencies. If this is the case, please ensure that the circular dependency + is resolved, or consider using 'forwardRef()' to address it.`); } - let value; - - if (typeof type === 'object' && 'forwardRef' in type) { - value = type.forwardRef(); - } else { - value = type; + for (const strategy of reflectionStrategies) { + if (strategy.condition(reflectedType, type)) { + const result = strategy.exec(reflectedType, type); + return { ...result, property: key }; + } } return { property: key, - typeOrToken: value as string | Type, - value: - typeof type === 'string' ? (reflectedType as Type) : (value as PrimitiveValue | Type), + typeOrToken: type as Type, + value: type as Type, }; }); } diff --git a/packages/adapters/nestjs/src/index.ts b/packages/adapters/nestjs/src/index.ts index 2e1a5e7f..7c1dab8c 100644 --- a/packages/adapters/nestjs/src/index.ts +++ b/packages/adapters/nestjs/src/index.ts @@ -4,12 +4,16 @@ import { ReflectorFactory } from './class-reflector'; import { ClassPropsReflector } from './class-props-reflector'; import { ClassCtorReflector } from './class-ctor-reflector'; import { ParamsTokensReflector } from './params-token-resolver'; +import { PropertyReflectionStrategies } from './property-reflection-strategies.static'; const DependenciesReflector: DependenciesReflector = (( classPropsReflector: ClassPropsReflector, classCtorReflector: ClassCtorReflector ) => { return ReflectorFactory(classPropsReflector, classCtorReflector); -})(ClassPropsReflector(Reflect), ClassCtorReflector(Reflect, ParamsTokensReflector)); +})( + ClassPropsReflector(Reflect, PropertyReflectionStrategies), + ClassCtorReflector(Reflect, ParamsTokensReflector) +); export = DependenciesReflector; diff --git a/packages/adapters/nestjs/src/params-token-resolver.ts b/packages/adapters/nestjs/src/params-token-resolver.ts index 87de5282..e9523e29 100644 --- a/packages/adapters/nestjs/src/params-token-resolver.ts +++ b/packages/adapters/nestjs/src/params-token-resolver.ts @@ -1,5 +1,6 @@ import { Type } from '@automock/types'; -import { NestJSInjectable, CustomInjectableToken } from './types'; +import { UndefinedDependency, UndefinedDependencySymbol } from '@automock/common'; +import { CustomInjectableToken, NestJSInjectable } from './types'; export interface CustomToken { index: number; @@ -9,7 +10,10 @@ export interface CustomToken { export type ParamsTokensReflector = { resolveDependencyValue( tokens: CustomToken[] - ): (typeOrUndefined: NestJSInjectable | undefined, index: number) => [string | Type, Type]; + ): ( + typeOrUndefined: NestJSInjectable | undefined, + index: number + ) => [string | Type, Type | UndefinedDependencySymbol]; }; export const ParamsTokensReflector = (function (): ParamsTokensReflector { @@ -18,28 +22,45 @@ export const ParamsTokensReflector = (function (): ParamsTokensReflector { return record?.param; } - function resolveReferenceCallbackFromToken(token: CustomInjectableToken | Type): Type | string { + function resolveReferenceCallbackFromToken( + token: CustomInjectableToken | Type + ): Type | string | undefined { return typeof token === 'object' && 'forwardRef' in token ? token.forwardRef() : token; } function resolveDependencyValue( tokens: CustomToken[] - ): (typeOrUndefined: NestJSInjectable | undefined, index: number) => [string | Type, Type] { - return (dependencyType: Type, index: number): [string | Type, Type] => { + ): ( + typeOrUndefined: NestJSInjectable, + tokenIndexInCtor: number + ) => [string | Type, Type | UndefinedDependencySymbol] { + return ( + dependencyType: Type, + index: number + ): [string | Type, Type | UndefinedDependencySymbol] => { const token = lookupTokenInParams(tokens, index); - const isAnonymousObjectType = dependencyType && (dependencyType as Type).name === 'Object'; - if (token) { - const ref = resolveReferenceCallbackFromToken(token); + if (!token) { + throw new Error(`No token found at index: ${index}`); + } + + const ref = resolveReferenceCallbackFromToken(token); + const refIsAType = typeof ref !== 'string'; + + if (refIsAType) { + return [ + ref as Type, + typeof dependencyType === 'undefined' ? UndefinedDependency : (ref as Type), + ]; + } - if (dependencyType) { - return [ref, dependencyType]; - } - } else if (dependencyType && !isAnonymousObjectType) { - return [dependencyType, dependencyType]; + if (!dependencyType && typeof token === 'string') { + return [token, UndefinedDependency]; + } else if (!dependencyType && typeof ref !== 'string') { + return [ref, UndefinedDependency]; } - throw new Error(`No token found at index: ${index}`); + return [ref, dependencyType]; }; } diff --git a/packages/adapters/nestjs/src/property-reflection-strategies.static.ts b/packages/adapters/nestjs/src/property-reflection-strategies.static.ts new file mode 100644 index 00000000..bffc5384 --- /dev/null +++ b/packages/adapters/nestjs/src/property-reflection-strategies.static.ts @@ -0,0 +1,81 @@ +import { Type } from '@automock/types'; +import { UndefinedDependency, UndefinedDependencySymbol } from '@automock/common'; +import { ForwardRefToken, NestJSInjectable } from './types'; + +export interface StrategyReturnType { + typeOrToken: string | Type; + value: Type | UndefinedDependencySymbol; +} + +export interface PropertyReflectionStrategy { + condition: (reflectedType: NestJSInjectable, type: NestJSInjectable) => boolean; + exec: (reflectedType: NestJSInjectable, type: NestJSInjectable) => StrategyReturnType; +} + +export const PropertyReflectionStrategies: ReadonlyArray = [ + { + condition: (reflectedType: NestJSInjectable, type: NestJSInjectable): boolean => { + return typeof type === 'object' && 'forwardRef' in type; + }, + exec: (reflectedType: NestJSInjectable, type: NestJSInjectable) => { + const forwardRefToken: string | Type | undefined = (type as ForwardRefToken).forwardRef(); + + if (typeof forwardRefToken === 'undefined') { + throw new Error('Token is undefined'); + } + + if (typeof forwardRefToken === 'string') { + return { + typeOrToken: forwardRefToken as string, + value: typeof type === 'undefined' ? UndefinedDependency : (type as Type), + }; + } + + return { + typeOrToken: forwardRefToken as Type, + value: typeof reflectedType === 'undefined' ? UndefinedDependency : forwardRefToken, + }; + }, + }, + { + condition: (reflectedType: undefined, type: NestJSInjectable): boolean => { + return !reflectedType && typeof type === 'string'; + }, + exec: (reflectedType: NestJSInjectable, type: string) => { + return { + typeOrToken: type, + value: UndefinedDependency, + }; + }, + }, + { + condition: (reflectedType: undefined, type: Type): boolean => { + return !reflectedType && typeof type !== 'string'; + }, + exec: (reflectedType: NestJSInjectable, type: Type) => { + return { + typeOrToken: type, + value: !type ? UndefinedDependency : type, + }; + }, + }, + { + condition: (reflectedType: Type, type: string): boolean => { + return typeof type === 'string'; + }, + exec: (reflectedType: Type, type: string) => { + return { + typeOrToken: type, + value: reflectedType, + }; + }, + }, + { + condition: (reflectedType: undefined, type: undefined): boolean => { + return !reflectedType && !type; + }, + exec: () => { + throw new Error(`Failed`); + }, + }, +]; diff --git a/packages/adapters/nestjs/src/types.ts b/packages/adapters/nestjs/src/types.ts index 1eea68ce..48538f6f 100644 --- a/packages/adapters/nestjs/src/types.ts +++ b/packages/adapters/nestjs/src/types.ts @@ -1,8 +1,8 @@ import { Type } from '@automock/types'; -export type ForwardRefToken = { forwardRef: () => Type }; -export type CustomInjectableToken = ForwardRefToken | string; -export type NestJSInjectable = Type | CustomInjectableToken; +export type ForwardRefToken = { forwardRef: () => Type | string | undefined }; +export type CustomInjectableToken = ForwardRefToken | string | Type; +export type NestJSInjectable = Type | CustomInjectableToken | undefined; export type MetadataReflector = typeof Reflect;