Skip to content

Commit

Permalink
fix(graphql,hooks,#957): Fix HookInterceptor not working with custom …
Browse files Browse the repository at this point in the history
…resolvers
  • Loading branch information
doug-martin committed Mar 17, 2021
1 parent d99d2b4 commit c947b3a
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 41 deletions.
29 changes: 21 additions & 8 deletions documentation/docs/graphql/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -413,46 +413,59 @@ import { TodoItemUpdateDTO } from './dto/todo-item-update.dto';

// create the base input type
@InputType()
export class UpdateManyTodoItemsInput extends UpdateManyInputType(TodoItemDTO, TodoItemUpdateDTO) {}
export class MarkTodoItemsAsCompletedInput extends UpdateManyInputType(TodoItemDTO, TodoItemUpdateDTO) {}

// Wrap the input in the MutationArgsType to provide a uniform format for all mutations
// The `MutationArgsType` is a thin wrapper that names the args as input
@ArgsType()
export class UpdateManyTodoItemsArgs extends MutationArgsType(UpdateManyTodoItemsInput) {}
export class MarkTodoItemsAsCompletedArgs extends MutationArgsType(UpdateManyTodoItemsInput) {}
```
Now we can use our new types in the resolver.

```ts title="todo-item/todo-item.resolver.ts" {14,15}
```ts title="todo-item/todo-item.resolver.ts" {16,17}
import { InjectQueryService, mergeFilter, QueryService, UpdateManyResponse } from '@nestjs-query/core';
import { HookTypes, HookInterceptor, MutationHookArgs, UpdateManyResponseType } from '@nestjs-query/query-graphql';
import { UseInterceptors } from '@nestjs/common';
import { Mutation, Resolver } from '@nestjs/graphql';
import { TodoItemDTO } from './dto/todo-item.dto';
import { TodoItemEntity } from './todo-item.entity';
import { UpdateManyTodoItemsArgs } from './types';
import { MarkTodoItemsAsCompletedArgs } from './types';
import { TodoItemUpdateDTO } from './dto/todo-item-update.dto';

@Resolver(() => TodoItemDTO)
export class TodoItemResolver {
constructor(@InjectQueryService(TodoItemEntity) readonly service: QueryService<TodoItemDTO>) {}

// Set the return type to the TodoItemConnection
@Mutation(() => UpdateManyResponseType())
@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO))
markTodoItemsAsCompleted(@MutationHookArgs() { input }: UpdateManyTodoItemsArgs): Promise<UpdateManyResponse> {
@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemUpdateDTO))
markTodoItemsAsCompleted(@MutationHookArgs() { input }: MarkTodoItemsAsCompletedArgs): Promise<UpdateManyResponse> {
return this.service.updateMany(
{ ...input.update, completed: false },
{ ...input.update, completed: true },
mergeFilter(input.filter, { completed: { is: false } }),
);
}
}

```
The first thing to notice is the

```ts
@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO))
@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemUpdateDTO))
```

This interceptor adds the correct hook to the `context` to be used by the param decorator.

There are a few things to take note of:
* The `HookTypes.BEFORE_UPDATE_MANY` lets the interceptor know we are wanting the BeforeUpdateMany hook to be used
for this mutation.
* We use the `TodoItemUpdateDTO`, that is because the `@BeforeUpdateMany` decorator was put on the
`TodoItemUpdateDTO` not the `TodoItemDTO`.

:::warning
When using the HookInterceptor you must use the DTO that you added the hook decorator to.
:::

:::note
In this example we bind the `BEFORE_UPDATE_MANY` hook, you can use any of the hooks available to bind to the correct
one when `creating`, `updating`, or `deleting` records.
Expand Down
52 changes: 52 additions & 0 deletions examples/hooks/e2e/todo-item.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,58 @@ describe('TodoItemResolver (hooks - e2e)', () => {
}));
});

describe('markAllAsCompleted', () => {
it('should call the beforeUpdateMany hook when marking all items as completed', async () => {
const queryService = app.get<QueryService<TodoItemEntity>>(getQueryServiceToken(TodoItemEntity));
const todoItems = await queryService.createMany([
{ title: 'To Be Marked As Completed - 1', completed: false },
{ title: 'To Be Marked As Completed - 2', completed: false },
]);
expect(todoItems).toHaveLength(2);
const ids = todoItems.map((ti) => ti.id);
return request(app.getHttpServer())
.post('/graphql')
.set({
[AUTH_HEADER_NAME]: config.auth.header,
[USER_HEADER_NAME]: 'e2e',
})
.send({
operationName: null,
variables: {},
query: `mutation {
markTodoItemsAsCompleted(
input: {
filter: {id: { in: [${ids.join(',')}]} },
update: { }
}
) {
updatedCount
}
}`,
})
.expect(200, {
data: {
markTodoItemsAsCompleted: {
updatedCount: 2,
},
},
})
.then(async () => {
const updatedTodoItems = await queryService.query({ filter: { id: { in: ids } } });
expect(
updatedTodoItems.map((ti) => ({
title: ti.title,
completed: ti.completed,
updatedBy: ti.updatedBy,
})),
).toEqual([
{ title: 'To Be Marked As Completed - 1', completed: true, updatedBy: '[email protected]' },
{ title: 'To Be Marked As Completed - 2', completed: true, updatedBy: '[email protected]' },
]);
});
});
});

afterAll(async () => {
await app.close();
});
Expand Down
2 changes: 2 additions & 0 deletions examples/hooks/src/todo-item/todo-item.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { TodoItemInputDTO } from './dto/todo-item-input.dto';
import { TodoItemUpdateDTO } from './dto/todo-item-update.dto';
import { TodoItemDTO } from './dto/todo-item.dto';
import { TodoItemEntity } from './todo-item.entity';
import { TodoItemResolver } from './todo-item.resolver';

const guards = [AuthGuard];
@Module({
providers: [TodoItemResolver],
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity]), AuthModule],
Expand Down
13 changes: 8 additions & 5 deletions examples/hooks/src/todo-item/todo-item.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { InjectQueryService, mergeFilter, QueryService, UpdateManyResponse } from '@nestjs-query/core';
import { HookTypes, HookInterceptor, MutationHookArgs, UpdateManyResponseType } from '@nestjs-query/query-graphql';
import { UseInterceptors } from '@nestjs/common';
import { UseInterceptors, UseGuards } from '@nestjs/common';
import { Mutation, Resolver } from '@nestjs/graphql';
import { TodoItemDTO } from './dto/todo-item.dto';
import { TodoItemEntity } from './todo-item.entity';
import { UpdateManyTodoItemsArgs } from './types';
import { MarkTodoItemsAsCompletedArgs } from './types';
import { AuthGuard } from '../auth/auth.guard';
import { TodoItemUpdateDTO } from './dto/todo-item-update.dto';

@Resolver(() => TodoItemDTO)
export class TodoItemResolver {
constructor(@InjectQueryService(TodoItemEntity) readonly service: QueryService<TodoItemDTO>) {}

// Set the return type to the TodoItemConnection
@Mutation(() => UpdateManyResponseType())
@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO))
markTodoItemsAsCompleted(@MutationHookArgs() { input }: UpdateManyTodoItemsArgs): Promise<UpdateManyResponse> {
@UseGuards(AuthGuard)
@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemUpdateDTO))
markTodoItemsAsCompleted(@MutationHookArgs() { input }: MarkTodoItemsAsCompletedArgs): Promise<UpdateManyResponse> {
return this.service.updateMany(
{ ...input.update, completed: false },
{ ...input.update, completed: true },
mergeFilter(input.filter, { completed: { is: false } }),
);
}
Expand Down
9 changes: 6 additions & 3 deletions examples/hooks/src/todo-item/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { MutationArgsType, UpdateManyInputType } from '@nestjs-query/query-graphql';
import { ArgsType, InputType } from '@nestjs/graphql';
import { ArgsType, InputType, OmitType } from '@nestjs/graphql';
import { TodoItemDTO } from './dto/todo-item.dto';
import { TodoItemUpdateDTO } from './dto/todo-item-update.dto';

@InputType()
class UpdateManyTodoItemsInput extends UpdateManyInputType(TodoItemDTO, TodoItemUpdateDTO) {}
class MarkTodoItemAsCompleted extends OmitType(TodoItemUpdateDTO, ['completed']) {}

@InputType()
class MarkTodoItemsAsCompletedInput extends UpdateManyInputType(TodoItemDTO, MarkTodoItemAsCompleted) {}

@ArgsType()
export class UpdateManyTodoItemsArgs extends MutationArgsType(UpdateManyTodoItemsInput) {}
export class MarkTodoItemsAsCompletedArgs extends MutationArgsType(MarkTodoItemsAsCompletedInput) {}
40 changes: 15 additions & 25 deletions packages/query-graphql/src/interceptors/hook.interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,33 @@
import { Class } from '@nestjs-query/core';
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { getHookForType } from '../decorators';
import { HookTypes, Hook, getHookToken } from '../hooks';

export type HookContext<H extends Hook<unknown>> = { hook?: H };

export function HookInterceptor(type: HookTypes, ...DTOClasses: Class<unknown>[]): Class<NestInterceptor> {
const tokens = DTOClasses.map((Cls) => getHookToken(type, Cls));
class DefaultHookInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle();
}
}

export function HookInterceptor(type: HookTypes, ...DTOClasses: Class<unknown>[]): Class<NestInterceptor> {
const HookedClass = DTOClasses.find((Cls) => getHookForType(type, Cls));
if (!HookedClass) {
return DefaultHookInterceptor;
}
const hookToken = getHookToken(type, HookedClass);
@Injectable()
class Interceptor implements NestInterceptor {
private hook?: Hook<unknown>;

constructor(private readonly moduleRef: ModuleRef) {
this.hook = this.lookupHook();
}
constructor(@Inject(hookToken) readonly hook: Hook<typeof HookedClass>) {}

intercept(context: ExecutionContext, next: CallHandler) {
const gqlContext = GqlExecutionContext.create(context);
const ctx = gqlContext.getContext<HookContext<Hook<unknown>>>();
if (this.hook) {
ctx.hook = this.hook;
}
ctx.hook = this.hook;
return next.handle();
}

private lookupHook(): Hook<unknown> | undefined {
return tokens.reduce((h: Hook<unknown> | undefined, hookToken) => {
if (h) {
return h;
}
try {
return this.moduleRef.get<Hook<unknown>>(hookToken);
} catch {
return undefined;
}
}, undefined);
}
}
Object.defineProperty(Interceptor, 'name', {
writable: false,
Expand Down

0 comments on commit c947b3a

Please sign in to comment.