Open27

NestJS の GraphQL 機能を試してみる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

GraphQLModule のインポート

hello-nestjs-graphql/src/app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

開発サーバー起動

コマンド
npm run start:dev

この時点では GraphQL の設定が完了していないのでエラーメッセージが表示される

エラーメッセージ
/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/apollo-server-core/src/ApolloServer.ts:677
      throw Error(
            ^
Error: Apollo Server requires either an existing schema, modules or typeDefs
    at ApolloServer.constructSchema (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/apollo-server-core/src/ApolloServer.ts:677:13)
    at new ApolloServerBase (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/apollo-server-core/src/ApolloServer.ts:330:18)
    at new ApolloServer (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/apollo-server-express/src/ApolloServer.ts:55:1)
    at ApolloDriver.registerExpress (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/@nestjs/apollo/dist/drivers/apollo-base.driver.js:77:30)
    at ApolloDriver.registerServer (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/@nestjs/apollo/dist/drivers/apollo.driver.js:38:24)
    at ApolloDriver.start (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/@nestjs/apollo/dist/drivers/apollo.driver.js:23:20)
    at GraphQLModule.onModuleInit (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/@nestjs/graphql/dist/graphql.module.js:105:9)
    at callModuleInitHook (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/@nestjs/core/hooks/on-module-init.hook.js:51:9)
    at NestApplication.callInitHook (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/@nestjs/core/nest-application-context.js:224:13)
    at NestApplication.init (/Users/susukida/workspace/js/hello-nestjs-graphql/node_modules/@nestjs/core/nest-application.js:98:9)
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Module の生成

コマンド
nest g module posts
nest g module authors

生成されるファイルがこちら

hello-nestjs-graphql/src/posts/posts.module.ts
import { Module } from '@nestjs/common';

@Module({})
export class PostsModule {}
hello-nestjs-graphql/src/authors/authors.module.ts
import { Module } from '@nestjs/common';

@Module({})
export class AuthorsModule {}

app.module.ts も自動的に変更してくれる

hello-nestjs-graphql/src/app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthorsModule } from './authors/authors.module';  // この行が自動的に追加される
import { PostsModule } from './posts/posts.module';  // この行が自動的に追加される

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
    }),
    AuthorsModule, // この行が自動的に追加される
    PostsModule, // この行が自動的に追加される
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Object type の作成

コマンド
mkdir -p src/posts/models
mkdir -p src/authors/models
touch src/posts/models/post.model.ts
touch src/authors/models/author.model.ts
src/posts/models/post.model.ts
/* eslint-disable @typescript-eslint/no-unused-vars */

import { Field, Int, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class Post {
  @Field((type) => Int)
  id: number;

  @Field()
  title: string;

  @Field((type) => Int, { nullable: true })
  votes?: number;
}
src/authors/models/author.model.ts
/* eslint-disable @typescript-eslint/no-unused-vars */

import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Post } from '../../posts/models/post.model';

@ObjectType()
export class Author {
  @Field((type) => Int)
  id: number;

  @Field({ nullable: true })
  firstName?: string;

  @Field({ nullable: true })
  lastName?: string;

  @Field((type) => [Post])
  posts: Post[];
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

仕切り直し

一旦リセットして GitHub の Example を参考にして進めていく。

まずはワークスペースの準備。

コマンド
rm -rf hello-nestjs-graphql
nest new -p npm hello-graphql
cd hello-graphql
npm install --save @nestjs/graphql @nestjs/apollo graphql apollo-server-express
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

App Module のコーディング

src
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

CRUD 生成

下記のページで知った CRUD 生成を使ってみる。

https://docs.nestjs.com/recipes/crud-generator

コマンド
# nest g res recipes でも OK です
nest generate resource recipes
トランスポートレイヤーに関する質問
? What transport layer do you use? 
  REST API 
❯ GraphQL (code first) 
  GraphQL (schema first) 
  Microservice (non-HTTP) 
  WebSockets 
エントリーポイントに関する質問
? Would you like to generate CRUD entry points? (Y/n) Y
コンソール出力
CREATE src/recipes/recipes.module.ts (238 bytes)
CREATE src/recipes/recipes.resolver.spec.ts (545 bytes)
CREATE src/recipes/recipes.resolver.ts (1181 bytes)
CREATE src/recipes/recipes.service.spec.ts (467 bytes)
CREATE src/recipes/recipes.service.ts (653 bytes)
CREATE src/recipes/dto/create-recipe.input.ts (198 bytes)
CREATE src/recipes/dto/update-recipe.input.ts (251 bytes)
CREATE src/recipes/entities/recipe.entity.ts (189 bytes)
UPDATE src/app.module.ts (685 bytes)

src/app.module.ts で自動的に RecipesModule がインポートされている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

生成されたソースコードを眺めてみる

src/recipes/recipes.module.ts
import { Module } from '@nestjs/common';
import { RecipesService } from './recipes.service';
import { RecipesResolver } from './recipes.resolver';

@Module({
  providers: [RecipesResolver, RecipesService]
})
export class RecipesModule {}
src/recipes/recipes.resolver.ts
import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql';
import { RecipesService } from './recipes.service';
import { Recipe } from './entities/recipe.entity';
import { CreateRecipeInput } from './dto/create-recipe.input';
import { UpdateRecipeInput } from './dto/update-recipe.input';

@Resolver(() => Recipe)
export class RecipesResolver {
  constructor(private readonly recipesService: RecipesService) {}

  @Mutation(() => Recipe)
  createRecipe(
    @Args('createRecipeInput') createRecipeInput: CreateRecipeInput,
  ) {
    return this.recipesService.create(createRecipeInput);
  }

  @Query(() => [Recipe], { name: 'recipes' })
  findAll() {
    return this.recipesService.findAll();
  }

  @Query(() => Recipe, { name: 'recipe' })
  findOne(@Args('id', { type: () => Int }) id: number) {
    return this.recipesService.findOne(id);
  }

  @Mutation(() => Recipe)
  updateRecipe(
    @Args('updateRecipeInput') updateRecipeInput: UpdateRecipeInput,
  ) {
    return this.recipesService.update(updateRecipeInput.id, updateRecipeInput);
  }

  @Mutation(() => Recipe)
  removeRecipe(@Args('id', { type: () => Int }) id: number) {
    return this.recipesService.remove(id);
  }
}
src/recipes/recipes.service.ts
import { Injectable } from '@nestjs/common';
import { CreateRecipeInput } from './dto/create-recipe.input';
import { UpdateRecipeInput } from './dto/update-recipe.input';

@Injectable()
export class RecipesService {
  create(createRecipeInput: CreateRecipeInput) {
    return 'This action adds a new recipe';
  }

  findAll() {
    return `This action returns all recipes`;
  }

  findOne(id: number) {
    return `This action returns a #${id} recipe`;
  }

  update(id: number, updateRecipeInput: UpdateRecipeInput) {
    return `This action updates a #${id} recipe`;
  }

  remove(id: number) {
    return `This action removes a #${id} recipe`;
  }
}
src/recipes/dto/create-recipe.input.ts
import { InputType, Int, Field } from '@nestjs/graphql';

@InputType()
export class CreateRecipeInput {
  @Field(() => Int, { description: 'Example field (placeholder)' })
  exampleField: number;
}
src/recipes/dto/update-recipe.input.ts
import { CreateRecipeInput } from './create-recipe.input';
import { InputType, Field, Int, PartialType } from '@nestjs/graphql';

@InputType()
export class UpdateRecipeInput extends PartialType(CreateRecipeInput) {
  @Field(() => Int)
  id: number;
}
src/recipes/entities/recipe.entity.ts
import { ObjectType, Field, Int } from '@nestjs/graphql';

@ObjectType()
export class Recipe {
  @Field(() => Int, { description: 'Example field (placeholder)' })
  exampleField: number;
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

生成されるスキーマ

schema.gql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------

type Recipe {
  """Example field (placeholder)"""
  exampleField: Int!
}

type Query {
  recipes: [Recipe!]!
  recipe(id: Int!): Recipe!
}

type Mutation {
  createRecipe(createRecipeInput: CreateRecipeInput!): Recipe!
  updateRecipe(updateRecipeInput: UpdateRecipeInput!): Recipe!
  removeRecipe(id: Int!): Recipe!
}

input CreateRecipeInput {
  """Example field (placeholder)"""
  exampleField: Int!
}

input UpdateRecipeInput {
  """Example field (placeholder)"""
  exampleField: Int
  id: Int!
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Query 実行

http://localhost:3000/graphql にアクセスしてクエリを実行してみる。

サービスを未実装なのでエラーになる。

実行するクエリ
query {
  recipes {
    exampleField
  }
}
クエリ実行結果
{
  "errors": [
    {
      "message": "Expected Iterable, but did not find one for field \"Query.recipes\".",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "recipes"
      ],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR",
        "exception": {
          "message": "Expected Iterable, but did not find one for field \"Query.recipes\".",
          "stacktrace": [
            "GraphQLError: Expected Iterable, but did not find one for field \"Query.recipes\".",
            "    at completeListValue (/Users/susukida/workspace/js/hello-graphql/node_modules/graphql/execution/execute.js:668:11)",
            "    at completeValue (/Users/susukida/workspace/js/hello-graphql/node_modules/graphql/execution/execute.js:607:12)",
            "    at completeValue (/Users/susukida/workspace/js/hello-graphql/node_modules/graphql/execution/execute.js:584:23)",
            "    at /Users/susukida/workspace/js/hello-graphql/node_modules/graphql/execution/execute.js:486:9",
            "    at processTicksAndRejections (node:internal/process/task_queues:96:5)",
            "    at async Promise.all (index 0)",
            "    at execute (/Users/susukida/workspace/js/hello-graphql/node_modules/apollo-server-core/src/requestPipeline.ts:501:14)",
            "    at processGraphQLRequest (/Users/susukida/workspace/js/hello-graphql/node_modules/apollo-server-core/src/requestPipeline.ts:407:22)",
            "    at processHTTPRequest (/Users/susukida/workspace/js/hello-graphql/node_modules/apollo-server-core/src/runHttpQuery.ts:436:24)"
          ]
        }
      }
    }
  ],
  "data": null
}

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

サービス実装

src/recipes/recipes.service.ts
import { Injectable } from '@nestjs/common';
import { CreateRecipeInput } from './dto/create-recipe.input';
import { UpdateRecipeInput } from './dto/update-recipe.input';
import { Recipe } from './entities/recipe.entity';

@Injectable()
export class RecipesService {
  create(createRecipeInput: CreateRecipeInput): Recipe {
    const recipe = new Recipe();
    recipe.exampleField = createRecipeInput.exampleField;

    return recipe;
  }

  findAll(): Recipe[] {
    const recipe1 = new Recipe();
    recipe1.exampleField = 1;

    const recipe2 = new Recipe();
    recipe2.exampleField = 2;

    const recipe3 = new Recipe();
    recipe3.exampleField = 3;

    return [recipe1, recipe2, recipe3];
  }

  findOne(id: number): Recipe {
    const recipe = new Recipe();
    recipe.exampleField = 1;

    return recipe;
  }

  update(id: number, updateRecipeInput: UpdateRecipeInput): Recipe {
    const recipe = new Recipe();
    recipe.exampleField = updateRecipeInput.exampleField;

    return recipe;
  }

  remove(id: number): Recipe {
    const recipe = new Recipe();
    recipe.exampleField = 1;

    return recipe;
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

再度クエリ実行

クエリ
query {
  recipes {
    exampleField
  }
}
実行結果
{
  "data": {
    "recipes": [
      {
        "exampleField": 1
      },
      {
        "exampleField": 2
      },
      {
        "exampleField": 3
      }
    ]
  }
}

とりあえずできた!

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

nest generate resource を使わない実装

まずはワークスペースの作成から。

コマンド
rm -rf hello-nestjs-graphql
nest new -p npm hello-nestjs-graphql
cd hello-nestjs-graphql
npm install --save @nestjs/graphql @nestjs/apollo graphql apollo-server-express
touch src/app.resolver.ts
npm run start:dev
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

GraphQL モジュール読み込み

src/app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.graphql'),
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

リゾルバーのコーディング

src/app.resolver.ts
import { Field, Int, ObjectType, Query, Resolver } from '@nestjs/graphql';

@ObjectType()
export class Author {
  @Field(() => Int)
  id: number;
}

@Resolver(() => Author)
export class AppResolver {
  @Query(() => [Author])
  authors(): Author[] {
    const author1 = new Author();
    const author2 = new Author();

    author1.id = 1;
    author2.id = 2;

    return [author1, author2];
  }
}

モジュールでリゾルバーを読み込むのを忘れない。

src/app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppResolver } from './app.resolver'; // この行を追加しました。
import { AppService } from './app.service';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.graphql'),
    }),
  ],
  controllers: [AppController],
  providers: [AppService, AppResolver], // この行を変更しました。
})
export class AppModule {}

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

クエリ実行

ブラウザで http://localhost:3000/graphql にアクセスする。

左側のクエリ入力部に下記のクエリを入力する。

Mac の場合は Ctrl + Space でコード補完をしてくれるので便利。

クエリ
query {
  authors {
    id
  }
}

クエリを実行する。

Mac の場合は Command + Enter で実行できるので便利。

実行結果
{
  "data": {
    "authors": [
      {
        "id": 1
      },
      {
        "id": 2
      }
    ]
  }
}

無事にクエリ実行結果が表示された。

初回は何を間違えたのだろう?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

GraphQL CLI プラグイン

nest-cli.json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "plugins": [
      {
        "name": "@nestjs/graphql",
        "options": {
          "typeFileNameSuffix": [".resolver.ts"],
          "introspectComments": true
        }
      }
    ]
  }
}
src/app.resolver.ts
import { Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';

@ObjectType()
export class Author {
  /**
   * ユーザーの ID です。
   */
  @Field(() => ID)
  id: number;

  /**
   * ユーザーの氏名の名の部分です。
   */
  firstName?: string;

  /**
   * ユーザーの氏名の姓の部分です。
   */
  lastName?: string;
}

@Resolver(() => Author)
export class AppResolver {
  @Query(() => [Author])
  authors(): Author[] {
    const author1 = new Author();
    const author2 = new Author();

    author1.id = 1;
    author2.id = 2;

    return [author1, author2];
  }
}
src/schema.graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------

type Author {
  """ユーザーの ID です。"""
  id: ID!

  """ユーザーの氏名の名の部分です。"""
  firstName: String

  """ユーザーの氏名の姓の部分です。"""
  lastName: String
}

type Query {
  authors: [Author!]!
}

反映するには npm run start:dev の再起動が必要。

うまく動かない場合は nest-cli.json の typeFileNameSuffix を見直すと良い。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ResolveField を試してみる

src/app.resolver.ts
import {
  Field,
  ID,
  Int,
  ObjectType,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';

@ObjectType()
export class Author {
  @Field(() => ID)
  id: number;

  @Field()
  firstName?: string;

  @Field()
  lastName?: string;

  @Field(() => [Post], { nullable: 'items' })
  posts: Post[];
}

@ObjectType()
export class Post {
  @Field(() => ID)
  id: number;

  @Field()
  title: string;

  @Field(() => Int)
  votes?: number;
}

@Resolver(() => Author)
export class AppResolver {
  @Query(() => [Author])
  authors(): Author[] {
    const author1 = new Author();
    const author2 = new Author();

    author1.id = 1;
    author2.id = 2;

    return [author1, author2];
  }

  @ResolveField()
  posts(@Parent() author: Author): Post[] {
    const post = new Post();
    post.id = 1;
    post.title = `${author.firstName}の記事タイトル`;
    post.votes = 1;

    return [post];
  }
}
schema.graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------

type Author {
  id: ID!
  firstName: String
  lastName: String
  posts: [Post]!
}

type Post {
  id: ID!
  title: String!
  votes: Int
}

type Query {
  authors: [Author!]!
}

http://localhost:3000/graphql にアクセスして下記のクエリを実行する。

クエリ
query {
  authors {
    id
    firstName
    lastName
    posts {
      id
      title
      votes
    }
  }
}
クエリ実行結果
{
  "data": {
    "authors": [
      {
        "id": "1",
        "firstName": null,
        "lastName": null,
        "posts": [
          {
            "id": "1",
            "title": "undefinedの記事タイトル",
            "votes": 1
          }
        ]
      },
      {
        "id": "2",
        "firstName": null,
        "lastName": null,
        "posts": [
          {
            "id": "1",
            "title": "undefinedの記事タイトル",
            "votes": 1
          }
        ]
      }
    ]
  }
}