diff --git a/lib/external/type-graphql.types.ts b/lib/external/type-graphql.types.ts index 0ff1978e9..3203b2113 100644 --- a/lib/external/type-graphql.types.ts +++ b/lib/external/type-graphql.types.ts @@ -1,5 +1,5 @@ import { Type } from '@nestjs/common'; -import { GraphQLScalarType } from 'graphql'; +import { GraphQLDirective, GraphQLScalarType } from 'graphql'; /** * Some external types have to be included in order to provide types safety @@ -8,12 +8,7 @@ import { GraphQLScalarType } from 'graphql'; * see: https://github.com/19majkel94/type-graphql * 0.16.0 */ -export type TypeValue = - | Type - | GraphQLScalarType - | Function - | object - | symbol; +export type TypeValue = Type | GraphQLScalarType | Function | object | symbol; export type ReturnTypeFuncValue = TypeValue | [TypeValue]; export type ReturnTypeFunc = (returns?: void) => ReturnTypeFuncValue; export type NullableListOptions = 'items' | 'itemsAndList'; @@ -38,12 +33,14 @@ export interface ResolverClassOptions { } export type ClassTypeResolver = (of?: void) => Type; export type BasicOptions = DecoratorTypeOptions & DescriptionOptions; -export type AdvancedOptions = BasicOptions & - DepreciationOptions & - SchemaNameOptions; +export type AdvancedOptions = BasicOptions & DepreciationOptions & SchemaNameOptions; + export interface BuildSchemaOptions { dateScalarMode?: DateScalarMode; scalarsMap?: ScalarsTypeMap[]; + /** Any types that are not directly referenced or returned by resolvers */ + orphanedTypes?: Function[]; + directives?: GraphQLDirective[]; } export type DateScalarMode = 'isoDate' | 'timestamp'; export interface ScalarsTypeMap { diff --git a/lib/graphql-federation.factory.ts b/lib/graphql-federation.factory.ts index 909982992..5f1ad4d67 100644 --- a/lib/graphql-federation.factory.ts +++ b/lib/graphql-federation.factory.ts @@ -1,14 +1,19 @@ import { Injectable } from '@nestjs/common'; import { gql } from 'apollo-server-core'; import { loadPackage } from '@nestjs/common/utils/load-package.util'; - import { extend } from './utils'; +import { isEmpty, forEach } from 'lodash'; import { ScalarsExplorerService, DelegatesExplorerService, ResolversExplorerService, } from './services'; +import { mergeSchemas } from 'graphql-tools'; import { GqlModuleOptions } from './interfaces'; +import { GraphQLSchemaBuilder } from './graphql-schema-builder'; +import { GraphQLFactory } from './graphql.factory'; +import { GraphQLSchema, GraphQLSchemaConfig } from 'graphql'; +import { GraphQLObjectType } from 'graphql'; @Injectable() export class GraphQLFederationFactory { @@ -16,38 +21,80 @@ export class GraphQLFederationFactory { private readonly resolversExplorerService: ResolversExplorerService, private readonly delegatesExplorerService: DelegatesExplorerService, private readonly scalarsExplorerService: ScalarsExplorerService, + private readonly gqlSchemaBuilder: GraphQLSchemaBuilder, + private readonly graphqlFactory: GraphQLFactory, ) {} - private extendResolvers(resolvers: any[]) { - return resolvers.reduce((prev, curr) => extend(prev, curr), {}); + async mergeOptions(options: GqlModuleOptions = {}): Promise { + const transformSchema = async s => (options.transformSchema ? options.transformSchema(s) : s); + + let schema: GraphQLSchema; + if (options.autoSchemaFile) { + // Enable support when Directive support in type-graphql goes stable + throw new Error('Code-first not supported yet'); + schema = await this.generateSchema(options); + } else if (!isEmpty(options.typeDefs)) { + schema = options.schema; + } else { + schema = this.buildSchemaFromTypeDefs(options); + } + + return { + ...options, + schema: await transformSchema(schema), + typeDefs: undefined, + }; } - async mergeOptions(options: GqlModuleOptions = {}): Promise { + private buildSchemaFromTypeDefs(options: GqlModuleOptions) { const { buildFederatedSchema } = loadPackage('@apollo/federation', 'ApolloFederation'); - const externalResolvers = Array.isArray(options.resolvers) - ? options.resolvers - : [options.resolvers]; - const resolvers = this.extendResolvers([ - this.resolversExplorerService.explore(), - this.delegatesExplorerService.explore(), - ...this.scalarsExplorerService.explore(), - ...externalResolvers, - ]); - - const schema = buildFederatedSchema([ + return buildFederatedSchema([ { typeDefs: gql` ${options.typeDefs} `, - resolvers, + resolvers: this.getResolvers(options.resolvers), }, ]); + } + + private async generateSchema(options: GqlModuleOptions): Promise { + const { buildFederatedSchema, printSchema } = loadPackage( + '@apollo/federation', + 'ApolloFederation', + ); + + const autoGeneratedSchema: GraphQLSchema = await this.gqlSchemaBuilder.buildFederatedSchema( + options.autoSchemaFile, + options.buildSchemaOptions, + this.resolversExplorerService.getAllCtors(), + ); + const executableSchema = buildFederatedSchema({ + typeDefs: gql(printSchema(autoGeneratedSchema)), + resolvers: this.getResolvers(options.resolvers), + }); + + const schema = options.schema + ? mergeSchemas({ + schemas: [options.schema, executableSchema], + }) + : executableSchema; + + return schema; + } + + private getResolvers(optionResolvers) { + optionResolvers = Array.isArray(optionResolvers) ? optionResolvers : [optionResolvers]; + return this.extendResolvers([ + this.resolversExplorerService.explore(), + this.delegatesExplorerService.explore(), + ...this.scalarsExplorerService.explore(), + ...optionResolvers, + ]); + } - return { - ...options, - schema, - typeDefs: undefined, - }; + private extendResolvers(resolvers: any[]) { + return resolvers.reduce((prev, curr) => extend(prev, curr), {}); } } diff --git a/lib/graphql-schema-builder.ts b/lib/graphql-schema-builder.ts index 26ff2fd12..ee72d3b4c 100644 --- a/lib/graphql-schema-builder.ts +++ b/lib/graphql-schema-builder.ts @@ -1,15 +1,13 @@ import { Injectable } from '@nestjs/common'; import { loadPackage } from '@nestjs/common/utils/load-package.util'; -import { GraphQLSchema } from 'graphql'; +import { GraphQLSchema, specifiedDirectives } from 'graphql'; import { BuildSchemaOptions } from './external/type-graphql.types'; import { ScalarsExplorerService } from './services'; import { lazyMetadataStorage } from './storages/lazy-metadata.storage'; @Injectable() export class GraphQLSchemaBuilder { - constructor( - private readonly scalarsExplorerService: ScalarsExplorerService, - ) {} + constructor(private readonly scalarsExplorerService: ScalarsExplorerService) {} async build( autoSchemaFile: string | boolean, @@ -36,10 +34,48 @@ export class GraphQLSchemaBuilder { } } + async buildFederatedSchema( + autoSchemaFile: string | boolean, + options: BuildSchemaOptions = {}, + resolvers: Function[], + ) { + lazyMetadataStorage.load(); + + const buildSchema = this.loadBuildSchemaFactory(); + const scalarsMap = this.scalarsExplorerService.getScalarsMap(); + + try { + return await buildSchema({ + ...options, + directives: [ + ...specifiedDirectives, + ...this.loadFederationDirectives(), + ...((options && options.directives) || []), + ], + emitSchemaFile: autoSchemaFile !== true ? autoSchemaFile : false, + validate: false, + scalarsMap, + resolvers, + skipCheck: true, + }); + } catch (err) { + if (err && err.details) { + console.error(err.details); + } + throw err; + } + } + private loadBuildSchemaFactory(): (...args: any[]) => GraphQLSchema { - const { buildSchema } = loadPackage('type-graphql', 'SchemaBuilder', () => - require('type-graphql'), - ); + const { buildSchema } = loadPackage('type-graphql', 'SchemaBuilder'); return buildSchema; } + + private loadFederationDirectives() { + const { federationDirectives } = loadPackage( + '@apollo/federation/dist/directives', + 'SchemaBuilder', + ); + return federationDirectives; + } } diff --git a/tests/e2e/typegraphql-federation.spec.ts b/tests/e2e/typegraphql-federation.spec.ts new file mode 100644 index 000000000..cf1de8ca6 --- /dev/null +++ b/tests/e2e/typegraphql-federation.spec.ts @@ -0,0 +1,42 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { ApplicationModule } from '../type-graphql-federation/app.module'; + +describe.skip('TypeGraphQL - Federation', () => { + let app: INestApplication; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [ApplicationModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + it(`should return query result`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: ` + { + _service { sdl } + }`, + }) + .expect(200, { + data: { + _service: { + sdl: + 'type Post @key(fields: "id") {\n id: ID!\n title: String!\n authorId: Int!\n}\n\ntype Query {\n findPost(id: Float!): Post!\n getPosts: [Post!]!\n}\n\ntype User @extends @key(fields: "id") {\n id: ID! @external\n posts: [Post!]!\n}\n', + }, + }, + }); + }); + + afterEach(async () => { + await app.close(); + }); +}); diff --git a/tests/type-graphql-federation/app.module.ts b/tests/type-graphql-federation/app.module.ts new file mode 100644 index 000000000..5542db0c4 --- /dev/null +++ b/tests/type-graphql-federation/app.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { GraphQLFederationModule } from '../../lib'; +import { UserModule } from './user/user.module'; +import { PostModule } from './post/post.module'; +import { User } from './user/user.entity'; + +@Module({ + imports: [ + UserModule, + PostModule, + GraphQLFederationModule.forRoot({ + debug: false, + autoSchemaFile: true, + buildSchemaOptions: { + orphanedTypes: [User], + }, + }), + ], +}) +export class ApplicationModule {} diff --git a/tests/type-graphql-federation/main.ts b/tests/type-graphql-federation/main.ts new file mode 100644 index 000000000..c04a543a1 --- /dev/null +++ b/tests/type-graphql-federation/main.ts @@ -0,0 +1,10 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { ApplicationModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(ApplicationModule); + app.useGlobalPipes(new ValidationPipe()); + await app.listen(3000); +} +bootstrap(); diff --git a/tests/type-graphql-federation/post/post.entity.ts b/tests/type-graphql-federation/post/post.entity.ts new file mode 100644 index 000000000..5cd68f897 --- /dev/null +++ b/tests/type-graphql-federation/post/post.entity.ts @@ -0,0 +1,18 @@ +import { Field, ID, ObjectType, Directive, Int } from 'type-graphql'; + +@ObjectType() +@Directive('@key(fields: "id")') +export class Post { + @Field(type => ID) + public id: number; + + @Field() + public title: string; + + @Field(type => Int) + public authorId: number; + + constructor(post: Partial) { + Object.assign(this, post); + } +} diff --git a/tests/type-graphql-federation/post/post.module.ts b/tests/type-graphql-federation/post/post.module.ts new file mode 100644 index 000000000..1cba92ef0 --- /dev/null +++ b/tests/type-graphql-federation/post/post.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PostService } from './post.service'; +import { PostResolver } from './post.resolver'; + +@Module({ + providers: [PostService, PostResolver], + exports: [PostService], +}) +export class PostModule {} diff --git a/tests/type-graphql-federation/post/post.resolver.ts b/tests/type-graphql-federation/post/post.resolver.ts new file mode 100644 index 000000000..c6a42e587 --- /dev/null +++ b/tests/type-graphql-federation/post/post.resolver.ts @@ -0,0 +1,23 @@ +import { Query, Args, ResolveReference, Resolver } from '../../../lib'; +import { PostService } from './post.service'; +import { Post } from './post.entity'; + +@Resolver(of => Post) +export class PostResolver { + constructor(private readonly postService: PostService) {} + + @Query(returns => Post) + public findPost(@Args('id') id: number) { + return this.postService.findOne(id); + } + + @Query(returns => [Post]) + public getPosts() { + return this.postService.all(); + } + + @ResolveReference() + public resolveRef(reference: any) { + return this.postService.findOne(reference.id); + } +} diff --git a/tests/type-graphql-federation/post/post.service.ts b/tests/type-graphql-federation/post/post.service.ts new file mode 100644 index 000000000..09f9a6a31 --- /dev/null +++ b/tests/type-graphql-federation/post/post.service.ts @@ -0,0 +1,34 @@ +import { Post } from './post.entity'; +import { Injectable } from '@nestjs/common'; + +const data = [ + { + id: 1, + title: 'hello world', + authorId: 2, + }, + { + id: 2, + title: 'lorem ipsum', + authorId: 1, + }, +]; + +@Injectable() +export class PostService { + public findOne(id: number) { + const post = data.find(p => p.id === id); + if (post) { + return new Post(post); + } + return null; + } + + public all() { + return data.map(p => new Post(p)); + } + + public forAuthor(authorId: number) { + return data.filter(p => p.authorId === authorId).map(p => new Post(p)); + } +} diff --git a/tests/type-graphql-federation/user/user.entity.ts b/tests/type-graphql-federation/user/user.entity.ts new file mode 100644 index 000000000..6b718d353 --- /dev/null +++ b/tests/type-graphql-federation/user/user.entity.ts @@ -0,0 +1,18 @@ +import { Field, ID, ObjectType, Directive } from 'type-graphql'; +import { Post } from '../post/post.entity'; + +@ObjectType() +@Directive('@extends') +@Directive('@key(fields: "id")') +export class User { + @Field(type => ID) + @Directive('@external') + public id: number; + + @Field(type => [Post]) + public posts: Post[]; + + constructor(post: Partial) { + Object.assign(this, post); + } +} diff --git a/tests/type-graphql-federation/user/user.module.ts b/tests/type-graphql-federation/user/user.module.ts new file mode 100644 index 000000000..502eb79af --- /dev/null +++ b/tests/type-graphql-federation/user/user.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UserResolver } from './user.resolver'; +import { PostModule } from '../post/post.module'; + +@Module({ + providers: [UserResolver], + imports: [PostModule], +}) +export class UserModule {} diff --git a/tests/type-graphql-federation/user/user.resolver.ts b/tests/type-graphql-federation/user/user.resolver.ts new file mode 100644 index 000000000..5ced2e1db --- /dev/null +++ b/tests/type-graphql-federation/user/user.resolver.ts @@ -0,0 +1,13 @@ +import { ResolveProperty, Parent, Resolver } from '../../../lib'; +import { PostService } from '../post/post.service'; +import { User } from './user.entity'; + +@Resolver(of => User) +export class UserResolver { + constructor(private readonly postService: PostService) {} + + @ResolveProperty() + public posts(@Parent() user: User) { + return this.postService.forAuthor(user.id); + } +}