NestJS: GraphQL Schema First

Dung Tran
7 min readAug 27, 2019

--

Nhắc đến GraphQL chắc ai cũng biết Apollo, và do đó nên NestJS đã dùng Apollo như một module chính. Trong GraphQL có 2 phần là schema và resolver và Apollo định nghĩa cấu trúc file schema dựa trên những file text (thường là .gql, .graphql). Từ các file GraphQL SDL (Schema Definition Language) đó, chúng ta sẽ tạo ra các file model cho Javascript hoặc TypeScript để sử dụng trong code.

Trước version 5 của NestJS thì NestJS chỉ hỗ trợ cho Apollo theo dạng sử dụng các file graphQL để định nghĩa schema. Và hiện tại Nest gọi đó là schema-first.

Config

Trong nội dung bài viết này tôi sử dụng repo bên dưới để thực hành cách cấu hình một GraphQL module trong NestJS, đây là một repo sử dụng cấu trúc monorepo và universal

Module GraphQL

Chúng ta dùng Nest CLI để tạo ra module hỗ trợ GraphQL. Sau khi dùng NestJS cli thì chỉ tạo ra thư mục và một file module. Cho nên tự thêm vào file package.json và tsconfig.json.

$ cd packages
$ nest generate mo module-graphql
$ cd module-graphql
$ mkdir src
$ cp module-graphql.module.ts src/

Cài đặt các packages cần thiết cho Nest GraphQL

yarn add @nestjs/common @nestjs/core @nestjs/graphql apollo-server-express graphql-tools graphql class-validator class-transformer

Cấu hình pakage.json cho repo module-graphql

{
"name": "@mono/module-graphql",
"version": "0.1.0",
"main": "src/module-graphql.module.ts",
"license": "MIT",
"private": true,
"publishConfig": {},
"scripts": {
"generate-barrels": "barrelsby --delete -d src -l top"
},
"dependencies": {
"@mono/interfaces": "0.1.0",
"@nestjs/common": "^6.5.3",
"@nestjs/core": "^6.5.3",
"@nestjs/graphql": "^6.4.2",
"apollo-server-express": "^2.8.2",
"class-transformer": "^0.2.3",
"class-validator": "^0.10.0",
"graphql": "^14.5.0",
"graphql-tools": "^4.0.5"
},
"devDependencies": {
"@types/jest": "^24.0.18",
"barrelsby": "^2.1.1",
"jest": "^24.9.0",
"prettier": "^1.18.2",
"tslint": "^5.19.0",
"tslint-config-prettier": "^1.18.0",
"tslint-plugin-prettier": "^2.0.1",
"typescript": "^3.5.3"
}
}

Cấu hình tsconfig.json cho module-graphql

//tsconfig.json
{
"extends": "../../tsconfig.json",
"exclude": ["node_modules", "dist"]
}

Nest Application

Thêm repo module-graphql vào package.json của repo server

//server/package.json"dependencies": {
"@mono/entities": "0.1.0",
"@mono/interfaces": "0.1.0",
"@mono/models": "0.1.0",
"@mono/module-database": "0.1.0",
"@mono/module-graphql": "0.1.0", // add module-graphql
"@nestjs/common": "^6.5.3",
"@nestjs/core": "^6.5.3",
"@nestjs/platform-express": "^6.5.3",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.0",
"rxjs": "^6.5.2"
},

Cài đặt module-graphql vào repo server

$ lerna bootstrap

hoặc

$ yarn bootstrap

Cấu hình tsconfig.json trong repo server

//server/tsconfig.json
"references": [
{
"path": "../module-graphql/src"
}
]

Nhét module-graphql vào NestJS application

//packages/server/src/app.module.ts
import {ModuleGraphqlModule} from "@mono/module-graphql";

@Module({
imports: [ModuleGraphqlModule],
controllers: [],
providers: [],
})
export class AppModule {}

Coding

Bây giờ chúng ta quay lại repo module-graphql để cấu hình cho NestJS và Apollo

Chúng ta tạo riêng một module chỉ để xử lý cho một object là Post.

mkdir post-module

Cấu trúc của NestJS là một dạng module pattern nên việc chia nhỏ và quản lý code sẽ dễ dàng hơn. Đặc biệt, chúng ta đang sử dụng dạng monorepo-universal nên việc chia nhỏ module sẽ tiện cho việc tách các module thành repo. Tạo file .graphql trong post-module

//post-module/post.graphql
type Query {
getPosts: [Post]
post(id: ID!): Post
}

type Mutation {
createPost(createPostInput: CreatePostInput): Post
votePost(votePostInput: VotePostInput): Post
}

type Subscription {
postCreated: Post
postVoted: Post
}

type Post {
id: Int
title: String
vote: Int
}

input CreatePostInput {
title: String
}

input VotePostInput {
id:Int
vote: Int
}

Khi dùng schema-first trong NestJS, khi khai báo GraphQLModule trong @nestjs/graphql thì NestJS có thể tự động tạo file typescript cho graphQL, dưới đây là cấu hình cho auto generate

GraphQLModule.forRoot({
typePaths: ['./**/*.graphql'],
definitions: {
path: join(process.cwd(), 'src/graphql.ts'),
},
}),

Tuy nhiên trong một vài trường hợp có thể xảy ra vấn đề bất đồng bộ giữa các module như database, service … vì khi GraphQLModule chưa generate code xong thì các module này đã tham chiếu đến.

Cho nên cách an toàn nhất là tạo một file script để generate bằng tay, ở đây tôi sử dụng lại hướng dẫn của NestJS, đó là tạo một file generate-typings.ts

//src/generate-typings.tsimport { GraphQLDefinitionsFactory } from '@nestjs/graphql';
import { join } from 'path';

const definitionsFactory = new GraphQLDefinitionsFactory();
definitionsFactory.generate({
typePaths: ['../**/*.graphql'],
/* chuyển từ './src/**/*.graphql' để có thể kiểm tra nhiều external module*/
path: join(process.cwd(), 'src/graphql.schema.ts'),
outputAs: 'class',
});

Tạo file .ts định nghĩa cho graphQL

$ ts-node src/generate-typings.ts

Đến bước này chúng ta sẽ tạo được một file graphql.schema.ts, file này sẽ tập hợp tất cả các file .graphql và chứa các model, input, query …

Tạo file resolver cho post-module

//post-module/post.resolver 
import { ParseIntPipe, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
import {Post} from '../graphql.schema';
import { PostGuard } from './post.guard';
import { PostService } from './post.service';
import { CreatePostDto } from './create.post.dto';
import { VotePostDto } from './vote.post.dto';


const pubSub = new PubSub();

@Resolver('Post')
export class PostResolvers {
constructor(private readonly postService: PostService) {}

@Query('getPosts')
@UseGuards(PostGuard)
async getPosts() {
return await this.postService.findAll();
}

@Query('post')
async findOneById(
@Args('id', ParseIntPipe)
id: number,
): Promise<Post> {
return await this.postService.findOneById(id);
}

@Mutation('createPost')
async create(@Args('createPostInput') args: CreatePostDto): Promise<Post> {
const createdPost = await this.postService.create(args);
pubSub.publish('postCreated', { postCreated: createdPost });
return createdPost;
}
@Mutation('votePost')
async vote(@Args('votePostInput') args: VotePostDto): Promise<Post> {
const votedPost = await this.postService.vote(args);
pubSub.publish('postVoted', { postVoted: votedPost });
return votedPost;
}

@Subscription('postCreated')
postCreated() {
return pubSub.asyncIterator('postCreated');
}
@Subscription('postVoted')
postVoted() {
return pubSub.asyncIterator('postVoted');
}
}

Tạo file service cho post-module. Trong nội dung bài viết sẽ không kết nối đến database mà chỉ tạo mock data trong file service. Vì sử dụng schema-first sẽ có nhiều bất tiện với việc tương tác với entity hơn là với code-first.

import {Injectable} from '@nestjs/common';
import {Post} from '../graphql.schema';

@Injectable()
export class PostService {
private readonly post: Post[] = [{id: 1, title: 'Post', vote: 5}];

create(post: Post): Post {
const item = {...post, vote: 0, id: this.post.length + 1}
this.post.push(item);
return item;
}

vote(post: Post): Post {
const item = this.post.find(item => item.id == post.id)
item.vote += post.vote
return item;
}

findAll(): Post[] {
return this.post;
}

findOneById(id: number): Post {
return this.post.find(post => post.id === id);
}
}

Cuối cùng đến lúc cấu hình cho các module.

//src/post-module/post.module.tsimport { Module } from '@nestjs/common';
import { PostResolvers } from './post.resolver';
import { PostService } from './post.service';

@Module({
providers: [PostService, PostResolvers],
})
export class PostModule {}

Cấu hình cho file module-graphql.module.ts của repo module-graphql

//src/module-graphql.module.tsimport {Module} from '@nestjs/common';
import {GraphQLModule} from '@nestjs/graphql';
import {PostModule} from "./post-module/post.module";

console.log(process.cwd())
@Module({
imports: [PostModule, GraphQLModule.forRoot({
typePaths: ['../**/*.graphql'],/* for external graphql, because the the module is run in the repo server context*/
installSubscriptionHandlers: true,
})],
controllers: [],
providers: []
})
export class ModuleGraphqlModule {
}

Start repo server để test graphQL module, sau đó truy cập playground tại: http://localhost:3000/graphql

$ cd servers
$ yarn start

Dưới đây là link codesandbox, truy cập vào /graphql để vào playground

GraphQL Schema First on codesandbox.io

Test

Unit test

Giờ ta làm phần test cho module-graphql, tạo file .spec.ts để test unit

//post-module/post.resolver.tsimport {Test, TestingModule} from '@nestjs/testing';
import {PostResolver} from './post.resolver';
import {PostService} from './post.service';

describe('PostResolver', () => {
let resolver: PostResolver;
let service: PostService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PostResolver, PostService],
}).compile();

resolver = module.get<PostResolver>(PostResolver);
service = module.get<PostService>(PostService);
});

it('should be defined', () => {
expect(resolver).toBeDefined();
});

it('post created', async () => {
const postCreated = await resolver.create({title: "Post 1"})
expect(postCreated).toBeDefined()
expect(postCreated.id).toBeGreaterThan(1)
expect(postCreated.vote).toEqual(0)

});

it('post voted', async () => {
const vote=service.findOneById(1).vote
const voted=vote+1

const postVoted = await resolver.vote({id:1,vote:1})
expect(postVoted).toBeDefined()
expect(postVoted.id).toBeGreaterThan(0)
expect(postVoted.vote).toEqual(voted)

});
});

Ở đây chỉ tạo mẫu file spec cho post.resolver, còn các file khác implement tương tự. Trong NestJS thường các file spec sẽ đi chung với ts không cần phải tách ra thư mục test. Việc này giúp cho khi chia nhỏ các module hoặc move module ra repo không cần phải sửa lại phần import

Trở lại repo server và cấu hình để Jest có thể load các file .spec.ts trong module-graphql.

//jest.config.tsmodule.exports = {
...require('../../jest.config'),
roots: [...,`<rootDir>/../../module-graphql/`]

};

E2E test

Tạo file e2e-spec.ts để implement code e2e test cho module-graphql. File E2E được viết trong repo chứa Nest Application, vì repo này tập hợp tất cả các module để run và như vậy tránh làm phức tạp phần import

//test/module-graphql.e2e-spec.testimport {Test, TestingModule} from '@nestjs/testing';
import request from 'supertest';
import {AppModule} from './../src/app.module';
import {NestApplication} from "@nestjs/core";

describe('Module GraphQL (e2e)', () => {
let app: NestApplication;

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});

it('/ (getPosts)', () => {
const query = `
query{
getPosts{
id,title,vote
}
}`
return request(app.getHttpServer())
.post('/graphql')
.send({operationName: null, query})
.expect(200)
.expect(({body}) => {
const posts = body.data.getPosts;
expect(posts).toBeDefined()
expect(Array.isArray(posts)).toBeTruthy()
})
});

it('/ (createPost)', () => {
const query = `
mutation{
createPost(createPostInput:{title:"Hello"}){
id,title,vote
}
}
`
return request(app.getHttpServer())
.post('/graphql')
.send({operationName: null, query})
.expect(200)
.expect(({body}) => {
const post = body.data.createPost;
expect(post).toBeDefined()
expect(post.title).toEqual("Hello")
expect(post.vote).toEqual(0)
})
});

afterAll(async () => await app.close())
});

Chạy command để test module-graphql

$cd server#chạy unit test
$ yarn test
# chạy e2e test
$ yarn test:e2e

hoặc có thể open link codesandbox.io phía trên và open terminal để test

Kết luận

GraphQL schema-first khá dễ dàng trong việc xây dựng nhanh một module graphql nhưng sẽ có vấn đề khi trong một project lớn có nhiều module và model liên kết với nhau

Việc thay đổi schema sẽ dẫn đến việc thay đổi model trong code dẫn đến phải sửa code trong các module khác đặc biệt là trong các entity.

Để tối ưu các vấn đề trên thì NestJS sử dụng code-first để giải quyết. Các bạn có thể tham khảo tại

--

--