Skip to content

Commit

Permalink
feat: add code-first support for grahql federation
Browse files Browse the repository at this point in the history
Blocked until support for Directives lands in type-graphql[1]

1. MichalLytek/type-graphql#351
  • Loading branch information
rickdgeerling authored and Rick Dutour Geerling committed Jan 20, 2020
1 parent bc4be48 commit ef2e409
Show file tree
Hide file tree
Showing 13 changed files with 314 additions and 38 deletions.
17 changes: 7 additions & 10 deletions lib/external/type-graphql.types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,12 +8,7 @@ import { GraphQLScalarType } from 'graphql';
* see: https://github.com/19majkel94/type-graphql
* 0.16.0
*/
export type TypeValue =
| Type<any>
| GraphQLScalarType
| Function
| object
| symbol;
export type TypeValue = Type<any> | GraphQLScalarType | Function | object | symbol;
export type ReturnTypeFuncValue = TypeValue | [TypeValue];
export type ReturnTypeFunc = (returns?: void) => ReturnTypeFuncValue;
export type NullableListOptions = 'items' | 'itemsAndList';
Expand All @@ -38,12 +33,14 @@ export interface ResolverClassOptions {
}
export type ClassTypeResolver = (of?: void) => Type<any>;
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 {
Expand Down
89 changes: 68 additions & 21 deletions lib/graphql-federation.factory.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,100 @@
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 {
constructor(
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<GqlModuleOptions> {
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<GqlModuleOptions> {
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<GraphQLSchema> {
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), {});
}
}
50 changes: 43 additions & 7 deletions lib/graphql-schema-builder.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
}
}
42 changes: 42 additions & 0 deletions tests/e2e/typegraphql-federation.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
20 changes: 20 additions & 0 deletions tests/type-graphql-federation/app.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
10 changes: 10 additions & 0 deletions tests/type-graphql-federation/main.ts
Original file line number Diff line number Diff line change
@@ -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();
18 changes: 18 additions & 0 deletions tests/type-graphql-federation/post/post.entity.ts
Original file line number Diff line number Diff line change
@@ -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<Post>) {
Object.assign(this, post);
}
}
9 changes: 9 additions & 0 deletions tests/type-graphql-federation/post/post.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
23 changes: 23 additions & 0 deletions tests/type-graphql-federation/post/post.resolver.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
34 changes: 34 additions & 0 deletions tests/type-graphql-federation/post/post.service.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading

0 comments on commit ef2e409

Please sign in to comment.