diff --git a/packages/mercurius/lib/drivers/mercurius-federation.driver.ts b/packages/mercurius/lib/drivers/mercurius-federation.driver.ts index ea0821461..1a84e3322 100644 --- a/packages/mercurius/lib/drivers/mercurius-federation.driver.ts +++ b/packages/mercurius/lib/drivers/mercurius-federation.driver.ts @@ -9,9 +9,16 @@ import { IncomingMessage, Server, ServerResponse } from 'http'; import mercurius from 'mercurius'; import { MercuriusDriverConfig } from '../interfaces/mercurius-driver-config.interface'; import { buildMercuriusFederatedSchema } from '../utils/build-mercurius-federated-schema.util'; +import { registerMercuriusPlugin } from '../utils/register-mercurius-plugin.util'; @Injectable() export class MercuriusFederationDriver extends AbstractGraphQLDriver { + constructor( + private readonly graphqlFederationFactory: GraphQLFederationFactory, + ) { + super(); + } + get instance(): FastifyInstance< Server, IncomingMessage, @@ -21,17 +28,12 @@ export class MercuriusFederationDriver extends AbstractGraphQLDriver { get instance(): FastifyInstance< @@ -22,10 +23,12 @@ export class MercuriusGatewayDriver extends AbstractGraphQLDriver(); await app.register(mercurius, { - ...options, + ...mercuriusOptions, }); + await registerMercuriusPlugin(app, plugins); } public async stop(): Promise {} diff --git a/packages/mercurius/lib/drivers/mercurius.driver.ts b/packages/mercurius/lib/drivers/mercurius.driver.ts index 5a9fb5b63..712d28695 100644 --- a/packages/mercurius/lib/drivers/mercurius.driver.ts +++ b/packages/mercurius/lib/drivers/mercurius.driver.ts @@ -5,6 +5,7 @@ import { printSchema } from 'graphql'; import { IncomingMessage, Server, ServerResponse } from 'http'; import mercurius from 'mercurius'; import { MercuriusDriverConfig } from '../interfaces/mercurius-driver-config.interface'; +import { registerMercuriusPlugin } from '../utils/register-mercurius-plugin.util'; export class MercuriusDriver extends AbstractGraphQLDriver { get instance(): FastifyInstance< @@ -17,7 +18,7 @@ export class MercuriusDriver extends AbstractGraphQLDriver( mercuriusOptions, ); @@ -39,6 +40,7 @@ export class MercuriusDriver extends AbstractGraphQLDriver {} diff --git a/packages/mercurius/lib/interfaces/index.ts b/packages/mercurius/lib/interfaces/index.ts index 2cf9efbd6..083153ec6 100644 --- a/packages/mercurius/lib/interfaces/index.ts +++ b/packages/mercurius/lib/interfaces/index.ts @@ -1,3 +1,4 @@ export * from './mercurius-driver-config.interface'; export * from './mercurius-federation-driver-config.interface'; export * from './mercurius-gateway-driver-config.interface'; +export * from './mercurius-plugin.interface'; diff --git a/packages/mercurius/lib/interfaces/mercurius-driver-config.interface.ts b/packages/mercurius/lib/interfaces/mercurius-driver-config.interface.ts index d2899336b..6539b432b 100644 --- a/packages/mercurius/lib/interfaces/mercurius-driver-config.interface.ts +++ b/packages/mercurius/lib/interfaces/mercurius-driver-config.interface.ts @@ -4,8 +4,11 @@ import { GqlOptionsFactory, } from '@nestjs/graphql'; import { MercuriusOptions } from 'mercurius'; +import { MercuriusPlugins } from './mercurius-plugin.interface'; -export type MercuriusDriverConfig = GqlModuleOptions & MercuriusOptions; +export type MercuriusDriverConfig = GqlModuleOptions & + MercuriusOptions & + MercuriusPlugins; export type MercuriusDriverConfigFactory = GqlOptionsFactory; diff --git a/packages/mercurius/lib/interfaces/mercurius-gateway-driver-config.interface.ts b/packages/mercurius/lib/interfaces/mercurius-gateway-driver-config.interface.ts index e6d7c219a..9a8a2f768 100644 --- a/packages/mercurius/lib/interfaces/mercurius-gateway-driver-config.interface.ts +++ b/packages/mercurius/lib/interfaces/mercurius-gateway-driver-config.interface.ts @@ -4,10 +4,12 @@ import { GqlOptionsFactory, } from '@nestjs/graphql'; import { MercuriusCommonOptions, MercuriusGatewayOptions } from 'mercurius'; +import { MercuriusPlugin } from './mercurius-plugin.interface'; export type MercuriusGatewayDriverConfig = GqlModuleOptions & MercuriusCommonOptions & - MercuriusGatewayOptions; + MercuriusGatewayOptions & + MercuriusPlugin; export type MercuriusGatewayDriverConfigFactory = GqlOptionsFactory; diff --git a/packages/mercurius/lib/interfaces/mercurius-plugin.interface.ts b/packages/mercurius/lib/interfaces/mercurius-plugin.interface.ts new file mode 100644 index 000000000..357c1e1bf --- /dev/null +++ b/packages/mercurius/lib/interfaces/mercurius-plugin.interface.ts @@ -0,0 +1,24 @@ +import { + FastifyPluginAsync, + FastifyPluginCallback, + FastifyPluginOptions, + FastifyRegisterOptions, +} from 'fastify'; + +export interface MercuriusPlugin< + Options extends FastifyPluginOptions = unknown, +> { + plugin: + | FastifyPluginCallback + | FastifyPluginAsync + | Promise<{ + default: FastifyPluginCallback; + }>; + options?: FastifyRegisterOptions; +} + +export interface MercuriusPlugins< + Options extends FastifyPluginOptions = unknown, +> { + plugins?: MercuriusPlugin[]; +} diff --git a/packages/mercurius/lib/utils/register-mercurius-plugin.util.ts b/packages/mercurius/lib/utils/register-mercurius-plugin.util.ts new file mode 100644 index 000000000..029765692 --- /dev/null +++ b/packages/mercurius/lib/utils/register-mercurius-plugin.util.ts @@ -0,0 +1,21 @@ +import { FastifyInstance } from 'fastify'; +import { MercuriusPlugin } from '../interfaces/mercurius-plugin.interface'; +import { isArray, isNull, isUndefined } from './validation.util'; + +export async function registerMercuriusPlugin( + app: FastifyInstance, + plugins?: MercuriusPlugin[], +): Promise { + if ( + isUndefined(plugins) || + isNull(plugins) || + !isArray(plugins) || + plugins.length === 0 + ) { + return; + } + + for (const plugin of plugins) { + await app.register(plugin.plugin, plugin.options); + } +} diff --git a/packages/mercurius/lib/utils/validation.util.ts b/packages/mercurius/lib/utils/validation.util.ts new file mode 100644 index 000000000..f8ae2a647 --- /dev/null +++ b/packages/mercurius/lib/utils/validation.util.ts @@ -0,0 +1,9 @@ +export const isUndefined = (value: unknown): value is undefined => { + return typeof value === 'undefined'; +}; + +export const isNull = (value: unknown): value is null => value === null; + +export const isArray = (value: unknown): value is T[] => { + return Array.isArray(value); +}; diff --git a/packages/mercurius/tests/e2e/code-first-plugin.spec.ts b/packages/mercurius/tests/e2e/code-first-plugin.spec.ts new file mode 100644 index 000000000..75422bd7a --- /dev/null +++ b/packages/mercurius/tests/e2e/code-first-plugin.spec.ts @@ -0,0 +1,51 @@ +import { INestApplication } from '@nestjs/common'; +import { FastifyAdapter } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import { FastifyInstance } from 'fastify'; +import { ApplicationModule } from '../plugins/code-first-plugin/app.module'; +import { NEW_PLUGIN_URL } from '../plugins/mocks/utils/constants'; + +describe('Code-first with plugins', () => { + let app: INestApplication; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [ApplicationModule], + }).compile(); + + app = module.createNestApplication(new FastifyAdapter()); + await app.init(); + }); + + it('should get the plugin', async () => { + const fastifyInstance: FastifyInstance = app.getHttpAdapter().getInstance(); + await fastifyInstance.ready(); + + const response = await fastifyInstance.inject({ + method: 'GET', + url: NEW_PLUGIN_URL, + }); + const data = JSON.parse(response.body); + expect(fastifyInstance.printPlugins().includes('mockPlugin')).toBe(true); + expect(response.statusCode).toBe(200); + expect(data.from).toBe(NEW_PLUGIN_URL); + }); + + it('it should query dog', async () => { + const fastifyInstance: FastifyInstance = app.getHttpAdapter().getInstance(); + await fastifyInstance.ready(); + + const response = await fastifyInstance.graphql(` + { + getAnimalName + } + `); + expect(response.data).toEqual({ + getAnimalName: 'dog', + }); + }); + + afterEach(async () => { + await app.close(); + }); +}); diff --git a/packages/mercurius/tests/e2e/graphql-federation-plugin.spec.ts b/packages/mercurius/tests/e2e/graphql-federation-plugin.spec.ts new file mode 100644 index 000000000..2c218f075 --- /dev/null +++ b/packages/mercurius/tests/e2e/graphql-federation-plugin.spec.ts @@ -0,0 +1,72 @@ +import { INestApplication } from '@nestjs/common'; +import { FastifyAdapter } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import { FastifyInstance } from 'fastify'; +import { AppModule as PostsModule } from '../plugins/graphql-federation-plugin/posts-service/federation-posts.module'; +import { AppModule as UsersModule } from '../plugins/graphql-federation-plugin/users-service/federation-users.module'; +import { + BASE_PLUGIN_URL, + NEW_PLUGIN_URL, +} from '../plugins/mocks/utils/constants'; + +describe('GraphQL Federation with plugins', () => { + let app: INestApplication; + + describe('UsersService', () => { + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [UsersModule], + }).compile(); + + app = module.createNestApplication(new FastifyAdapter()); + await app.init(); + }); + + it('should get the plugin for users', async () => { + const fastifyInstance: FastifyInstance = app + .getHttpAdapter() + .getInstance(); + await fastifyInstance.ready(); + + const response = await fastifyInstance.inject({ + method: 'GET', + url: NEW_PLUGIN_URL, + }); + const data = JSON.parse(response.body); + expect(fastifyInstance.printPlugins().includes('mockPlugin')).toBe(true); + expect(response.statusCode).toBe(200); + expect(data.from).toBe(NEW_PLUGIN_URL); + }); + }); + + describe('PostsService', () => { + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [PostsModule], + }).compile(); + + app = module.createNestApplication(new FastifyAdapter()); + await app.init(); + }); + + it('should get the plugin for posts', async () => { + const fastifyInstance: FastifyInstance = app + .getHttpAdapter() + .getInstance(); + await fastifyInstance.ready(); + + const response = await fastifyInstance.inject({ + method: 'GET', + url: BASE_PLUGIN_URL, + }); + const data = JSON.parse(response.body); + expect(fastifyInstance.printPlugins().includes('mockPlugin')).toBe(true); + expect(response.statusCode).toBe(200); + expect(data.from).toBe(BASE_PLUGIN_URL); + }); + }); + + afterEach(async () => { + await app.close(); + }); +}); diff --git a/packages/mercurius/tests/e2e/graphql-gateway-plugin.spec.ts b/packages/mercurius/tests/e2e/graphql-gateway-plugin.spec.ts new file mode 100644 index 000000000..84a88c454 --- /dev/null +++ b/packages/mercurius/tests/e2e/graphql-gateway-plugin.spec.ts @@ -0,0 +1,55 @@ +import { INestApplication } from '@nestjs/common'; +import { FastifyAdapter } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AppModule as PostsModule } from '../graphql-federation/posts-service/federation-posts.module'; +import { AppModule as UsersModule } from '../graphql-federation/users-service/federation-users.module'; +import { AppModule as GatewayModule } from '../plugins/graphql-federation-plugin/gateway/gateway.module'; +import { BASE_PLUGIN_URL } from '../plugins/mocks/utils/constants'; + +describe('GraphQL Gateway', () => { + let postsApp: INestApplication; + let usersApp: INestApplication; + let gatewayApp: INestApplication; + + beforeEach(async () => { + const usersModule = await Test.createTestingModule({ + imports: [UsersModule], + }).compile(); + + usersApp = usersModule.createNestApplication(new FastifyAdapter()); + await usersApp.listen(3011); + + const postsModule = await Test.createTestingModule({ + imports: [PostsModule], + }).compile(); + + postsApp = postsModule.createNestApplication(new FastifyAdapter()); + await postsApp.listen(3012); + + const gatewayModule = await Test.createTestingModule({ + imports: [GatewayModule], + }).compile(); + + gatewayApp = gatewayModule.createNestApplication(new FastifyAdapter()); + await gatewayApp.init(); + + await gatewayApp.getHttpAdapter().getInstance().ready(); + }); + + it('should get the plugin url', () => { + return request(gatewayApp.getHttpServer()) + .get(BASE_PLUGIN_URL) + .expect(200) + .expect('Content-Type', /json/) + .expect({ + from: BASE_PLUGIN_URL, + }); + }); + + afterEach(async () => { + await postsApp.close(); + await usersApp.close(); + await gatewayApp.close(); + }); +}); diff --git a/packages/mercurius/tests/plugins/code-first-plugin/app.module.ts b/packages/mercurius/tests/plugins/code-first-plugin/app.module.ts new file mode 100644 index 000000000..0da9eee56 --- /dev/null +++ b/packages/mercurius/tests/plugins/code-first-plugin/app.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { MercuriusDriverConfig } from '../../../lib'; +import { MercuriusDriver } from '../../../lib/drivers'; +import { mockPlugin } from '../mocks/mock.plugin'; +import { NEW_PLUGIN_URL } from '../mocks/utils/constants'; +import { DogsModule } from './dogs/dogs.module'; + +@Module({ + imports: [ + DogsModule, + GraphQLModule.forRoot({ + driver: MercuriusDriver, + autoSchemaFile: true, + plugins: [ + { + plugin: mockPlugin, + options: { + url: NEW_PLUGIN_URL, + }, + }, + ], + }), + ], +}) +export class ApplicationModule {} diff --git a/packages/mercurius/tests/plugins/code-first-plugin/dogs/dogs.module.ts b/packages/mercurius/tests/plugins/code-first-plugin/dogs/dogs.module.ts new file mode 100644 index 000000000..f6ba88083 --- /dev/null +++ b/packages/mercurius/tests/plugins/code-first-plugin/dogs/dogs.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { DogsResolver } from './dogs.resolver'; + +@Module({ + providers: [DogsResolver], +}) +export class DogsModule {} diff --git a/packages/mercurius/tests/plugins/code-first-plugin/dogs/dogs.resolver.ts b/packages/mercurius/tests/plugins/code-first-plugin/dogs/dogs.resolver.ts new file mode 100644 index 000000000..adccd5314 --- /dev/null +++ b/packages/mercurius/tests/plugins/code-first-plugin/dogs/dogs.resolver.ts @@ -0,0 +1,9 @@ +import { Query, Resolver } from '@nestjs/graphql'; + +@Resolver() +export class DogsResolver { + @Query((returns) => String) + getAnimalName(): string { + return 'dog'; + } +} diff --git a/packages/mercurius/tests/plugins/code-first-plugin/main.ts b/packages/mercurius/tests/plugins/code-first-plugin/main.ts new file mode 100644 index 000000000..1db5f27c6 --- /dev/null +++ b/packages/mercurius/tests/plugins/code-first-plugin/main.ts @@ -0,0 +1,17 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { FastifyAdapter } from '@nestjs/platform-fastify'; +import mercurius from 'mercurius'; +import { ApplicationModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(ApplicationModule, new FastifyAdapter()); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory: (errors) => + new mercurius.ErrorWithProps('Validation error', { errors }, 200), + }), + ); + await app.listen(3010); +} +bootstrap(); diff --git a/packages/mercurius/tests/plugins/graphql-federation-plugin/gateway/gateway.module.ts b/packages/mercurius/tests/plugins/graphql-federation-plugin/gateway/gateway.module.ts new file mode 100644 index 000000000..65a0d913e --- /dev/null +++ b/packages/mercurius/tests/plugins/graphql-federation-plugin/gateway/gateway.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { MercuriusGatewayDriver } from '../../../../lib/drivers'; +import { mockPlugin } from '../../mocks/mock.plugin'; + +@Module({ + imports: [ + GraphQLModule.forRoot({ + driver: MercuriusGatewayDriver, + gateway: { + services: [ + { name: 'users', url: 'http://localhost:3011/graphql' }, + { name: 'posts', url: 'http://localhost:3012/graphql' }, + ], + }, + plugins: [ + { + plugin: mockPlugin, + }, + ], + }), + ], +}) +export class AppModule {} diff --git a/packages/mercurius/tests/plugins/graphql-federation-plugin/posts-service/federation-posts.module.ts b/packages/mercurius/tests/plugins/graphql-federation-plugin/posts-service/federation-posts.module.ts new file mode 100644 index 000000000..750dff1d9 --- /dev/null +++ b/packages/mercurius/tests/plugins/graphql-federation-plugin/posts-service/federation-posts.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { join } from 'path'; +import { + MercuriusDriverConfig, + MercuriusFederationDriver, +} from '../../../../lib'; +import { PostsModule } from '../../../graphql-federation/posts-service/posts/posts.module'; +import { upperDirectiveTransformer } from '../../../graphql-federation/posts-service/posts/upper.directive'; +import { mockPlugin } from '../../mocks/mock.plugin'; + +@Module({ + imports: [ + GraphQLModule.forRoot({ + driver: MercuriusFederationDriver, + typePaths: [ + join( + __dirname, + '../../../graphql-federation/posts-service', + '**/*.graphql', + ), + ], + transformSchema: (schema) => upperDirectiveTransformer(schema, 'upper'), + federationMetadata: true, + plugins: [ + { + plugin: mockPlugin, + }, + ], + }), + PostsModule, + ], +}) +export class AppModule {} diff --git a/packages/mercurius/tests/plugins/graphql-federation-plugin/users-service/federation-users.module.ts b/packages/mercurius/tests/plugins/graphql-federation-plugin/users-service/federation-users.module.ts new file mode 100644 index 000000000..2206a86c1 --- /dev/null +++ b/packages/mercurius/tests/plugins/graphql-federation-plugin/users-service/federation-users.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { join } from 'path'; +import { + MercuriusDriverConfig, + MercuriusFederationDriver, +} from '../../../../lib'; +import { UsersModule } from '../../../graphql-federation/users-service/users/users.module'; +import { mockPlugin } from '../../mocks/mock.plugin'; +import { NEW_PLUGIN_URL } from '../../mocks/utils/constants'; + +@Module({ + imports: [ + GraphQLModule.forRoot({ + driver: MercuriusFederationDriver, + typePaths: [ + join( + __dirname, + '../../../graphql-federation/users-service', + '**/*.graphql', + ), + ], + federationMetadata: true, + plugins: [ + { + plugin: mockPlugin, + options: { + url: NEW_PLUGIN_URL, + }, + }, + ], + }), + UsersModule, + ], +}) +export class AppModule {} diff --git a/packages/mercurius/tests/plugins/mocks/interfaces/plugin-options.interface.ts b/packages/mercurius/tests/plugins/mocks/interfaces/plugin-options.interface.ts new file mode 100644 index 000000000..a5a377198 --- /dev/null +++ b/packages/mercurius/tests/plugins/mocks/interfaces/plugin-options.interface.ts @@ -0,0 +1,3 @@ +export interface PluginOptions { + url: string; +} diff --git a/packages/mercurius/tests/plugins/mocks/interfaces/plugin-response.interface.ts b/packages/mercurius/tests/plugins/mocks/interfaces/plugin-response.interface.ts new file mode 100644 index 000000000..674feb49a --- /dev/null +++ b/packages/mercurius/tests/plugins/mocks/interfaces/plugin-response.interface.ts @@ -0,0 +1,3 @@ +export interface PluginResponse { + from: string; +} diff --git a/packages/mercurius/tests/plugins/mocks/mock.plugin.ts b/packages/mercurius/tests/plugins/mocks/mock.plugin.ts new file mode 100644 index 000000000..ae234a48a --- /dev/null +++ b/packages/mercurius/tests/plugins/mocks/mock.plugin.ts @@ -0,0 +1,15 @@ +import { FastifyInstance } from 'fastify'; +import { PluginOptions } from './interfaces/plugin-options.interface'; +import { BASE_PLUGIN_URL } from './utils/constants'; +import { pluginResponse } from './utils/plugin-response'; + +export async function mockPlugin( + fastify: FastifyInstance, + options?: PluginOptions, +) { + const url = options?.url ?? BASE_PLUGIN_URL; + + fastify.get(url, async (request, reply) => { + return pluginResponse(url); + }); +} diff --git a/packages/mercurius/tests/plugins/mocks/utils/constants.ts b/packages/mercurius/tests/plugins/mocks/utils/constants.ts new file mode 100644 index 000000000..bb8744277 --- /dev/null +++ b/packages/mercurius/tests/plugins/mocks/utils/constants.ts @@ -0,0 +1,2 @@ +export const NEW_PLUGIN_URL = '/new-plugin-url'; +export const BASE_PLUGIN_URL = '/mock-plugin'; diff --git a/packages/mercurius/tests/plugins/mocks/utils/plugin-response.ts b/packages/mercurius/tests/plugins/mocks/utils/plugin-response.ts new file mode 100644 index 000000000..a698f1b71 --- /dev/null +++ b/packages/mercurius/tests/plugins/mocks/utils/plugin-response.ts @@ -0,0 +1,7 @@ +import { PluginResponse } from '../interfaces/plugin-response.interface'; + +export function pluginResponse(url: string): PluginResponse { + return { + from: url, + }; +}