diff --git a/examples/graphql/src/__tests__/acceptance/graphql-tests.ts b/examples/graphql/src/__tests__/acceptance/graphql-tests.ts index e82b3919a9e9..47ea205cbc73 100644 --- a/examples/graphql/src/__tests__/acceptance/graphql-tests.ts +++ b/examples/graphql/src/__tests__/acceptance/graphql-tests.ts @@ -125,4 +125,13 @@ mutation AddRecipe { numberInCollection creationDate } -}`; +} + +subscription AllNotifications { + recipeCreated { + id + numberInCollection + creationDate + } +} +`; diff --git a/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts b/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts index b48247e63f63..840b3bdc4f77 100644 --- a/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts +++ b/examples/graphql/src/__tests__/acceptance/graphql.acceptance.ts @@ -28,7 +28,11 @@ describe('GraphQL server', () => { runTests(() => supertest(server.httpServer?.url)); async function givenServer() { - server = new GraphQLServer({host: '127.0.0.1', port: 0}); + server = new GraphQLServer({ + host: '127.0.0.1', + port: 0, + apollo: {subscriptions: '/subscriptions'}, + }); server.resolver(RecipeResolver); server.bind('recipes').to([...sampleRecipes]); diff --git a/examples/graphql/src/application.ts b/examples/graphql/src/application.ts index 90a1da6ed5fc..cdef25bbf11a 100644 --- a/examples/graphql/src/application.ts +++ b/examples/graphql/src/application.ts @@ -22,9 +22,6 @@ export class GraphqlDemoApplication extends BootMixin( this.component(GraphQLComponent); const server = this.getSync(GraphQLBindings.GRAPHQL_SERVER); this.expressMiddleware('middleware.express.GraphQL', server.expressApp); - this.configure(GraphQLBindings.GRAPHQL_SERVER).to({ - asMiddlewareOnly: true, - }); // It's possible to register a graphql context resolver this.bind(GraphQLBindings.GRAPHQL_CONTEXT_RESOLVER).to(context => { diff --git a/examples/graphql/src/graphql-resolvers/recipe-resolver.ts b/examples/graphql/src/graphql-resolvers/recipe-resolver.ts index b3d9bb9a305f..b267b5139336 100644 --- a/examples/graphql/src/graphql-resolvers/recipe-resolver.ts +++ b/examples/graphql/src/graphql-resolvers/recipe-resolver.ts @@ -11,11 +11,14 @@ import { GraphQLBindings, Int, mutation, + Publisher, + pubSub, query, resolver, ResolverData, ResolverInterface, root, + subscription, } from '@loopback/graphql'; import {repository} from '@loopback/repository'; import {RecipeInput} from '../graphql-types/recipe-input'; @@ -46,8 +49,18 @@ export class RecipeResolver implements ResolverInterface { } @mutation(returns => Recipe) - async addRecipe(@arg('recipe') recipe: RecipeInput): Promise { - return this.recipeRepo.add(recipe); + async addRecipe( + @arg('recipe') recipe: RecipeInput, + @pubSub('recipeCreated') publish: Publisher, + ): Promise { + const result = await this.recipeRepo.add(recipe); + await publish(result); + return result; + } + + @subscription(returns => Recipe, {topics: 'recipeCreated'}) + async recipeCreated(@root() recipe: Recipe) { + return recipe; } @fieldResolver() diff --git a/examples/graphql/src/index.ts b/examples/graphql/src/index.ts index 06d5e9a4f49e..68a7ecb9d21f 100644 --- a/examples/graphql/src/index.ts +++ b/examples/graphql/src/index.ts @@ -1,8 +1,8 @@ +import {GraphQLServerOptions} from '@loopback/graphql'; // Copyright IBM Corp. 2020. All Rights Reserved. // Node module: @loopback/example-graphql // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT - import {ApplicationConfig, GraphqlDemoApplication} from './application'; export * from './application'; @@ -20,6 +20,12 @@ export async function main(options: ApplicationConfig = {}) { } if (require.main === module) { + const graphqlCfg: GraphQLServerOptions = { + apollo: { + subscriptions: '/subscriptions', + }, + asMiddlewareOnly: true, + }; // Run the application const config = { rest: { @@ -36,6 +42,7 @@ if (require.main === module) { setServersFromRequest: true, }, }, + graphql: graphqlCfg, }; main(config).catch(err => { console.error('Cannot start the application.', err); diff --git a/extensions/graphql/README.md b/extensions/graphql/README.md index 0e8b8afa6e23..8ee20d508650 100644 --- a/extensions/graphql/README.md +++ b/extensions/graphql/README.md @@ -68,11 +68,11 @@ export class GraphqlDemoApplication extends BootMixin( super(options); this.component(GraphQLComponent); - const server = this.getSync(GraphQLBindings.GRAPHQL_SERVER); - this.expressMiddleware('middleware.express.GraphQL', server.expressApp); this.configure(GraphQLBindings.GRAPHQL_SERVER).to({ asMiddlewareOnly: true, }); + const server = this.getSync(GraphQLBindings.GRAPHQL_SERVER); + this.expressMiddleware('middleware.express.GraphQL', server.expressApp); // ... @@ -89,6 +89,17 @@ export class GraphqlDemoApplication extends BootMixin( } ``` +The GraphQLServer configuration can also be passed in from the application +config, such as: + +```ts +const app = new Application({ + graphql: { + asMiddlewareOnly: true, + }, +}); +``` + ## Add GraphQL types The `@loopback/graphql` packages supports GraphQL schemas to be defined using diff --git a/extensions/graphql/src/__tests__/unit/graphql.component.unit.ts b/extensions/graphql/src/__tests__/unit/graphql.component.unit.ts index b9de6ce4f1a9..832cdb6c7667 100644 --- a/extensions/graphql/src/__tests__/unit/graphql.component.unit.ts +++ b/extensions/graphql/src/__tests__/unit/graphql.component.unit.ts @@ -5,14 +5,38 @@ import {Application} from '@loopback/core'; import {expect} from '@loopback/testlab'; -import {GraphQLComponent} from '../../graphql.component'; -import {GraphQLBindings} from '../../keys'; +import {GraphQLBindings, GraphQLComponent} from '../..'; describe('GraphQL component', () => { - it('binds server and booter bindings;', () => { + it('binds server and booter bindings', () => { const app = new Application(); app.component(GraphQLComponent); expect(app.isBound(GraphQLBindings.COMPONENT)).to.be.true(); expect(app.isBound('booters.GraphQLResolverBooter')).to.be.true(); }); + + it('configures GraphQL server from app config', () => { + const app = new Application({ + graphql: { + asMiddlewareOnly: true, + }, + }); + app.component(GraphQLComponent); + expect(app.getConfigSync(GraphQLBindings.GRAPHQL_SERVER)).to.eql({ + asMiddlewareOnly: true, + }); + }); + + it('configures GraphQL server to override app config', () => { + const app = new Application({ + graphql: { + asMiddlewareOnly: true, + }, + }); + app.component(GraphQLComponent); + app.configure(GraphQLBindings.GRAPHQL_SERVER).to({asMiddlewareOnly: false}); + expect(app.getConfigSync(GraphQLBindings.GRAPHQL_SERVER)).to.eql({ + asMiddlewareOnly: false, + }); + }); }); diff --git a/extensions/graphql/src/__tests__/unit/graphql.server.unit.ts b/extensions/graphql/src/__tests__/unit/graphql.server.unit.ts index ef5e1860ca1f..578e10d67157 100644 --- a/extensions/graphql/src/__tests__/unit/graphql.server.unit.ts +++ b/extensions/graphql/src/__tests__/unit/graphql.server.unit.ts @@ -23,7 +23,7 @@ describe('GraphQL server', () => { it('registers resolver classes', () => { server.resolver(RecipeResolver); - expect(server.getResolvers()).to.containEql(RecipeResolver); + expect(server.getResolverClasses()).to.containEql(RecipeResolver); }); it('registers resolver classes with name', () => { @@ -39,10 +39,29 @@ describe('GraphQL server', () => { return next(); }; server.middleware(middleware); - const middlewareList = await server.getMiddleware(); + const middlewareList = await server.getMiddlewareList(); expect(middlewareList).to.containEql(middleware); }); + it('fails to start without resolvers', async () => { + await expect(server.start()).to.be.rejectedWith( + /Empty `resolvers` array property found in `buildSchema` options/, + ); + }); + + it('starts and stops', async () => { + server.resolver(RecipeResolver); + await server.start(); + expect(server.listening).to.be.true(); + await server.stop(); + expect(server.listening).to.be.false(); + }); + + it('does not create http server with asMiddlewareOnly option', async () => { + server = new GraphQLServer({asMiddlewareOnly: true}); + expect(server.httpServer).to.be.undefined(); + }); + function givenServer() { server = new GraphQLServer(); } diff --git a/extensions/graphql/src/decorators/index.ts b/extensions/graphql/src/decorators/index.ts index cd0762e4d546..b345e5f2c025 100644 --- a/extensions/graphql/src/decorators/index.ts +++ b/extensions/graphql/src/decorators/index.ts @@ -15,9 +15,11 @@ import { InputType, Mutation, ObjectType, + PubSub, Query, Resolver, Root, + Subscription, } from 'type-graphql'; /** @@ -38,3 +40,5 @@ export const field = Field; export const inputType = InputType; export const objectType = ObjectType; export const authorized = Authorized; +export const subscription = Subscription; +export const pubSub = PubSub; diff --git a/extensions/graphql/src/graphql.component.ts b/extensions/graphql/src/graphql.component.ts index 86d92d4f52c7..8c3b195dd8b6 100644 --- a/extensions/graphql/src/graphql.component.ts +++ b/extensions/graphql/src/graphql.component.ts @@ -4,14 +4,16 @@ // License text available at https://opensource.org/licenses/MIT import { + Application, Binding, Component, - config, + CoreBindings, createBindingFromClass, + inject, } from '@loopback/core'; import {GraphQLResolverBooter} from './booters/resolver.booter'; import {GraphQLServer} from './graphql.server'; -import {GraphQLComponentOptions} from './types'; +import {GraphQLBindings} from './keys'; /** * Component for GraphQL @@ -22,5 +24,9 @@ export class GraphQLComponent implements Component { createBindingFromClass(GraphQLResolverBooter), ]; - constructor(@config() private options: GraphQLComponentOptions = {}) {} + constructor(@inject(CoreBindings.APPLICATION_INSTANCE) app: Application) { + app + .configure(GraphQLBindings.GRAPHQL_SERVER) + .toAlias(GraphQLBindings.CONFIG); + } } diff --git a/extensions/graphql/src/graphql.server.ts b/extensions/graphql/src/graphql.server.ts index b0edf1d59041..6c5512b7dadf 100644 --- a/extensions/graphql/src/graphql.server.ts +++ b/extensions/graphql/src/graphql.server.ts @@ -18,9 +18,14 @@ import { lifeCycleObserver, Server, } from '@loopback/core'; -import {HttpOptions, HttpServer} from '@loopback/http-server'; +import {HttpServer} from '@loopback/http-server'; import {ContextFunction} from 'apollo-server-core'; -import {ApolloServer, ApolloServerExpressConfig} from 'apollo-server-express'; +import { + ApolloServer, + ApolloServerExpressConfig, + PubSub, + PubSubEngine, +} from 'apollo-server-express'; import {ExpressContext} from 'apollo-server-express/dist/ApolloServer'; import express from 'express'; import { @@ -32,29 +37,7 @@ import { import {Middleware} from 'type-graphql/dist/interfaces/Middleware'; import {LoopBackContainer} from './graphql.container'; import {GraphQLBindings, GraphQLTags} from './keys'; - -export {ContextFunction} from 'apollo-server-core'; -export {ApolloServerExpressConfig} from 'apollo-server-express'; -export {ExpressContext} from 'apollo-server-express/dist/ApolloServer'; -export {Middleware as GraphQLMiddleware} from 'type-graphql/dist/interfaces/Middleware'; - -/** - * Options for GraphQL server - */ -export interface GraphQLServerOptions extends HttpOptions { - /** - * GraphQL related configuration - */ - graphql?: ApolloServerExpressConfig; - /** - * Express settings - */ - express?: Record; - /** - * Use as a middleware for RestServer instead of a standalone server - */ - asMiddlewareOnly?: boolean; -} +import {GraphQLServerOptions} from './types'; /** * GraphQL Server @@ -73,13 +56,17 @@ export class GraphQLServer extends Context implements Server { parent?: Context, ) { super(parent, 'graphql-server'); + + // An internal express application for GraphQL only this.expressApp = express(); - if (options.express) { - for (const p in options.express) { - this.expressApp.set(p, options.express[p]); + if (options.expressSettings) { + for (const p in options.expressSettings) { + this.expressApp.set(p, options.expressSettings[p]); } } + // Create a standalone http server if GraphQL is mounted as an Express + // middleware to a RestServer from `@loopback/rest` if (!options.asMiddlewareOnly) { this.httpServer = new HttpServer(this.expressApp, this.options); } @@ -88,7 +75,7 @@ export class GraphQLServer extends Context implements Server { /** * Get a list of resolver classes */ - getResolvers(): Constructor>[] { + getResolverClasses(): Constructor>[] { const view = this.createView(filterByTag(GraphQLTags.RESOLVER)); return view.bindings .filter(b => b.valueConstructor != null) @@ -98,7 +85,7 @@ export class GraphQLServer extends Context implements Server { /** * Get a list of middleware */ - async getMiddleware(): Promise[]> { + async getMiddlewareList(): Promise[]> { const view = this.createView>( filterByTag(GraphQLTags.MIDDLEWARE), ); @@ -128,15 +115,21 @@ export class GraphQLServer extends Context implements Server { } async start() { - const resolverClasses = (this.getResolvers() as unknown) as NonEmptyArray< + const resolverClasses = (this.getResolverClasses() as unknown) as NonEmptyArray< Function >; + // Get the configured auth checker const authChecker: AuthChecker = (await this.get(GraphQLBindings.GRAPHQL_AUTH_CHECKER, { optional: true, })) ?? ((resolverData, roles) => true); + const pubSub: PubSubEngine | undefined = + (await this.get(GraphQLBindings.PUB_SUB_ENGINE, { + optional: true, + })) ?? new PubSub(); + // build TypeGraphQL executable schema const schema = await buildSchema({ // See https://github.com/MichalLytek/type-graphql/issues/150#issuecomment-420181526 @@ -146,7 +139,8 @@ export class GraphQLServer extends Context implements Server { // emitSchemaFile: path.resolve(__dirname, 'schema.gql'), container: new LoopBackContainer(this), authChecker, - globalMiddlewares: await this.getMiddleware(), + pubSub, + globalMiddlewares: await this.getMiddlewareList(), }); // Allow a graphql context resolver to be bound to GRAPHQL_CONTEXT_RESOLVER @@ -155,25 +149,35 @@ export class GraphQLServer extends Context implements Server { optional: true, })) ?? (context => context); + // Create ApolloServerExpress GraphQL server const serverConfig: ApolloServerExpressConfig = { // enable GraphQL Playground playground: true, context: graphqlContextResolver, - ...this.options.graphql, + subscriptions: false, + ...this.options.apollo, schema, }; - // Create GraphQL server const graphQLServer = new ApolloServer(serverConfig); - graphQLServer.applyMiddleware({app: this.expressApp}); + // Set up subscription handlers + if (this.httpServer && serverConfig.subscriptions) { + graphQLServer.installSubscriptionHandlers(this.httpServer?.server); + } + + // Start the http server if created await this.httpServer?.start(); } async stop() { + // Stop the http server if created await this.httpServer?.stop(); } + /** + * Is the GraphQL listening + */ get listening() { return !!this.httpServer?.listening; } diff --git a/extensions/graphql/src/keys.ts b/extensions/graphql/src/keys.ts index 82c8d8367630..a620c85c60b6 100644 --- a/extensions/graphql/src/keys.ts +++ b/extensions/graphql/src/keys.ts @@ -3,15 +3,23 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {BindingKey, Constructor} from '@loopback/core'; -import {AuthChecker, ResolverData} from 'type-graphql'; +import {BindingKey, Constructor, CoreBindings} from '@loopback/core'; +import {AuthChecker, PubSubEngine, ResolverData} from 'type-graphql'; import {GraphQLComponent} from './graphql.component'; -import {ContextFunction, ExpressContext, GraphQLServer} from './graphql.server'; +import {GraphQLServer} from './graphql.server'; +import {ContextFunction, ExpressContext, GraphQLServerOptions} from './types'; /** * Namespace for GraphQL related bindings */ export namespace GraphQLBindings { + /** + * Binding key for setting and injecting GraphQLServerConfig + */ + export const CONFIG: BindingKey = CoreBindings.APPLICATION_CONFIG.deepProperty( + 'graphql', + ); + /** * Binding key for the GraphQL server */ @@ -40,6 +48,13 @@ export namespace GraphQLBindings { 'graphql.authChecker', ); + /** + * Binding key for the GraphQL pub/sub engine + */ + export const PUB_SUB_ENGINE = BindingKey.create( + 'graphql.pubSubEngine', + ); + /** * Binding key for the GraphQL resolver data - which is bound per request */ diff --git a/extensions/graphql/src/types.ts b/extensions/graphql/src/types.ts index 89d4eef7c427..41b3b2cd03ad 100644 --- a/extensions/graphql/src/types.ts +++ b/extensions/graphql/src/types.ts @@ -3,7 +3,14 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {HttpOptions} from '@loopback/http-server'; +import {ApolloServerExpressConfig} from 'apollo-server-express'; + +export {ContextFunction} from 'apollo-server-core'; +export {ApolloServerExpressConfig} from 'apollo-server-express'; +export {ExpressContext} from 'apollo-server-express/dist/ApolloServer'; export {Float, ID, Int, ResolverInterface} from 'type-graphql'; +export {Middleware as GraphQLMiddleware} from 'type-graphql/dist/interfaces/Middleware'; /** * Options for GraphQL component @@ -11,3 +18,21 @@ export {Float, ID, Int, ResolverInterface} from 'type-graphql'; export interface GraphQLComponentOptions { // To be added } + +/** + * Options for GraphQL server + */ +export interface GraphQLServerOptions extends HttpOptions { + /** + * ApolloServerExpress related configuration + */ + apollo?: ApolloServerExpressConfig; + /** + * Express settings + */ + expressSettings?: Record; + /** + * Use as a middleware for RestServer instead of a standalone server + */ + asMiddlewareOnly?: boolean; +}