diff --git a/documentation/docs/graphql/authorization.mdx b/documentation/docs/graphql/authorization.mdx index 42b6130c6..5c85d0af3 100644 --- a/documentation/docs/graphql/authorization.mdx +++ b/documentation/docs/graphql/authorization.mdx @@ -388,6 +388,10 @@ export class TodoItemResolver extends CRUDResolver(TodoItemDTO) { ``` +:::important +If you are extending the `CRUDResolver` directly be sure to [register your DTOs with the `NestjsQueryGraphQLModule`](./resolvers.mdx#crudresolver) +::: + ## Complete Example You can find a complete example in [`examples/auth`](https://github.com/doug-martin/nestjs-query/tree/master/examples/auth) diff --git a/documentation/docs/graphql/hooks.mdx b/documentation/docs/graphql/hooks.mdx index 735a8c92d..a9030749e 100644 --- a/documentation/docs/graphql/hooks.mdx +++ b/documentation/docs/graphql/hooks.mdx @@ -387,3 +387,91 @@ export class TodoItemDTO { } ``` + +## Using Hooks In Custom Endpoints + +You can also use hooks in custom endpoints by using the `HookInterceptor` along with +* `HookArgs` - Used to apply hooks to any query endpoint. +* `MutationHookArgs` - Used to apply hooks to any `mutation` that uses `MutationArgsType` + +### Example + +In this example we'll create an endpoint that marks all todo items that are currently not completed as completed. + +To start we'll create our input types. + +There are two types that are created +* The `UpdateManyTodoItemsInput` which extends the `UpdateManyInputType` this exposes an `update` and `filter` field just like the `updateMany` endpoints that are auto generated. +* The `UpdateManyTodoItemsArgs` which extends `MutationArgsType`, this provides a uniform interface for all mutations + ensuring that the argument provided to the mutation is named `input`. + +```ts title="todo-item/types.ts" +import { MutationArgsType, UpdateManyInputType } from '@nestjs-query/query-graphql'; +import { ArgsType, InputType } from '@nestjs/graphql'; +import { TodoItemDTO } from './dto/todo-item.dto'; +import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; + +// create the base input type +@InputType() +export class UpdateManyTodoItemsInput 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) {} +``` +Now we can use our new types in the resolver. + +```ts title="todo-item/todo-item.resolver.ts" {14,15} +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'; + +@Resolver(() => TodoItemDTO) +export class TodoItemResolver { + constructor(@InjectQueryService(TodoItemEntity) readonly service: QueryService) {} + + @Mutation(() => UpdateManyResponseType()) + @UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO)) + markTodoItemsAsCompleted(@MutationHookArgs() { input }: UpdateManyTodoItemsArgs): Promise { + return this.service.updateMany( + { ...input.update, completed: false }, + mergeFilter(input.filter, { completed: { is: false } }), + ); + } +} +``` +The first thing to notice is the + +```ts +@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO)) +``` + +This interceptor adds the correct hook to the `context` to be used by the param decorator. + +:::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. +::: + +The next piece is the + +```ts +@MutationHookArgs() { input }: UpdateManyTodoItemsArgs +``` +By using the `MutationHookArgs` decorator we ensure that the hook is applied to the arguments adding any additional +fields to the update. + +Finally we invoke the service `updateMany` with a filter that ensures we only update `TodoItems` that are completed, +and add an setting `completed` to true to the update + +```ts +return this.service.updateMany( + { ...input.update, completed: false }, + mergeFilter(input.filter, { completed: { is: false } }), +); +``` diff --git a/documentation/docs/graphql/resolvers.mdx b/documentation/docs/graphql/resolvers.mdx index 6f6059174..b8de053bb 100644 --- a/documentation/docs/graphql/resolvers.mdx +++ b/documentation/docs/graphql/resolvers.mdx @@ -93,7 +93,8 @@ export class TodoItemModule {} ### CRUDResolver -If you want to add custom queries or mutations you can use the `CRUDResolver` to manually define your resolver. +If you want to override auto generated queries or mutations you can use the `CRUDResolver` to manually define your +resolver. Resolvers work the same as they do in [`@nestjs/graphql`](https://docs.nestjs.com/graphql/resolvers-map) by annotating your class with `@Resolver`. @@ -122,6 +123,101 @@ export class TodoItemResolver extends CRUDResolver(TodoItemDTO) { ``` +To ensure that all the correct providers are setup (e.g. hooks, assemblers, and authorizers) you also need to +register your DTOs with the `NestjsQueryGraphQLModule`. + +Notice how the `dtos` property is specified instead of the resolvers, this allows you to specify your `DTOClass`, +`CreateDTOClass`, and `UpdateDTOClass` without creating an auto-generated resolver. + + + + +```ts title="todo-item.module.ts" {9,13} +import { NestjsQueryGraphQLModule } from '@nestjs-query/query-graphql'; +import { NestjsQueryTypeOrmModule } from '@nestjs-query/query-typeorm'; +import { Module } from '@nestjs/common'; +import { TodoItemDTO } from './todo-item.dto'; +import { TodoItemEntity } from './todo-item.entity'; +import { TodoItemResolver } from './todo-item.resolver' + +@Module({ + providers: [TodoItemResolver] + imports: [ + NestjsQueryGraphQLModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity])], + dtos: [{ DTOClass: TodoItemDTO }], + }), + ], +}) +export class TodoItemModule {} +``` + + + + +```ts title="todo-item.module.ts" {9,13} +import { NestjsQueryGraphQLModule } from '@nestjs-query/query-graphql'; +import { NestjsQuerySequelizeModule } from '@nestjs-query/query-sequelize'; +import { Module } from '@nestjs/common'; +import { TodoItemDTO } from './todo-item.dto'; +import { TodoItemEntity } from './todo-item.entity'; +import { TodoItemResolver } from './todo-item.resolver' + +@Module({ + providers: [TodoItemResolver] + imports: [ + NestjsQueryGraphQLModule.forFeature({ + imports: [NestjsQuerySequelizeModule.forFeature([TodoItemEntity])], + dtos: [{ DTOClass: TodoItemDTO }], + }), + ], +}) +export class TodoItemModule {} +``` + + + + +```ts title="todo-item.module.ts" {9,17} +import { NestjsQueryGraphQLModule } from '@nestjs-query/query-graphql'; +import { NestjsQueryMongooseModule } from '@nestjs-query/query-mongoose'; +import { Module } from '@nestjs/common'; +import { TodoItemDTO } from './todo-item.dto'; +import { TodoItemEntity } from './todo-item.entity'; +import { TodoItemResolver } from './todo-item.resolver' + +@Module({ + providers: [TodoItemResolver] + imports: [ + NestjsQueryGraphQLModule.forFeature({ + imports: [ + NestjsQueryMongooseModule.forFeature([ + { document: TodoItemEntity, name: TodoItemEntity.name, schema: TodoItemEntitySchema }, + ]), + ], + dtos: [{ DTOClass: TodoItemDTO }], + }), + ], +}) +export class TodoItemModule {} +``` + + + + +:::warning +All of the subsequent examples omit the module definition for custom resolvers but you should still register your +DTOs to ensure all the providers are set up properly. +::: + ### Generated Endpoints When using the auto-generated resolver or extending `CRUDResolver` the methods that will be exposed for the `TodoItemDTO` are: @@ -1281,7 +1377,7 @@ The `DeleteResolver` will only expose the `deleteOne` and `deleteMany` endpoints For example the following resolver will expose the `updateOneTodoItem` and `updateManyTodoItems` mutations. -``` title="todo-item.resolver.ts" +```ts title="todo-item.resolver.ts" import { QueryService, InjectQueryService } from '@nestjs-query/core'; import { DeleteResolver } from '@nestjs-query/query-graphql'; import { Resolver } from '@nestjs/graphql'; @@ -1299,9 +1395,14 @@ export class TodoItemResolver extends DeleteResolver(TodoItemDTO) { ``` --- -## Custom Methods +## Custom Endpoints + +You can also create custom methods. -You can also create custom methods that build on the methods added by CRUDResolver. +:::note +Unless you are overriding an endpoint you DO NOT need to extend the crud resolver directly, instead you can create a +new resolver for your type and add the new endpoint. `@nestjs/graphql` will handle merging the two resolver into one. +::: Lets create a new query endpoint that only returns completed `TodoItems`. @@ -1327,18 +1428,16 @@ In the code above we export two types. `TodoItemConnection` and `TodoItemQuery`. In your resolver you can now create a new `completedTodoItems` method with the following: ```ts title="todo-item.resolver.ts" -import { Filter, QueryService, InjectQueryService } from '@nestjs-query/core'; -import { ConnectionType, CRUDResolver } from '@nestjs-query/query-graphql'; +import { Filter, InjectAssemblerQueryService, QueryService } from '@nestjs-query/core'; +import { ConnectionType } from '@nestjs-query/query-graphql'; import { Args, Query, Resolver } from '@nestjs/graphql'; -import { TodoItemDTO } from './todo-item.dto'; -import { TodoItemEntity } from './todo-item.entity'; +import { TodoItemDTO } from './dto/todo-item.dto'; +import { TodoItemAssembler } from './todo-item.assembler'; import { TodoItemConnection, TodoItemQuery } from './types'; @Resolver(() => TodoItemDTO) -export class TodoItemResolver extends CRUDResolver(TodoItemDTO) { - constructor(@InjectQueryService(TodoItemEntity) readonly service: QueryService) { - super(service); - } +export class TodoItemResolver { + constructor(@InjectQueryService(TodoItemEntity) readonly service: QueryService) {} // Set the return type to the TodoItemConnection @Query(() => TodoItemConnection) @@ -1349,11 +1448,11 @@ export class TodoItemResolver extends CRUDResolver(TodoItemDTO) { ...{ completed: { is: true } }, }; - // call the original queryMany method with the new query - return this.queryMany({ ...query, ...{ filter } }); - } + return TodoItemConnection.createFromPromise((q) => this.service.query(q), { ...query, ...{ filter } }); + } + ``` Lets break this down so you know what is going on. @@ -1378,7 +1477,7 @@ completedTodoItems( ): TodoItemConnection! ``` Notice how there is not a query arg but instead the you see the fields of `TodoItemQuery` that is because we used -`@Args` without a name. +`@Args` without a name and added the `@ArgsType` decorator to the `TodoItemQuery`. The next piece is @@ -1393,26 +1492,41 @@ const filter: Filter = { Here we do a shallow copy of the `filter` and add `completed: { is: true }`. This will override any completed arguments that an end user may have provided to ensure we always query for completed todos. -Finally we call the `queryMany` method from the `CRUDResolver` with the new filter set on the query. +Finally we call create our connection response by using the `createFromPromise` method on the connection. ```ts // call the original queryMany method with the new query -return this.queryMany({ ...query, ...{ filter } }); +return TodoItemConnection.createFromPromise((q) => this.service.query(q), { ...query, ...{ filter } }); ``` -The last step is to add the resolver to the module. +The last step is to add the resolver to the module, by registering our resolver as a provider and importing the +`NestjsQueryGraphQLModule` we will get both the auto generated resolver along with the custom endpoints from the +custom resolver. ```ts title="todo-items.module.ts" -import { Module } from '@nestjs/common'; +import { NestjsQueryGraphQLModule } from '@nestjs-query/query-graphql'; import { NestjsQueryTypeOrmModule } from '@nestjs-query/query-typeorm'; +import { Module } from '@nestjs/common'; +import { TodoItemDTO } from './dto/todo-item.dto'; +import { TodoItemAssembler } from './todo-item.assembler'; import { TodoItemEntity } from './todo-item.entity'; -import { TodoItemsResolver } from './todo-items.resolver'; +import { TodoItemResolver } from './todo-item.resolver'; @Module({ providers: [TodoItemResolver], imports: [ - NestjsQueryTypeOrmModule.forFeature([TodoItemEntity]), + NestjsQueryGraphQLModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity])], + assemblers: [TodoItemAssembler], + resolvers: [ + { + DTOClass: TodoItemDTO, + EntityClass: TodoItemEntity, + }, + ], + }), ], }) -export class TodoItemsModule {} +export class TodoItemModule {} + ``` diff --git a/examples/hooks/src/todo-item/todo-item.resolver.ts b/examples/hooks/src/todo-item/todo-item.resolver.ts new file mode 100644 index 000000000..d836dfd87 --- /dev/null +++ b/examples/hooks/src/todo-item/todo-item.resolver.ts @@ -0,0 +1,22 @@ +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'; + +@Resolver(() => TodoItemDTO) +export class TodoItemResolver { + constructor(@InjectQueryService(TodoItemEntity) readonly service: QueryService) {} + + // Set the return type to the TodoItemConnection + @Mutation(() => UpdateManyResponseType()) + @UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO)) + markTodoItemsAsCompleted(@MutationHookArgs() { input }: UpdateManyTodoItemsArgs): Promise { + return this.service.updateMany( + { ...input.update, completed: false }, + mergeFilter(input.filter, { completed: { is: false } }), + ); + } +} diff --git a/examples/hooks/src/todo-item/types.ts b/examples/hooks/src/todo-item/types.ts index bd5158a1e..b5d0fce26 100644 --- a/examples/hooks/src/todo-item/types.ts +++ b/examples/hooks/src/todo-item/types.ts @@ -1,8 +1,10 @@ -import { ConnectionType, QueryArgsType } from '@nestjs-query/query-graphql'; -import { ArgsType } from '@nestjs/graphql'; +import { MutationArgsType, UpdateManyInputType } from '@nestjs-query/query-graphql'; +import { ArgsType, InputType } from '@nestjs/graphql'; import { TodoItemDTO } from './dto/todo-item.dto'; +import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; -export const TodoItemConnection = ConnectionType(TodoItemDTO, { enableTotalCount: true }); +@InputType() +class UpdateManyTodoItemsInput extends UpdateManyInputType(TodoItemDTO, TodoItemUpdateDTO) {} @ArgsType() -export class TodoItemQuery extends QueryArgsType(TodoItemDTO, { defaultResultSize: 2 }) {} +export class UpdateManyTodoItemsArgs extends MutationArgsType(UpdateManyTodoItemsInput) {} diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index 82b0621da..d040edab4 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -29,6 +29,8 @@ export { Authorize, AuthorizerFilter, RelationAuthorizerFilter, + HookArgs, + MutationHookArgs, KeySet, } from './decorators'; export * from './resolvers'; @@ -40,6 +42,7 @@ export { pubSubToken, GraphQLPubSub } from './subscription'; export { Authorizer, AuthorizerOptions } from './auth'; export { Hook, + HookTypes, BeforeCreateOneHook, BeforeCreateManyHook, BeforeUpdateOneHook, @@ -49,4 +52,4 @@ export { BeforeQueryManyHook, BeforeFindOneHook, } from './hooks'; -export { AuthorizerInterceptor, AuthorizerContext } from './interceptors'; +export { AuthorizerInterceptor, AuthorizerContext, HookInterceptor, HookContext } from './interceptors'; diff --git a/packages/query-graphql/src/module.ts b/packages/query-graphql/src/module.ts index 906dd6081..cf92b950a 100644 --- a/packages/query-graphql/src/module.ts +++ b/packages/query-graphql/src/module.ts @@ -5,6 +5,12 @@ import { ReadResolverOpts } from './resolvers'; import { defaultPubSub, pubSubToken, GraphQLPubSub } from './subscription'; import { PagingStrategies } from './types/query/paging'; +interface DTOModuleOpts { + DTOClass: Class; + CreateDTOClass: Class; + UpdateDTOClass: Class; +} + export interface NestjsQueryGraphqlModuleOpts { // eslint-disable-next-line @typescript-eslint/no-explicit-any imports: Array | DynamicModule | Promise | ForwardReference>; @@ -13,32 +19,62 @@ export interface NestjsQueryGraphqlModuleOpts { assemblers?: Class>[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any resolvers: AutoResolverOpts, PagingStrategies>[]; + dtos?: DTOModuleOpts[]; pubSub?: Provider; } export class NestjsQueryGraphQLModule { static forFeature(opts: NestjsQueryGraphqlModuleOpts): DynamicModule { - const coreModule = NestjsQueryCoreModule.forFeature({ - assemblers: opts.assemblers, - imports: opts.imports, - }); - const pubSubProvider = opts.pubSub ?? this.defaultPubSubProvider(); - const DTOClasses = opts.resolvers.map((r) => r.DTOClass); - const resolverProviders = createResolvers(opts.resolvers); - const providers = [ - ...(opts.services || []), - ...createAuthorizerProviders(DTOClasses), - ...createHookProviders(opts.resolvers), - ]; + const coreModule = this.getCoreModule(opts); + const providers = this.getProviders(opts); return { module: NestjsQueryGraphQLModule, imports: [...opts.imports, coreModule], - providers: [...providers, ...resolverProviders, pubSubProvider], - exports: [...providers, ...resolverProviders, ...opts.imports, coreModule, pubSubProvider], + providers: [...providers], + exports: [...providers, ...opts.imports, coreModule], }; } static defaultPubSubProvider(): Provider { return { provide: pubSubToken(), useValue: defaultPubSub() }; } + + private static getCoreModule(opts: NestjsQueryGraphqlModuleOpts): DynamicModule { + return NestjsQueryCoreModule.forFeature({ + assemblers: opts.assemblers, + imports: opts.imports, + }); + } + + private static getProviders(opts: NestjsQueryGraphqlModuleOpts): Provider[] { + return [ + ...this.getServicesProviders(opts), + ...this.getPubSubProviders(opts), + ...this.getAuthorizerProviders(opts), + ...this.getHookProviders(opts), + ...this.getResolverProviders(opts), + ]; + } + + private static getPubSubProviders(opts: NestjsQueryGraphqlModuleOpts): Provider[] { + return [opts.pubSub ?? this.defaultPubSubProvider()]; + } + + private static getServicesProviders(opts: NestjsQueryGraphqlModuleOpts): Provider[] { + return opts.services ?? []; + } + + private static getResolverProviders(opts: NestjsQueryGraphqlModuleOpts): Provider[] { + return createResolvers(opts.resolvers); + } + + private static getAuthorizerProviders(opts: NestjsQueryGraphqlModuleOpts): Provider[] { + const resolverDTOs = opts.resolvers.map((r) => r.DTOClass); + const dtos = opts.dtos?.map((o) => o.DTOClass) ?? []; + return createAuthorizerProviders([...resolverDTOs, ...dtos]); + } + + private static getHookProviders(opts: NestjsQueryGraphqlModuleOpts): Provider[] { + return createHookProviders([...opts.resolvers, ...(opts.dtos ?? [])]); + } }