🚀
Apollo Server/ClientでGraphQL利用時の認証エラーを処理する
Apollo Serverで認証エラーを掴むときの挙動。
RESTで組むときはhttpステータスコードで401としますが、GraphQLでは200で返し、レスポンスのerrors
キーに詳細情報をもたせるというのがベストプラクティス。
最近はRESTでも同じような実装にする流れがあるというのをどこかで聞いたような。
この記事では、ここの使用技術の解説は割愛して、全体の流れとコードを載せておきます。
利用する技術
Apollo Server
Apollo Client
-
Reactive Variables
- グローバルなストアで変数管理できる使用感
-
Apollo Link
- リクエストを送る前とレスポンスを受け取る前に前処理するための機構
エラー発生時の流れ
- [Client]何かしらエラーになる通信呼び出し
- [Server]Directiveで認証チェック → エラー発生
- [Client]Error LinkでGraphQLエラーをキャッチ
- [Client]Reactive Variablesにエラーを格納
- [Client]4の変数をsubscribeしてサインイン画面に戻すなどの共通処理を実装
実装詳細
2. [Server]Directiveで認証チェック
GraphQLの仕様にあるDirectivesを使います。
schema.ts(一部抜粋)
type ShopQueryAPI @auth(requires: MEMBER) {
shop: Shop @auth(requires: ADMIN)
myAccount: ShopUser
}
directive @auth(
requires: Role = NONE,
) on OBJECT | FIELD_DEFINITION
enum Role {
ADMIN
MEMBER
NONE
}
schemaでの活用の仕方。
typeだけでなく、field単位での設定が可能です。
authDirectiveTransformer.js
import { mapSchema, MapperKind, getDirective } from "@graphql-tools/utils";
import { AuthenticationError } from "apollo-server-errors";
import ShopAuthentication from "../domains/ShopAuthentication.js";
import { defaultFieldResolver, GraphQLSchema } from "graphql";
const executeAuth = (fieldConfig: any, authDirective: any) => {
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (...args: any[]) {
const requiredRole = authDirective.requires;
if (!requiredRole) {
return resolve.apply(this, args);
}
const context = args[2];
if (!ShopAuthentication.hasRole(requiredRole, context.tokenData)) {
throw new AuthenticationError("not authorized");
}
return resolve.apply(this, args);
};
return fieldConfig;
};
export default function authDirectiveTransformer(schema: GraphQLSchema) {
const directiveName = 'auth';
const typeDirectiveArgumentMaps: Record<string, any> = {};
return mapSchema(schema, {
[MapperKind.TYPE]: (typeName: any) => {
const authDirective = getDirective(schema, typeName, directiveName)?.[0];
if (authDirective) {
typeDirectiveArgumentMaps[typeName] = authDirective;
}
return typeName;
},
[MapperKind.OBJECT_FIELD]: (fieldConfig: any, _fieldName: any, typeName: any) => {
const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0] ?? typeDirectiveArgumentMaps[typeName];
if (authDirective) {
return executeAuth(fieldConfig, authDirective);
}
return fieldConfig;
},
});
};
最後に、ApolloServer
のコンストラクタに食わせます。
index.ts(一部抜粋)
let schema = makeExecutableSchema({
typeDefs,
resolvers,
});
schema = authDirectiveTransformer(schema);
const server = new ApolloServer({
schema,
dataSources,
context,
formatError: (error) => {
console.error(error?.extensions);
return error;
},
plugins: [
logging,
]
});
await server.start();
3. [Client]Error LinkでGraphQLエラーをキャッチ, 4. [Client]Reactive Variablesにエラーを格納
globalVars.ts
import { makeVar, ServerError, ServerParseError } from '@apollo/client';
import { GraphQLError } from 'graphql';
type ApiErrorType = GraphQLError | Error | ServerError | ServerParseError | undefined
// eslint-disable-next-line import/prefer-default-export
export const apiError = makeVar<ApiErrorType[]>([]);
client.ts
import {
createHttpLink, ApolloClient, InMemoryCache, from,
} from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { apiError } from './globalVars';
const httpLink = createHttpLink({
uri: 'http://localhost:4000',
});
const errorLink = onError(({ graphQLErrors, networkError }) => {
const errors = [];
if (graphQLErrors) {
graphQLErrors.forEach((err) => {
errors.push(err);
});
}
if (networkError) {
errors.push(networkError);
}
apiError(errors);
});
const apolloClient = new ApolloClient({
cache: new InMemoryCache(),
link: from([errorLink, httpLink]),
});
export default apolloClient;
Apollo Linkを使用するときは、ApolloServer
のコンストラクタのほうでuriの指定が効かなくなる仕様なので、httpLink
も記述します。
エラーの中身をReactive Variablesに格納して、UI側で扱えるようにします。
5. [Client]4の変数をsubscribeしてサインイン画面に戻すなどの共通処理を実装
_app.tsx
import {
Snackbar, Alert, Slide,
} from '@material-ui/core';
import { AppProps } from 'next/dist/next-server/lib/router/router';
import Head from 'next/head';
import React, { useEffect, useState } from 'react';
import { ApolloProvider, useReactiveVar } from '@apollo/client';
import { GraphQLError } from 'graphql';
import { useRouter } from 'next/router';
import apolloClient from '../lib/apollo/client';
import { apiError } from '../lib/apollo/globalVars';
const MyApp = (props: AppProps) => {
const { Component, pageProps } = props;
const router = useRouter();
const [snackbarOpen, setSnackbarOpen] = useState(false);
const apiErr = useReactiveVar(apiError);
const [errorMessage, setErrorMessage] = useState('通信エラーが発生しました');
const onCloseSnackbar = () => {
apiError([]);
setSnackbarOpen(false);
};
useEffect(() => {
setSnackbarOpen(apiErr.length > 0);
const isUnauthorized = apiErr.some((error) => {
const e = error as GraphQLError;
return e?.extensions?.code === 'UNAUTHENTICATED';
});
if (isUnauthorized) {
setErrorMessage('認証エラーが発生しました。再ログインしてください。');
router.push('/signin');
}
}, [apiErr]);
return (
<ApolloProvider client={apolloClient}>
<>
<Head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0" />
</Head>
<Component {...pageProps} />
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
autoHideDuration={5000}
TransitionComponent={(prps) => <Slide {...prps} direction="down" />}
open={snackbarOpen}
onClose={onCloseSnackbar}
>
<Alert
severity="error"
onClose={onCloseSnackbar}
>
{errorMessage}
</Alert>
</Snackbar>
</>
</ApolloProvider>
);
};
export default MyApp;
Next.jsとMaterial-UIを使用している例です。
Reactive VariablesをHooksで扱うときはuseReactiveVar
を用いるのが味噌です。
どの画面でもアラート上げるようにしたいので、_app.tsx
に実装しました。
総括
気持ち的には、とてもスッキリ実装できた感覚です。
サーバーもクライアントもApolloで統一することで、エラーの型も同じように扱え、生産性の高さを感じました。
Discussion