Skip to content

Commit

Permalink
feat(adapters.nestjs): support undefined dependency reflection
Browse files Browse the repository at this point in the history
  • Loading branch information
omermorad committed Aug 4, 2023
1 parent 681f670 commit a9ccaa6
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 55 deletions.
36 changes: 36 additions & 0 deletions packages/adapters/nestjs/__test__/integration.assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[];

Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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)
);

Expand All @@ -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],
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
});
});
});
45 changes: 27 additions & 18 deletions packages/adapters/nestjs/src/class-ctor-reflector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
36 changes: 20 additions & 16 deletions packages/adapters/nestjs/src/class-props-reflector.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ClassPropsReflector>;

export function ClassPropsReflector(reflector: MetadataReflector) {
export function ClassPropsReflector(
reflector: MetadataReflector,
reflectionStrategies: ReadonlyArray<PropertyReflectionStrategy>
) {
function reflectInjectables(targetClass: Type): ClassPropsInjectables {
const classProperties = reflectProperties(targetClass)(reflector);
const classInstance = Object.create(targetClass.prototype);
Expand All @@ -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,
};
});
}
Expand Down
6 changes: 5 additions & 1 deletion packages/adapters/nestjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
49 changes: 35 additions & 14 deletions packages/adapters/nestjs/src/params-token-resolver.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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];
};
}

Expand Down
Loading

0 comments on commit a9ccaa6

Please sign in to comment.