Apollo GraphQサーバーにログイン(認証、承認、セッション管理)機能を追加する Apollo GraphQLに入門してみた(4) の続きです。
今回でApollo GraphQサーバーの認証、承認、セッション管理機能は完成です。
このブログから見たかたは、Apollo GraphQLに入門してみた(1)、Apollo GraphQLに入門してみた(2)、Apollo GraphQLに入門してみた(3) も見てくさい。
登録機能
今回はユーザー登録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を実装するには、
- Resolver内にValidationのコードを書く
- Resolverにデコレータを宣言すると、Validation処理を追加してくれるライブラリーを使う
- 型定義にデコレータを宣言すると、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アプリ用バックエンドを作る基本ができました。
今回つかった機能、ライブラリー、ツール等は
- TypeScript TypeScript言語
- Apollo GraphQL Server GraphQLサーバー
- GraphQL Scalars GraphQL型のライブラリー
- DataLoader N+1問題の解決
- GraphQL Code Generator GraphQL型からTypeScript型を生成するツール
- pg-promise PostgreSQLインターフェス
- express-session セッション管理
- graphql-sheild 承認管理
- graphql-constraint-directive Validation(入力チェック)
- Apollo Server plugins:willSendResponse HTTPステータスの書換
最終コード
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');
});