diff --git a/packages/apollo/package.json b/packages/apollo/package.json index 7b2071bdc..4b17f4585 100644 --- a/packages/apollo/package.json +++ b/packages/apollo/package.json @@ -15,7 +15,8 @@ "url": "git+https://github.com/nestjs/graphql.git" }, "scripts": { - "test:e2e": "jest --config ./tests/jest-e2e.ts --runInBand", + "test:e2e": "jest --config ./tests/jest-e2e.ts --runInBand && yarn test:e2e:fed2", + "test:e2e:fed2": "jest --config ./tests/jest-e2e-fed2.ts --runInBand", "test:e2e:dev": "jest --config ./tests/jest-e2e.ts --runInBand --watch" }, "bugs": { @@ -23,6 +24,9 @@ }, "devDependencies": { "@apollo/gateway": "0.51.0", + "@apollo/gateway-v2": "npm:@apollo/gateway@2.0.5", + "@apollo/subgraph-v2": "npm:@apollo/subgraph@2.0.5", + "graphql-16": "npm:graphql@16.5.0", "@nestjs/common": "8.4.7", "@nestjs/core": "8.4.7", "@nestjs/platform-express": "8.4.7", diff --git a/packages/apollo/tests/code-first-graphql-federation2/gateway/gateway.module.ts b/packages/apollo/tests/code-first-graphql-federation2/gateway/gateway.module.ts new file mode 100644 index 000000000..f63bff186 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/gateway/gateway.module.ts @@ -0,0 +1,22 @@ +import { IntrospectAndCompose } from '@apollo/gateway-v2'; +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ApolloGatewayDriver, ApolloGatewayDriverConfig } from '../../../lib'; + +@Module({ + imports: [ + GraphQLModule.forRoot({ + driver: ApolloGatewayDriver, + gateway: { + debug: false, + supergraphSdl: new IntrospectAndCompose({ + subgraphs: [ + { name: 'users', url: 'http://localhost:3001/graphql' }, + { name: 'posts', url: 'http://localhost:3002/graphql' }, + ], + }), + }, + }), + ], +}) +export class AppModule {} diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/federation-posts.module.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/federation-posts.module.ts new file mode 100644 index 000000000..3fb5b9f69 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/federation-posts.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ApolloServerPluginInlineTraceDisabled } from 'apollo-server-core'; +import { ApolloDriverConfig } from '../../../lib'; +import { ApolloFederationDriver } from '../../../lib/drivers'; +import { PostsModule } from './posts/posts.module'; +import { User } from './posts/user.entity'; + +@Module({ + imports: [ + GraphQLModule.forRoot({ + driver: ApolloFederationDriver, + autoSchemaFile: { + useFed2: true, + }, + buildSchemaOptions: { + orphanedTypes: [User], + }, + plugins: [ApolloServerPluginInlineTraceDisabled()], + }), + PostsModule, + ], +}) +export class AppModule {} diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/post-type.enum.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/post-type.enum.ts new file mode 100644 index 000000000..4dada0208 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/post-type.enum.ts @@ -0,0 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum PostType { + IMAGE = 'IMAGE', + TEXT = 'TEXT', +} + +registerEnumType(PostType, { + name: 'PostType', +}); diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.entity.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.entity.ts new file mode 100644 index 000000000..92eca5657 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.entity.ts @@ -0,0 +1,23 @@ +import { Directive, Field, ID, ObjectType } from '@nestjs/graphql'; +import { PostType } from './post-type.enum'; + +@ObjectType() +@Directive('@key(fields: "id")') +export class Post { + @Field(() => ID) + id: string; + + @Field() + title: string; + + @Field() + body: string; + + userId: string; + + @Field({ nullable: true }) + publishDate: Date; + + @Field(() => PostType, { nullable: true }) + type: PostType; +} diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.module.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.module.ts new file mode 100644 index 000000000..9fa531c4b --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PostsResolvers } from './posts.resolvers'; +import { UsersResolvers } from './users.resolvers'; +import { PostsService } from './posts.service'; + +@Module({ + providers: [PostsResolvers, PostsService, UsersResolvers], +}) +export class PostsModule {} diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.resolvers.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.resolvers.ts new file mode 100644 index 000000000..e3e870487 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.resolvers.ts @@ -0,0 +1,42 @@ +import { + Args, + ID, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { PostType } from './post-type.enum'; +import { Post } from './posts.entity'; +import { PostsService } from './posts.service'; +import { User } from './user.entity'; + +@Resolver(Post) +export class PostsResolvers { + constructor(private readonly postsService: PostsService) {} + + @Query(() => [Post]) + getPosts( + @Args('type', { nullable: true, type: () => PostType }) type: PostType, + ) { + if (type) { + return this.postsService.findByType(type); + } else { + return this.postsService.findAll(); + } + } + + @Mutation(() => Post) + publishPost( + @Args('id', { type: () => ID }) id, + @Args('publishDate') publishDate: Date, + ) { + return this.postsService.publish(id, publishDate); + } + + @ResolveField('user', () => User, { nullable: true }) + getUser(@Parent() post: Post) { + return { __typename: 'User', id: post.userId }; + } +} diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.service.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.service.ts new file mode 100644 index 000000000..a9e5b9df8 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/posts.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { Post } from './posts.entity'; +import { PostType } from './post-type.enum'; + +@Injectable() +export class PostsService { + private readonly posts: Post[] = [ + { + id: '1', + title: 'HELLO WORLD', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + userId: '5', + publishDate: new Date(0), + type: PostType.TEXT, + }, + ]; + + findAll() { + return Promise.resolve(this.posts); + } + + findById(id: string) { + return Promise.resolve(this.posts.find((p) => p.id === id)); + } + + findByUserId(id: string) { + return Promise.resolve(this.posts.filter((p) => p.userId === id)); + } + + findByType(type: PostType) { + return Promise.resolve(this.posts.filter((p) => p.type === type)); + } + + async publish(id: string, publishDate: Date) { + const post = await this.findById(id); + post.publishDate = publishDate; + return post; + } +} diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/user.entity.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/user.entity.ts new file mode 100644 index 000000000..32f8dc0e0 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/user.entity.ts @@ -0,0 +1,8 @@ +import { Directive, Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +@Directive('@key(fields: "id")') +export class User { + @Field(() => ID) + id: string; +} diff --git a/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/users.resolvers.ts b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/users.resolvers.ts new file mode 100644 index 000000000..038eea3ea --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/posts-service/posts/users.resolvers.ts @@ -0,0 +1,14 @@ +import { ResolveField, Resolver } from '@nestjs/graphql'; +import { Post } from './posts.entity'; +import { PostsService } from './posts.service'; +import { User } from './user.entity'; + +@Resolver(User) +export class UsersResolvers { + constructor(private readonly postsService: PostsService) {} + + @ResolveField('posts', () => [Post]) + getPosts(reference: any) { + return this.postsService.findByUserId(reference.id); + } +} diff --git a/packages/apollo/tests/code-first-graphql-federation2/users-service/federation-users.module.ts b/packages/apollo/tests/code-first-graphql-federation2/users-service/federation-users.module.ts new file mode 100644 index 000000000..c7540c079 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/users-service/federation-users.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ApolloServerPluginInlineTraceDisabled } from 'apollo-server-core'; +import { ApolloDriverConfig } from '../../../lib'; +import { ApolloFederationDriver } from '../../../lib/drivers'; +import { UsersModule } from './users/users.module'; + +@Module({ + imports: [ + GraphQLModule.forRoot({ + driver: ApolloFederationDriver, + autoSchemaFile: { + useFed2: true, + }, + plugins: [ApolloServerPluginInlineTraceDisabled()], + }), + UsersModule, + ], +}) +export class AppModule {} diff --git a/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.entity.ts b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.entity.ts new file mode 100644 index 000000000..da6e24180 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.entity.ts @@ -0,0 +1,11 @@ +import { Directive, Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +@Directive('@key(fields: "id")') +export class User { + @Field(() => ID) + id: string; + + @Field() + name: string; +} diff --git a/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.module.ts b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.module.ts new file mode 100644 index 000000000..d71042e83 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UsersResolvers } from './users.resolvers'; +import { UsersService } from './users.service'; + +@Module({ + providers: [UsersResolvers, UsersService], +}) +export class UsersModule {} diff --git a/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.resolvers.ts b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.resolvers.ts new file mode 100644 index 000000000..284c0ec5a --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.resolvers.ts @@ -0,0 +1,18 @@ +import { Args, ID, Query, Resolver, ResolveReference } from '@nestjs/graphql'; +import { User } from './users.entity'; +import { UsersService } from './users.service'; + +@Resolver(User) +export class UsersResolvers { + constructor(private readonly usersService: UsersService) {} + + @Query(() => User, { nullable: true }) + getUser(@Args('id', { type: () => ID }) id: string) { + return this.usersService.findById(id); + } + + @ResolveReference() + resolveReference(reference: { __typename: string; id: string }) { + return this.usersService.findById(reference.id); + } +} diff --git a/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.service.ts b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.service.ts new file mode 100644 index 000000000..f5029f9b8 --- /dev/null +++ b/packages/apollo/tests/code-first-graphql-federation2/users-service/users/users.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { User } from './users.entity'; + +@Injectable() +export class UsersService { + private readonly users: User[] = [ + { + id: '5', + name: 'GraphQL', + }, + ]; + + findById(id: string) { + return Promise.resolve(this.users.find((p) => p.id === id)); + } +} diff --git a/packages/apollo/tests/e2e/code-first-graphql-federation2.fed2-spec.ts b/packages/apollo/tests/e2e/code-first-graphql-federation2.fed2-spec.ts new file mode 100644 index 000000000..37f578a29 --- /dev/null +++ b/packages/apollo/tests/e2e/code-first-graphql-federation2.fed2-spec.ts @@ -0,0 +1,184 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AppModule as PostsModule } from '../code-first-graphql-federation2/posts-service/federation-posts.module'; +import { AppModule as UsersModule } from '../code-first-graphql-federation2/users-service/federation-users.module'; + +describe('Code First GraphQL Federation 2', () => { + let app: INestApplication; + + describe('UsersService', () => { + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [UsersModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + it(`should return query result`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: ` + { + getUser(id: "5") { + id, + name, + } + }`, + }) + .expect(200, { + data: { + getUser: { + id: '5', + name: 'GraphQL', + }, + }, + }); + }); + + it('should resolve references', () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + variables: { + representations: [ + { + __typename: 'User', + id: '5', + }, + ], + }, + query: ` + query ($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on User { + id + name + } + } + }`, + }) + .expect(200, { + data: { + _entities: [ + { + __typename: 'User', + id: '5', + name: 'GraphQL', + }, + ], + }, + }); + }); + }); + + describe('PostsService', () => { + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [PostsModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + it(`should return query result`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: ` + { + getPosts { + id, + title, + body, + } + }`, + }) + .expect(200, { + data: { + getPosts: [ + { + id: '1', + title: 'HELLO WORLD', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + ], + }, + }); + }); + + it('should return a stripped reference', () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: ` + { + getPosts { + id, + title, + body, + user { + id + } + } + }`, + }) + .expect(200, { + data: { + getPosts: [ + { + id: '1', + title: 'HELLO WORLD', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + user: { + id: '5', + }, + }, + ], + }, + }); + }); + + it('should accept enum as query input', () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + variables: { + postType: 'TEXT', + }, + query: ` + query ($postType: PostType!) { + getPosts(type: $postType) { + id + type + } + }`, + }) + .expect(200, { + data: { + getPosts: [ + { + id: '1', + type: 'TEXT', + }, + ], + }, + }); + }); + }); + + afterEach(async () => { + await app.close(); + }); +}); diff --git a/packages/apollo/tests/e2e/code-first-graphql-gateway2.fed2-spec.ts b/packages/apollo/tests/e2e/code-first-graphql-gateway2.fed2-spec.ts new file mode 100644 index 000000000..85b73c08d --- /dev/null +++ b/packages/apollo/tests/e2e/code-first-graphql-gateway2.fed2-spec.ts @@ -0,0 +1,113 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AppModule as GatewayModule } from '../code-first-graphql-federation2/gateway/gateway.module'; +import { AppModule as PostsModule } from '../code-first-graphql-federation2/posts-service/federation-posts.module'; +import { AppModule as UsersModule } from '../code-first-graphql-federation2/users-service/federation-users.module'; + +describe('GraphQL Gateway with Federation 2', () => { + let postsApp: INestApplication; + let usersApp: INestApplication; + let gatewayApp: INestApplication; + + beforeAll(async () => { + const usersModule = await Test.createTestingModule({ + imports: [UsersModule], + }).compile(); + + usersApp = usersModule.createNestApplication(); + await usersApp.listen(3001); + + const postsModule = await Test.createTestingModule({ + imports: [PostsModule], + }).compile(); + + postsApp = postsModule.createNestApplication(); + await postsApp.listen(3002); + + const gatewayModule = await Test.createTestingModule({ + imports: [GatewayModule], + }).compile(); + + gatewayApp = gatewayModule.createNestApplication(); + await gatewayApp.init(); + }); + + it(`should run lookup across boundaries`, () => { + return request(gatewayApp.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: ` + { + getPosts { + id, + title, + body, + user { + id, + name, + } + } + }`, + }) + .expect(200, { + data: { + getPosts: [ + { + id: '1', + title: 'HELLO WORLD', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + user: { + id: '5', + name: 'GraphQL', + }, + }, + ], + }, + }); + }); + + it(`should run reverse lookup across boundaries`, () => { + return request(gatewayApp.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: ` + { + getUser(id: "5") { + id, + name, + posts { + id, + title, + body, + } + } + }`, + }) + .expect(200, { + data: { + getUser: { + id: '5', + name: 'GraphQL', + posts: [ + { + id: '1', + title: 'HELLO WORLD', + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + ], + }, + }, + }); + }); + + afterAll(async () => { + await postsApp.close(); + await usersApp.close(); + await gatewayApp.close(); + }); +}); diff --git a/packages/apollo/tests/jest-e2e-fed2.ts b/packages/apollo/tests/jest-e2e-fed2.ts new file mode 100644 index 000000000..c6bd5a4f4 --- /dev/null +++ b/packages/apollo/tests/jest-e2e-fed2.ts @@ -0,0 +1,36 @@ +import type { Config } from '@jest/types'; +import { pathsToModuleNameMapper } from 'ts-jest'; +import { compilerOptions } from '../tsconfig.spec.json'; + +const moduleNameMapper = pathsToModuleNameMapper(compilerOptions.paths, { + prefix: '/', +}); + +const config: Config.InitialOptions = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '../.', + testRegex: '.fed2-spec.ts$', + moduleNameMapper: { + ...moduleNameMapper, + '^@apollo/subgraph$': '/../../node_modules/@apollo/subgraph-v2', + '^@apollo/subgraph/(.*)$': + '/../../node_modules/@apollo/subgraph-v2/$1', + '^@apollo/gateway$': '/../../node_modules/@apollo/gateway-v2', + '^@apollo/gateway/(.*)$': + '/../../node_modules/@apollo/gateway-v2/$1', + '^graphql$': '/../../node_modules/graphql-16', + '^graphql/(.*)$': '/../../node_modules/graphql-16/$1', + }, + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + coverageDirectory: '../coverage', + testEnvironment: 'node', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, +}; + +export default config; diff --git a/packages/apollo/tests/jest-e2e.ts b/packages/apollo/tests/jest-e2e.ts index c2b5bf072..aebae50ea 100644 --- a/packages/apollo/tests/jest-e2e.ts +++ b/packages/apollo/tests/jest-e2e.ts @@ -10,6 +10,7 @@ const config: Config.InitialOptions = { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: '../.', testRegex: '.spec.ts$', + testPathIgnorePatterns: ['.fed2-spec.ts$'], moduleNameMapper, transform: { '^.+\\.(t|j)s$': 'ts-jest', diff --git a/packages/graphql/lib/federation/graphql-federation.factory.ts b/packages/graphql/lib/federation/graphql-federation.factory.ts index b24d5b94b..6bb815bad 100644 --- a/packages/graphql/lib/federation/graphql-federation.factory.ts +++ b/packages/graphql/lib/federation/graphql-federation.factory.ts @@ -1,5 +1,6 @@ import { mergeSchemas } from '@graphql-tools/schema'; -import { Injectable } from '@nestjs/common'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { Injectable, Logger } from '@nestjs/common'; import { loadPackage } from '@nestjs/common/utils/load-package.util'; import { isString } from '@nestjs/common/utils/shared.utils'; import { @@ -25,9 +26,13 @@ import { gql } from 'graphql-tag'; import { forEach, isEmpty } from 'lodash'; import { GraphQLSchemaBuilder } from '../graphql-schema.builder'; import { GraphQLSchemaHost } from '../graphql-schema.host'; -import { GqlModuleOptions, BuildFederatedSchemaOptions } from '../interfaces'; +import { + GqlModuleOptions, + BuildFederatedSchemaOptions, + AutoSchemaFileValue, +} from '../interfaces'; import { ResolversExplorerService, ScalarsExplorerService } from '../services'; -import { extend } from '../utils'; +import { extend, getFederation2Info, stringifyWithoutQuotes } from '../utils'; import { transformSchema } from '../utils/transform-schema.util'; @Injectable() @@ -93,7 +98,11 @@ export class GraphQLFederationFactory { 'ApolloFederation', () => require('@apollo/subgraph'), ); + const apolloSubgraphVersion = ( + await import('@apollo/subgraph/package.json') + ).version; + const isApolloSubgraph2 = Number(apolloSubgraphVersion.split('.')[0]) >= 2; const printSubgraphSchema = apolloSubgraph.printSubgraphSchema; if (!buildFederatedSchema) { @@ -105,9 +114,49 @@ export class GraphQLFederationFactory { options, this.resolversExplorerService.getAllCtors(), ); + let typeDefs = isApolloSubgraph2 + ? printSchemaWithDirectives(autoGeneratedSchema) + : printSubgraphSchema(autoGeneratedSchema); + + const useFed2 = getFederation2Info(options.autoSchemaFile); + + if (useFed2 && isApolloSubgraph2) { + const { + directives = [ + '@key', + '@shareable', + '@external', + '@override', + '@requires', + ], + importUrl = 'https://specs.apollo.dev/federation/v2.0', + } = typeof useFed2 === 'boolean' ? {} : useFed2; + const mappedDirectives = directives + .map((directive) => { + if (!isString(directive)) { + return stringifyWithoutQuotes(directive); + } + let finalDirective = directive; + if (!directive.startsWith('@')) { + finalDirective = `@${directive}`; + } + return `"${finalDirective}"`; + }) + .join(', '); + + typeDefs = ` + extend schema @link(url: "${importUrl}", import: [${mappedDirectives}]) + ${typeDefs} + `; + } else if (useFed2 && !isApolloSubgraph2) { + Logger.error( + 'You are trying to use Apollo Federation 2 but you are not using @apollo/subgraph@^2.0.0, please upgrade', + 'GraphQLFederationFactory', + ); + } let executableSchema: GraphQLSchema = buildFederatedSchema({ - typeDefs: gql(printSubgraphSchema(autoGeneratedSchema)), + typeDefs: gql(typeDefs), resolvers: this.getResolvers(options.resolvers), }); @@ -201,7 +250,10 @@ export class GraphQLFederationFactory { ...autoGeneratedObjectType.astNode, }; } - type.extensions = autoGeneratedObjectType.extensions; + type.extensions = { + ...type.extensions, + ...autoGeneratedObjectType.extensions, + }; return type; } else if (isScalarType(type) && type.name === 'DateTime') { const autoGeneratedScalar = autoGeneratedSchema.getType( @@ -282,13 +334,14 @@ export class GraphQLFederationFactory { } async buildFederatedSchema( - autoSchemaFile: string | boolean, + autoSchemaFile: AutoSchemaFileValue, options: T, resolvers: Function[], ) { const scalarsMap = this.scalarsExplorerService.getScalarsMap(); try { const buildSchemaOptions = options.buildSchemaOptions || {}; + const useFed2 = getFederation2Info(options.autoSchemaFile); return await this.gqlSchemaBuilder.generateSchema( resolvers, autoSchemaFile, @@ -296,7 +349,7 @@ export class GraphQLFederationFactory { ...buildSchemaOptions, directives: [ ...specifiedDirectives, - ...this.loadFederationDirectives(), + ...(useFed2 ? [] : this.loadFederationDirectives()), ...((buildSchemaOptions && buildSchemaOptions.directives) || []), ], scalarsMap, diff --git a/packages/graphql/lib/graphql-schema.builder.ts b/packages/graphql/lib/graphql-schema.builder.ts index f1ba0d70f..557dfc136 100644 --- a/packages/graphql/lib/graphql-schema.builder.ts +++ b/packages/graphql/lib/graphql-schema.builder.ts @@ -3,11 +3,12 @@ import { isString } from '@nestjs/common/utils/shared.utils'; import { GraphQLSchema, lexicographicSortSchema, printSchema } from 'graphql'; import { resolve } from 'path'; import { GRAPHQL_SDL_FILE_HEADER } from './graphql.constants'; -import { GqlModuleOptions } from './interfaces'; +import { AutoSchemaFileValue, GqlModuleOptions } from './interfaces'; import { BuildSchemaOptions } from './interfaces/build-schema-options.interface'; import { GraphQLSchemaFactory } from './schema-builder/graphql-schema.factory'; import { FileSystemHelper } from './schema-builder/helpers/file-system.helper'; import { ScalarsExplorerService } from './services'; +import { getPathForAutoSchemaFile } from './utils'; @Injectable() export class GraphQLSchemaBuilder { @@ -18,7 +19,7 @@ export class GraphQLSchemaBuilder { ) {} public async build( - autoSchemaFile: string | boolean, + autoSchemaFile: AutoSchemaFileValue, options: GqlModuleOptions, resolvers: Function[], ): Promise { @@ -45,7 +46,7 @@ export class GraphQLSchemaBuilder { public async generateSchema( resolvers: Function[], - autoSchemaFile: boolean | string, + autoSchemaFile: AutoSchemaFileValue, options: BuildSchemaOptions = {}, sortSchema?: boolean, transformSchema?: ( @@ -53,11 +54,9 @@ export class GraphQLSchemaBuilder { ) => GraphQLSchema | Promise, ): Promise { const schema = await this.gqlSchemaFactory.create(resolvers, options); - if (typeof autoSchemaFile !== 'boolean') { - const filename = isString(autoSchemaFile) - ? autoSchemaFile - : resolve(process.cwd(), 'schema.gql'); + const filename = getPathForAutoSchemaFile(autoSchemaFile); + if (filename) { const transformedSchema = transformSchema ? await transformSchema(schema) : schema; diff --git a/packages/graphql/lib/interfaces/gql-module-options.interface.ts b/packages/graphql/lib/interfaces/gql-module-options.interface.ts index bd81dbac7..b491fd160 100644 --- a/packages/graphql/lib/interfaces/gql-module-options.interface.ts +++ b/packages/graphql/lib/interfaces/gql-module-options.interface.ts @@ -5,6 +5,7 @@ import { GraphQLSchema } from 'graphql'; import { GraphQLDriver } from '.'; import { DefinitionsGeneratorOptions } from '../graphql-ast.explorer'; import { BuildSchemaOptions } from './build-schema-options.interface'; +import { AutoSchemaFileValue } from './schema-file-config.interface'; export type Enhancer = 'guards' | 'interceptors' | 'filters'; @@ -63,7 +64,7 @@ export interface GqlModuleOptions { /** * If enabled, GraphQL schema will be generated automatically */ - autoSchemaFile?: string | boolean; + autoSchemaFile?: AutoSchemaFileValue; /** * Sort the schema lexicographically diff --git a/packages/graphql/lib/interfaces/index.ts b/packages/graphql/lib/interfaces/index.ts index 1fcfea1bb..601594515 100644 --- a/packages/graphql/lib/interfaces/index.ts +++ b/packages/graphql/lib/interfaces/index.ts @@ -14,3 +14,4 @@ export * from './graphql-driver.interface'; export * from './resolve-type-fn.interface'; export * from './return-type-func.interface'; export * from './build-federated-schema-options.interface'; +export * from './schema-file-config.interface'; diff --git a/packages/graphql/lib/interfaces/schema-file-config.interface.ts b/packages/graphql/lib/interfaces/schema-file-config.interface.ts new file mode 100644 index 000000000..f7f13c703 --- /dev/null +++ b/packages/graphql/lib/interfaces/schema-file-config.interface.ts @@ -0,0 +1,40 @@ +export interface AliasDirectiveImport { + name: string; + as: string; +} + +export interface Federation2Config { + /** + * The imported directives + * @default ['@key', '@shareable', '@external', '@override', '@requires'] + */ + directives?: (string | AliasDirectiveImport)[]; + /** + * The import link + * @default 'https://specs.apollo.dev/federation/v2.0' + */ + importUrl?: string; +} + +export type UseFed2Value = boolean | Federation2Config; + +export interface SchemaFileConfig { + /** + * If enabled, it will use federation 2 schema + * + * **Note:** You need to have installed @apollo/subgraph@^2.0.0 and enable `autoSchemaFile` + * + * This will add to your schema: + * ```graphql + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable", "@external", "@override", "@requires"]) + * ``` + */ + useFed2?: UseFed2Value; + + /** + * The path to the schema file + */ + path?: string; +} + +export type AutoSchemaFileValue = boolean | string | SchemaFileConfig; diff --git a/packages/graphql/lib/utils/auto-schema-file.util.ts b/packages/graphql/lib/utils/auto-schema-file.util.ts new file mode 100644 index 000000000..571b684d7 --- /dev/null +++ b/packages/graphql/lib/utils/auto-schema-file.util.ts @@ -0,0 +1,23 @@ +import { isString, isObject } from '@nestjs/common/utils/shared.utils'; +import { AutoSchemaFileValue, UseFed2Value } from '../interfaces'; + +export function getPathForAutoSchemaFile( + autoSchemaFile: AutoSchemaFileValue, +): string | null { + if (isString(autoSchemaFile)) { + return autoSchemaFile; + } + if (isObject(autoSchemaFile) && autoSchemaFile.path) { + return autoSchemaFile.path; + } + return null; +} + +export function getFederation2Info( + autoSchemaFile: AutoSchemaFileValue, +): UseFed2Value { + if (isObject(autoSchemaFile)) { + return autoSchemaFile.useFed2; + } + return false; +} diff --git a/packages/graphql/lib/utils/index.ts b/packages/graphql/lib/utils/index.ts index 663519e73..bf6c21036 100644 --- a/packages/graphql/lib/utils/index.ts +++ b/packages/graphql/lib/utils/index.ts @@ -1,6 +1,8 @@ +export * from './auto-schema-file.util'; export * from './extend.util'; export * from './extract-metadata.util'; export * from './generate-token.util'; export * from './get-number-of-arguments.util'; export * from './normalize-route-path.util'; +export * from './object.util'; export * from './remove-temp.util'; diff --git a/packages/graphql/lib/utils/object.util.ts b/packages/graphql/lib/utils/object.util.ts new file mode 100644 index 000000000..f9f72efaa --- /dev/null +++ b/packages/graphql/lib/utils/object.util.ts @@ -0,0 +1,13 @@ +export function stringifyWithoutQuotes(obj: object, includeSpaces?: boolean) { + let result = includeSpaces + ? JSON.stringify(obj, null, 2) + : JSON.stringify(obj); + result = result + .replace(/"([^"]+)":/g, '$1:') + .replace(/(?({|,|:))/g, '$ '); + + if (!includeSpaces) { + result = result.replace(/}/g, ' }'); + } + return result; +} diff --git a/packages/graphql/tests/utils/function.utils.spec.ts b/packages/graphql/tests/utils/function.utils.spec.ts index f882d4c7a..eb4b17055 100644 --- a/packages/graphql/tests/utils/function.utils.spec.ts +++ b/packages/graphql/tests/utils/function.utils.spec.ts @@ -1,4 +1,4 @@ -import { getNumberOfArguments } from '../../lib/utils'; +import { getNumberOfArguments, stringifyWithoutQuotes } from '../../lib/utils'; describe('getNumberOfArguments', () => { describe('when using function', () => { @@ -145,3 +145,14 @@ describe('getNumberOfArguments', () => { }); }); }); + +describe('stringifyWithoutQuotes', () => { + it('should stringify object correctly', () => { + const obj = { + name: '@tag', + as: '@mytag', + }; + + expect(stringifyWithoutQuotes(obj)).toBe('{ name: "@tag", as: "@mytag" }'); + }); +}); diff --git a/yarn.lock b/yarn.lock index b6c22e650..ab3cbb369 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,11 +9,35 @@ dependencies: "@jridgewell/trace-mapping" "^0.3.0" +"@apollo/composition@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@apollo/composition/-/composition-2.0.5.tgz#c82f05575dfd154570e92c3771e7a0d2d9324e28" + integrity sha512-8rohdLt6fuYST/4/wC8RV1p0+trHPWI5vgkozwNEXvJ75CpZy/tUdxPYVDjWNb4wiJqH8Xh0ogb59tSk0vD9Rw== + dependencies: + "@apollo/federation-internals" "^2.0.5" + "@apollo/query-graphs" "^2.0.5" + "@apollo/core-schema@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@apollo/core-schema/-/core-schema-0.2.0.tgz#4eb529ee27553eeb6c18cd27eb0b94f2bd8b22a8" integrity sha512-bhzZMIyzP3rynXwtUuEt2ENJIgKd9P/iR98VsuA3tOyYdWPjD5BfsrdWO0oIJXW/pjbbr0oHX5gqutFRKYuwAA== +"@apollo/core-schema@~0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@apollo/core-schema/-/core-schema-0.3.0.tgz#c6c1a6821fb326501d73eb85d920bbf57906cc77" + integrity sha512-v+Ys6+W1pDQu+XwP++tI5y4oZdzKrEuVXwsEenOmg2FO/3/G08C+qMhQ9YQ9Ug34rvQGQtgbIDssHEVk4YZS7g== + dependencies: + "@protoplasm/recall" "^0.2" + +"@apollo/federation-internals@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@apollo/federation-internals/-/federation-internals-2.0.5.tgz#a96d6d73247e9a3deac6851937c6b8bcc632ced4" + integrity sha512-UbkNNOtzp1N14ZR6ynjwctF6rXEvAnlDz527cUYN0mkYkyH8v0RZNA1WfhVdcPWsTpjuvAR7Q5cDEf3e2dYcEQ== + dependencies: + "@apollo/core-schema" "~0.3.0" + chalk "^4.1.0" + js-levenshtein "^1.1.6" + "@apollo/federation@^0.36.2": version "0.36.2" resolved "https://registry.yarnpkg.com/@apollo/federation/-/federation-0.36.2.tgz#bc37e668cb541dfdd0d76eeb20e9b39e8a29365c" @@ -23,6 +47,32 @@ apollo-server-types "^3.0.2" lodash.xorby "^4.7.0" +"@apollo/gateway-v2@npm:@apollo/gateway@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@apollo/gateway/-/gateway-2.0.5.tgz#735ebb4a2ab96a7e11077912018d5bee31cd7aab" + integrity sha512-bTHZohLqyGw1GDUvV5Kje1dVXDEANWgmwtfHsE1o5dYXx7AfU48ts8b5dzQVUfDQ/PlsWsf1YT6MnY5PWKVl8A== + dependencies: + "@apollo/composition" "^2.0.5" + "@apollo/core-schema" "~0.3.0" + "@apollo/federation-internals" "^2.0.5" + "@apollo/query-planner" "^2.0.5" + "@apollo/utils.createhash" "^1.0.0" + "@apollo/utils.fetcher" "^1.0.0" + "@apollo/utils.isnodelike" "^1.0.0" + "@apollo/utils.logger" "^1.0.0" + "@josephg/resolvable" "^1.0.1" + "@opentelemetry/api" "^1.0.1" + "@types/node-fetch" "2.6.1" + apollo-reporting-protobuf "^0.8.0 || ^3.0.0" + apollo-server-caching "^0.7.0 || ^3.0.0" + apollo-server-core "^2.23.0 || ^3.0.0" + apollo-server-errors "^2.5.0 || ^3.0.0" + apollo-server-types "^0.9.0 || ^3.0.0" + async-retry "^1.3.3" + loglevel "^1.6.1" + make-fetch-happen "^10.1.2" + pretty-format "^27.0.0" + "@apollo/gateway@0.51.0": version "0.51.0" resolved "https://registry.yarnpkg.com/@apollo/gateway/-/gateway-0.51.0.tgz#b3f44653474f7a5f7771349928bc02ab1660db3b" @@ -66,6 +116,15 @@ "@types/node" "^10.1.0" long "^4.0.0" +"@apollo/query-graphs@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@apollo/query-graphs/-/query-graphs-2.0.5.tgz#3c09d4fdb5f8e7675d386a79fa49d61b40d81bab" + integrity sha512-YfPMiskZ8NxSyMeHAeL2jWuOfCf1SFxsZohFra3bVB/TSOrlYmjiNGL8u2lylSXJp5A6J08PCzvZCz6ND1L2Pw== + dependencies: + "@apollo/federation-internals" "^2.0.5" + deep-equal "^2.0.5" + ts-graphviz "^0.16.0" + "@apollo/query-planner@^0.10.2": version "0.10.2" resolved "https://registry.yarnpkg.com/@apollo/query-planner/-/query-planner-0.10.2.tgz#e71d4bf794787229da49f23421a6a9253dcc9775" @@ -75,6 +134,24 @@ deep-equal "^2.0.5" pretty-format "^27.4.6" +"@apollo/query-planner@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@apollo/query-planner/-/query-planner-2.0.5.tgz#cd0e72295a901a248b828f02d1e03f6e4ea2b79e" + integrity sha512-9+XiG+tdR41mZEDlSqQlkpznKGTNvJOtBzqiIS4mYX4PqGUVG83paFuIjNA2eihSp0TnxgjxHFX8k2yQMdHaGQ== + dependencies: + "@apollo/federation-internals" "^2.0.5" + "@apollo/query-graphs" "^2.0.5" + chalk "^4.1.0" + deep-equal "^2.0.5" + pretty-format "^27.0.0" + +"@apollo/subgraph-v2@npm:@apollo/subgraph@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@apollo/subgraph/-/subgraph-2.0.5.tgz#79bd284da1b9ba1196e7de9973654c878f21ae88" + integrity sha512-bnxxoYmlalYJ5wiE2iOYae6bc9fHKik0zOHGcfTLpIm/T3l+1J0Gj5QIVYOs7u/agduo3VGjm8MQbvrtNHJExg== + dependencies: + "@apollo/federation-internals" "^2.0.5" + "@apollo/subgraph@0.4.2", "@apollo/subgraph@^0.4.2": version "0.4.2" resolved "https://registry.yarnpkg.com/@apollo/subgraph/-/subgraph-0.4.2.tgz#f8516f1d5e5fbd57189727497ed9f805fa5017c8" @@ -2329,6 +2406,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= +"@protoplasm/recall@^0.2": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@protoplasm/recall/-/recall-0.2.4.tgz#d7b1a5f1e94481d015e9666bd791e57d51b8cc67" + integrity sha512-+w5WCHQwuZ0RZ3+ayJ42ArGPIeew2Wxb+g1rPNA+qiehCc4EDTDjW7DyPPq9FBa4lFUDC4mgDytV8Fkxe/Z3iQ== + "@sinclair/typebox@^0.23.3": version "0.23.4" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.23.4.tgz#6ff93fd2585ce44f7481c9ff6af610fbb5de98a4" @@ -5802,6 +5884,11 @@ graceful-fs@^4.2.9: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== +"graphql-16@npm:graphql@16.5.0": + version "16.5.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.5.0.tgz#41b5c1182eaac7f3d47164fb247f61e4dfb69c85" + integrity sha512-qbHgh8Ix+j/qY+a/ZcJnFQ+j8ezakqPiHwPiZhV/3PgGlgf96QMBB5/f2rkiC9sgLoy/xvT6TSiaf2nTHJh5iA== + graphql-jit@^0.7.0: version "0.7.3" resolved "https://registry.yarnpkg.com/graphql-jit/-/graphql-jit-0.7.3.tgz#cdc14d93efb8aba75cafb0be263cbafd4881d4d9" @@ -7017,6 +7104,11 @@ jest@28.1.1: import-local "^3.0.2" jest-cli "^28.1.1" +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -10225,6 +10317,11 @@ trim-newlines@^3.0.0: resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== +ts-graphviz@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-0.16.0.tgz#7a6e6b5434854bc90ab861e70d5af0d6d20729a7" + integrity sha512-3fTPO+G6bSQNvMh/XQQzyiahVLMMj9kqYO99ivUraNJ3Wp05HZOOVtRhi6w9hq7+laP1MKHjLBtGWqTeb1fcpg== + ts-invariant@^0.4.0: version "0.4.4" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"