Hook
Hooks are the place that you can add your own code to the default behavior or DryerJS. Below is a example of a hook that will be called before a new Tag is created. If the tag already exists, it will throw an error.
@Definition()
export class Tag {
@Id()
id: ObjectId;
@Property()
name: string;
}
@Injectable()
class TagHook {
constructor(@InjectBaseService(Tag) public tagService: BaseService<Tag, Context>) {}
@BeforeCreateHook(() => Tag)
async throwErrorIfNameAlreadyExists({ input }: BeforeCreateHookInput<Tag, Context>) {
const existingTag = await this.tagService.model.findOne({ name: input.name });
if (existingTag) {
throw new Error(`Tag with name ${input.name} already exists`);
}
}
}
@Module({
imports: [
// other imports
DryerModule.register({ definitions: [Tag], hooks: [TagHook] }),
],
})
export class AppModule {}
List of hooks
Below is the list of hooks that you can use.
BeforeCreateHook
AfterCreateHook
BeforeUpdateHook
AfterUpdateHook
BeforeRemoveHook
AfterRemoveHook
BeforeFindOneHook
AfterFindOneHook
BeforeFindManyHook
AfterFindManyHook
BeforeReadFilterHook
BeforeWriteFilterHook
Below is the types of hooks input.
type AfterRemoveHookInput<T = any, Context = any> = {
ctx: Context;
removed: T;
definition: Definition;
options: RemoveOptions;
};
type BeforeRemoveHookInput<T = any, Context = any> = {
ctx: Context;
beforeRemoved: T;
definition: Definition;
options: RemoveOptions;
};
type AfterFindOneHookInput<T = any, Context = any> = {
ctx: Context;
filter: FilterQuery<T>;
result: T;
definition: Definition;
};
type BeforeFindOneHookInput<T = any, Context = any> = {
ctx: Context;
filter: FilterQuery<T>;
definition: Definition;
};
type AfterUpdateHookInput<T = any, Context = any> = {
ctx: Context;
input: Partial<T>;
updated: T;
beforeUpdated: T;
definition: Definition;
};
type BeforeUpdateHookInput<T = any, Context = any> = {
ctx: Context;
input: Partial<T>;
beforeUpdated: T;
definition: Definition;
};
type AfterCreateHookInput<T = any, Context = any> = {
ctx: Context;
input: Partial<T>;
created: T;
definition: Definition;
};
type BeforeCreateHookInput<T = any, Context = any> = {
ctx: Context;
input: Partial<T>;
definition: Definition;
};
type BeforeFindManyHookInput<T = any, Context = any> = {
ctx: Context;
filter: FilterQuery<T>;
sort: object;
limit?: number;
page?: number;
definition: Definition;
};
type AfterFindManyHookInput<T = any, Context = any> = {
ctx: Context;
filter: FilterQuery<T>;
sort: object;
items: T[];
limit?: number;
page?: number;
definition: Definition;
};
type BeforeReadFilterHookInput<T = any, Context = any> = {
ctx: Context;
filter: FilterQuery<T>;
definition: Definition;
};
type BeforeWriteFilterHookInput<T = any, Context = any> = {
ctx: Context;
filter: FilterQuery<T>;
definition: Definition;
};
API update
and remove
use findOne
under the hood so you might feel like beforeFindOne
and afterFindOne
are applied for update
and remove
too.
If your context
cannot findOne
a document, you will not be able to update
and remove
it.
Special hooks
beforeReadFilter
beforeReadFilter
will be called before findMany
and findOne
, paginate
. This hook is called before beforeFindOne
and beforeFindMany
.
beforeWriteFilter
beforeWriteFilter
will be called before update
, remove
. This hook is called before beforeUpdate
, beforeRemove
.
beforeFindMany
beforeFindMany
will be called before findMany
and paginate
and after beforeReadFilter
.
Customize Context
There is a ctx
in every hook. By default, the value is an object which looks like below.
{ req: express.Request }
How NestJS passing context
You can skip this sub-section if you are familiar with NestJS
In NestJS world, it's common practice to attach properties to the request
.
In side your Guard
, you can add user
to req
,
and then create a custom parameter to use it in controllers or resolvers:
@Injectable()
export class MustBeUser implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const { req } = GqlExecutionContext.create(context).getContext();
// getUserFromReq is a function that will get user from req
// commonly it will parse a JWT token from header
const user = await getUserFromReq(req);
req.user = user;
}
}
export const Ctx = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
return GqlExecutionContext.create(ctx).getContext().req.user || null;
});
@Resolver(() => Tag)
export class TagResolver {
@Query(() => Tag)
async allTags(@Ctx() ctx: User) {
return this.tagService.findMany({ userId: ctx.id });
}
}
Usage on DryerJS
You can set the parameter decorator that returns the context object when calling DryerModule.register
.
export const Ctx = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
return GqlExecutionContext.create(ctx).getContext().req.user || null;
});
// app.module.ts
@Module({
imports: [
// other imports
DryerModule.register({
definitions: [Tag, User],
contextDecorator: Ctx,
}),
],
})
export class AppModule {}
Possible use cases
beforeCreate & beforeUpdate
- Add custom input validation (e.g. check if email is used so we can throw a better error)
- Modify the input based on context (e.g. add
userId
from context to input so that the user does not need to input it)
beforeFindOne & beforeFindMany
- Validate filter (e.g. check if the user has permission to see the data)
- Modify filter based on context (e.g. add
userId
from context to filter so that the user can only see their own data)
afterFindOne & afterFindMany
- Validate the returned data (e.g. check if the user has permission to see the data)
- Modify the returned data based on context (e.g. remove some fields that the user does not have permission to see)
afterCreate & afterUpdate & afterRemove
- Emit events (e.g. send emails to users after they register)
beforeReadFilter
- Same as
beforeFindOne
&beforeFindMany
but will be called beforebeforeFindOne
&beforeFindMany
. This is helpful when you do not want to duplicate your code inbeforeFindOne
&beforeFindMany
.
beforeWriteFilter
- Modify filter based on context on
update
andremove
(e.g. adduserId
from context to filter so that the user can only update or remove their own data)
Priority
If multiple hooks are defined for the same method, they will be sorted by priority.
If you do not specify the priority, it will be set to 100
.
@Injectable()
export class ProductHook {
@BeforeCreateHook(() => Product, { priority: 1 })
async assignUserIdIfNotMentioned({
input,
ctx,
}: BeforeCreateHookInput<Product, Context>) {
// assign userId if not provided
if (_.isNil(input.userId)) {
input.userId = ctx.user.id;
}
}
@BeforeCreateHook(() => Product, { priority: 2 })
async ensureNormalUserNotCreateProductForOtherUsers({
input,
ctx,
}: BeforeCreateHookInput<Product, Context>) {
if (ctx.user.role === UserRole.ADMIN) return;
// only admin can create product for other user
if (ctx.user.id.toString() === input.userId.toString()) return;
throw new UnauthorizedException('USER_ID_NOT_MATCH');
}
}
On the above example, assignUserIdIfNotMentioned
will be called before ensureNormalUserNotCreateProductForOtherUsers
.
General Hook
You can also create a general hook that will be called for all models.
class GeneralHook implements Hook {
@BeforeCreateHook(() => AllDefinitions)
async logOnCreate({ input }: BeforeCreateHookInput) {
console.log(`Before create ${definition.name}`);
}
}
AllDefinitions
is a special value that you can use to indicate that the hook will be called for all models which can be imported from 'dryerjs'
.
Default hook
DryerJS has a default hook that will be called for all models.
It is mostly used for checking relations before write operations.
It also removes related documents when remove mode is CleanUpRelationsAfterRemoved
. See Remove Mode for more details.
You can skip the default hook by setting skipDefaultHookMethods
in @Definition
decorator.
@Definition({
skipDefaultHookMethods: [
'beforeCreate',
'beforeUpdate',
'beforeRemove',
],
})
class Tag {}
If you want to ignore default hook completely, you can set { skipDefaultHookMethods: ['all'] }