EY-Office ブログ

Apollo GraphQLに入門してみた(最終回)

Apollo GraphQサーバーにログイン(認証、承認、セッション管理)機能を追加する Apollo GraphQLに入門してみた(4) の続きです。
今回でApollo GraphQサーバーの認証、承認、セッション管理機能は完成です。

このブログから見たかたは、Apollo GraphQLに入門してみた(1)Apollo GraphQLに入門してみた(2)Apollo GraphQLに入門してみた(3) も見てくさい。

Apollo

登録機能

今回はユーザー登録APIを追加しました。これは普通にMutationを追加すればOKです

  • まずは型を定義、登録APIはuserRegister(user: UserInput!): Userになります。入力データのオブジェクト型UserInputを準備しました、
    ・・・
const typeDefs = gql`
   ・・・
type Mutation {
  userLogin(email: String!, password: String!): User
  userLogout: Void
  userRegister(user: UserInput!): User
}
    ・・・
input UserInput {
  name: String!
  email: String!
  password: String!
  group_id: Int!
}
  • 登録APIの処理は
    • pawssordはbcryptでハッシュ化
    • 同じEmailがすでに登録されてないかチェック、あればUserInputErrorをthrow
    • データーベースにデータをinsert、PostgreSQL方言のRETURNING *を使いinsertされた値を戻り値にしています
      • pg-promiseにはhelpers.insertのようにSQLを生成してくれる便利メソッドがあり、PostgreSQL方言も使えます
const resolvers:Resolvers = {
    ・・・
  Mutation: {
    userRegister: async (_parent, args:UserRegisterArgs, context) => {
      const userInput = {...args.user, password: 
          bcrypt.hashSync(args.user.password, BCRYPT_SALT_ROUNDS)}
      const userCheck = await context.db.one(
          'SELECT count(*) FROM users WHERE email = $1', userInput.email)
      if (Number(userCheck.count) > 0) {
        throw new UserInputError('Already registered', {argumentName: 'email'})
      }
      const sql = pgp.helpers.insert(userInput, null, 'users') + "RETURNING *"
      const user = await context.db.one(sql)
      return user
    }
  }
};

Validation(入力データのチェック)の追加

GraphQLではデータの型定義があり、間違った型のデータは入力できません。また必須項目(型の後ろに!が付いているもの)も指定できるのでサーバー側でのValidation(入力データのチェック)は要らないかな?と思っていました。
しかし、必須項目だと思っていたものはnullには出来ないだけで、空の文字列を指定すればOKになってしまいます。また重要な項目はクライアントだけでなくサーバー側でもチェックしたくなります。

Apollo GraphSQLサーバーでValidationを実装するには、

  1. Resolver内にValidationのコードを書く
  2. Resolverにデコレータを宣言すると、Validation処理を追加してくれるライブラリーを使う
  3. 型定義にデコレータを宣言すると、Validation処理を追加してくれるライブラリーを使う

3が良さそうなのでgraphql-constraint-directiveを採用しました、 型定義に以下のようなconstraintを宣言を追加しました。

input UserInput {
  name: String! @constraint(minLength: 1)
  email: String! @constraint(format: "email")
  password: String! @constraint(minLength: 8)
  group_id: Int!
}

graphql-constraint-directiveの処理はGraphQL Middlewareで以下のように追加します。

import { constraintDirective, constraintDirectiveTypeDefs } from 
        '@karavaan/graphql-constraint-directive'
    ・・・
const schema = applyMiddleware(
  constraintDirective()(
  makeExecutableSchema({
    typeDefs: [...scalarTypeDefs, constraintDirectiveTypeDefs, typeDefs],
    resolvers: {...scalarResolvers, ...resolvers}
  })),
  permisions
)

ただし npm install graphql-constraint-directiveでインストールするとTypeScriptに対応してないので? @karavaan/graphql-constraint-directive を使いnpm install @karavaan/graphql-constraint-directiveでインストールしました。😮‍💨

ValidationエラーでHTTPステータス400になる!

しかし、ValidationエラーがクライアントにはHTTPステータスコード400で戻ります! Apollo Clientを使う場合、400が戻るとクライアント側の処理が煩雑になります。また登録APIでEmailが重複している際のエラーは200で戻りますが、Apollo Clientはエラー情報を戻す仕組みがあるのでステータスは200で問題ないと思われます。

きっと、graphql-constraint-directiveのValidationエラーで200が戻せるに違いないと思い、ドキュメントを読んだりネットを検索しましたが解決方法が見つかりませんでした。
そこで、ソースコードを読んで見ることにしました。ただしApollo GraphQLサーバーは大きなコードです。今回の関連ヵ所を見つけるのも大変そうです。

そこで、思い出したのがNode.jsにはconsole.traceという関数がありソースコードの適当な場所に以下のコードを書くと、その時点でのスタックトレース(呼び出し履歴)が表示されます。

Error.stackTraceLimit = 30   // デフォルトは10件しか表示されないので30件出すように設定
console.trace("==== Validation error")

graphql-constraint-directiveのエラーをThrowする場所に上のコードを入れてみました、すると以下のようなスタックトレースが表示されました。

==== Validation error
at new ConstraintDirectiveError (node_modules/@karavaan/graphql-constraint-directive/lib/error.js:11:13)
at validate (node_modules/@karavaan/graphql-constraint-directive/scalars/string.js:37:11)
at GraphQLScalarType.parseValue (node_modules/@karavaan/graphql-constraint-directive/scalars/string.js:20:9)
at coerceInputValueImpl (node_modules/graphql/utilities/coerceInputValue.js:128:26)
at coerceInputValueImpl (node_modules/graphql/utilities/coerceInputValue.js:54:14)
at coerceInputValueImpl (node_modules/graphql/utilities/coerceInputValue.js:105:34)
at coerceInputValueImpl (node_modules/graphql/utilities/coerceInputValue.js:54:14)
at coerceInputValue (node_modules/graphql/utilities/coerceInputValue.js:37:10)
at _loop (node_modules/graphql/execution/values.js:109:69)
at coerceVariableValues (node_modules/graphql/execution/values.js:121:16)
at getVariableValues (node_modules/graphql/execution/values.js:50:19)
at buildExecutionContext (node_modules/graphql/execution/execute.js:203:61)
at executeImpl (node_modules/graphql/execution/execute.js:101:20)
at Object.execute (node_modules/graphql/execution/execute.js:60:35)
at execute (node_modules/apollo-server-core/src/requestPipeline.ts:479:20)
at Object.processGraphQLRequest (node_modules/apollo-server-core/src/requestPipeline.ts:374:28)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
at processHTTPRequest (node_modules/apollo-server-core/src/runHttpQuery.ts:335:20)

このトレースに現れるコードを読んでみました。

  • Apollo GraphQLサーバーはApolloが作ったコードと、Facebookが作ったGraphQLのコードが使われています
  • ApolloのコードはTypeScriptで書かれています
  • FacebookのGraphQLのコードはJavaScript+Flowで書かれています、Flowを使ったコードをはじめて見ました
  • FlowはFacebookか開発しているJavaScrpt型チェックソフト、TypeScriptのライバルだったソフトです

さてコードを読んで見ましたが、Validationエラーで200する手段はないことが判りました。😭

ValidationエラーはHTTPステータス200にする

しかし、Apollo GraphQLサーバーではPlugin機構があり、Apollo GraphQLサーバー処理に手を入れる事ができます。

今回は処理の最後、レスポンスを返す部分willSendResponseでValidationエラーの場合はHTTPステータスを200に置き換えるコードを組み込みました。

const setHttpStatus200onValidationError = 
    async (requestContext: GraphQLRequestContextWillSendResponse<Context>) => {
  const extentions:Record<"code", string>|undefined = 
    requestContext.response.errors?.[0]?.extensions
  if (extentions && extentions.code === "BAD_USER_INPUT" &&
      !requestContext.response?.errors?.[0]?.path) {
    requestContext.response.http!.status = 200
  }
  return requestContext as any
}

(async () => {
  const server = new ApolloServer({
    schema,
    context: ({req}) => ({
      req,
      db,
      loaders,
      log
     }),
     plugins: [{
        async requestDidStart(_requestContext) {
          return { willSendResponse: setHttpStatus200onValidationError }
        }
      }]
  });

  await server.start()
  server.applyMiddleware({ app, cors: false });
})()

Validationエラーが発生した場合、 requestContext.response.errors[0].extensions.code"BAD_USER_INPUT"が入っています。さらに処理(Resolver)が動作してないのでrequestContext.response.errors[0].pathが設定されていません。

まとめ

いろいろと苦労しましたが、なんとかApollo GraphQLサーバーでReactアプリ用バックエンドを作る基本ができました。

今回つかった機能、ライブラリー、ツール等は


最終コード

import express from 'express';
import cors from 'cors';
import { ApolloError, ApolloServer, gql, UserInputError } from 'apollo-server-express';
import { GraphQLRequestContextWillSendResponse }  from 'apollo-server-plugin-base'
import { makeExecutableSchema } from '@graphql-tools/schema'
import { typeDefs as scalarTypeDefs, resolvers as scalarResolvers } from 'graphql-scalars';
import { applyMiddleware } from 'graphql-middleware'
import { shield, rule, allow } from 'graphql-shield'
import { constraintDirective, constraintDirectiveTypeDefs } from '@karavaan/graphql-constraint-directive'

import { Resolvers, User, Group, UserInput } from './generated/graphql'
import DataLoader from 'dataloader';
import bcrypt  from 'bcrypt';
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
import pgPromise from "pg-promise";
import { Context } from 'apollo-server-core';

const BCRYPT_SALT_ROUNDS = 10

declare module 'express-session' {
  interface SessionData {
      userId: number;
  }
}

const log = (s: any) => console.log(`${(new Date()).toLocaleString('ja-JP')} ${s}`)

const pgp = pgPromise({query: (e) => log(`SQL: ${e.query}`)})

const db = pgp({
  host: process.env.DB_HOST,
  database: process.env.DB_DATABASE,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD
});

const typeDefs = gql`
type Query {
  user(id: Int!): User
  users: [User]
  group(id: Int!): Group
  groups: [Group]
}

type Mutation {
  userLogin(email: String!, password: String!): User
  userLogout: Void
  userRegister(user: UserInput!): User
}

type User {
  id: Int!
  name: String!
  email: String!
  group_id: Int!
  group: Group!
  created_at: DateTime!
  updated_at: DateTime!
}
input UserInput {
  name: String! @constraint(minLength: 1)
  email: String! @constraint(format: "email")
  password: String! @constraint(minLength: 8)
  group_id: Int!
}
type Group {
  id: Int!
  name: String!
  created_at: DateTime!
  updated_at: DateTime!
}
`;


type UserLoginArgs = {email:string, password:string}
type UserRegisterArgs = {user: UserInput}

const resolvers:Resolvers = {
  Query: {
    user: async (_parent, {id}: {id:number}, context, _info) =>
      await context.db.one('SELECT * FROM users WHERE id = $1', id),
    users: async (_parent, _args , context) =>
      await context.db.any('SELECT * FROM users'),
    group: async (_parent, {id}: {id:number}, context) =>
      await context.db.one('SELECT * FROM groups WHERE id = $1', id),
    groups: async (_parent, _args , context) =>
      await context.db.any('SELECT * FROM users')

  },
  User: {
    group: async (parent,  _args , context) =>
      await context.loaders.group.load(parent.group_id)
  },

  Mutation: {
    userLogin: async (_parent, {email, password}:UserLoginArgs, context) => {
      const user = await context.db.oneOrNone('SELECT * FROM users WHERE email = $1', email)
      if (user && bcrypt.compareSync(password, user.password)) {
        context.req.session.userId = user.id
        return user
      } else {
        return null
      }
    },
    userLogout: async (_parent, _args, context) => {
      context.req.session.destroy((err: Error) => console.log("---- logout"))
    },
    userRegister: async (_parent, args:UserRegisterArgs, context) => {
      const userInput = {...args.user, password: bcrypt.hashSync(args.user.password, BCRYPT_SALT_ROUNDS)}
      const userCheck = await context.db.one('SELECT count(*) FROM users WHERE email = $1', userInput.email)
      if (Number(userCheck.count) > 0) {
        throw new UserInputError('Already registered', {argumentName: 'email'})
      }
      const sql = pgp.helpers.insert(userInput, null, 'users') + "RETURNING *"
      const user = await context.db.one(sql)
      return user
    }
  }
};

const loaders = {
  group: new DataLoader<number, Group[]>(async (keys: readonly number[]) =>
     await db.any('SELECT * FROM groups WHERE id IN ($1:csv)', [keys])
)};


const isLogind = rule()(
  async (_parent, _args, context, _info) => {
    const result = Boolean(context.req.session.userId)
    console.info(`isLogind:${result}`);
    return result;
  }
)

const permisions = shield({
  Query: {
    "*": isLogind
  },
  Mutation: {
    "*": isLogind,
    userLogin: allow,
    userRegister: allow
  }}, {
  fallbackError: async (thrownThing) => {
    if (!thrownThing) {
      return new Error('Not Authorised!')
    }
    if (thrownThing instanceof ApolloError) {
      return thrownThing
    }
    return new ApolloError('Internal server error', 'ERR_INTERNAL_SERVER')
  }
})

const schema = applyMiddleware(
  constraintDirective()(
  makeExecutableSchema({
    typeDefs: [...scalarTypeDefs, constraintDirectiveTypeDefs, typeDefs],
    resolvers: {...scalarResolvers, ...resolvers}
  })),
  permisions
)

const corsOptions = {origin: 'http://localhost:3000', credentials: true}


const app = express();
app.set('trust proxy', 1);
app.use(cors(corsOptions));
app.use(session({
  store: new (connectPgSimple(session))({pgPromise: db}),
  secret: process.env.SESSION_ID_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 1 * 60 * 60 * 1000 }
}));

const setHttpStatus200onValidationError = async (requestContext: GraphQLRequestContextWillSendResponse<Context>) => {
  const extentions:Record<"code", string>|undefined = requestContext.response.errors?.[0]?.extensions
  if (extentions && extentions.code === "BAD_USER_INPUT" &&
      !requestContext.response?.errors?.[0]?.path) {
    requestContext.response.http!.status = 200
  }
  return requestContext as any
}


(async () => {
  const server = new ApolloServer({
    schema,
    context: ({req}) => ({
      req,
      db,
      loaders,
      log
     }),
     plugins: [{
        async requestDidStart(_requestContext) {
          return { willSendResponse: setHttpStatus200onValidationError }
        }
      }]
  });

  await server.start()
  server.applyMiddleware({ app, cors: false });
})()

app.listen({ port: 5000 }, () => {
  console.log('server on http://localhost:5000/graphql');
});

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ