GraphQLのサーバサイド認証とその実装のためのContext

新しい技術を試すにあたって、面倒ではあるが、認証までは一通り使いこなせる必要があると感じている。ただ、認証は結構めんどくさいし、すぐ忘れてしまいがちなので、メモを残しておく。

GraphQLサーバに、認証機能を付与するにあたって、Contextを定義する。このMyContextはGraphGLの各呼び出しに対して、引数のほかに共通的に呼び出される。ここでは認証を想定し、認証されていればMyContextはユーザ情報を含み、認証されていなければnullであるとする。

interface MyContext {
  user: User | null;
}

Resolverにも先ほどMyContextを設定する。/meのエンドポイントの定義の3つめの引数がMyContextとなるので、user情報を含んでいるかどうかをチェックしてエラーを投げている。エラーメッセージを適当に投げるとInternal Server Errorと扱われてしまうので、正確に認証エラーを返すには、GraphQLErrorを使ってカスタムエラーを投げることがある。

const resolvers: Resolvers<MyContext> = {
  Query: {
    me: (_parent, _args, {user}) => {
      if (user == null) throw new GraphQLError('Unauthorized', {
        extensions: { code: 'UNAUTHORIZED' }
      });
      return user
    },

もう2か所。ApolloServer生成時にもMyContextを設定しておき、startStandaloneServerのoptionsでは、設定したMyContext生成について定義する必要がある。今回はヘッダーで受け取ったAuthorizationの値からUser型のデータまたはnullを受け取り返すといった処理になっている。各ユーザ情報とAuthorizationの値とをマッチさせる返答することになる。

const server = new ApolloServer<MyContext>({
  typeDefs,
  resolvers,
});
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({req}) => {
    const token = req.headers.authorization || '';
    const user = getUser(token);
    return { user } satisfies MyContext;
  },
});

GraphQLの型定義をスキーマから自動生成する。

GraphQL、型定義をスキーマから自動生成するところまで試してみた。

やってみて気づいたこととして、自動生成する型定義はサーバとクライアントの2種類があるということ。それぞれ必要。

サーバ

サーバの方はschema.graphqlというようなスキーマ定義ファイルがあれば生成できる。

npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

公式はyamlでConfigを作成していたが、tsでもConfigは作成できる。

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: "./src/schema.graphql",
  generates: {
    "./src/__generated__/resolvers-types.ts": {
      plugins: [
        "typescript",
        "typescript-resolvers",
      ],
      config: {
        useIndexSignature: true,
        contextType: "../index#MyContext",
      },
    },
  },
  overwrite: true,
};
export default config;

このファイルをcodegen-server.tsと保存してCLIでgenerateする。

npx graphql-codegen --config codegen-server.ts

これで./src/__generated__/resolvers-types.tsに型定義が出力されるので、importして、Resolverの型を置き換えてやればよい。

最小限のコードだけ示すと以下。

import { Resolvers } from './__generated__/resolvers-types';

const resolvers: Resolvers = {
  Query: {
    ...
  }
};

もちろん、スキーマ中で定義した型もimportできる。

Apollo-serverのドキュメントを参考にした。 Generating types from a GraphQL schema - Apollo GraphQL Docs

クライアント

GraphQLクライアントでは、クエリ毎に取得できるデータは異なる。そのため、クエリ毎に型定義が必要になる。

例えば以下のコードでは、gqlで宣言しているクエリが対象となる。 クエリ名(ここでのGetBooks)は自動生成されるGraphQLの型定義でも利用されるため、クエリ毎にユニークな名前を付ける必要がある。

import { ApolloClient, InMemoryCache } from '@apollo/client/core';
import { gql } from '@apollo/client/core';

const query = gql(`
query GetBooks {
  books {
    id
    title
  }
}
`);

async function query() {
  const client = new ApolloClient({
    uri: 'http://localhost:4000/',
    cache: new InMemoryCache(),
  });

  const { data: { books } } = await client.query({ query });
  books.map(book => {
    console.log({ book });
  })
}

void query();

クライアント用のConfigの定義は以下codegen-client.tsとする。 サーバのときのConfig異なるのは対象スキーマ以外にdocumentsでtsファイルを指定している。(クエリの記述があるコードを指定する必要がある) presetでのclientを指定も必要。

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: "./src/schema.graphql",
  documents: ['src/**/*.{ts,tsx}'],
  generates: {
    "./src/__generated__/": {
      preset: 'client',
      plugins: [],
      presetConfig: {
        gqlTagName: 'gql',
      },
    },
  },
  ignoreNoDocuments: true,
  overwrite: true,
};

export default config;

ここでは無難にApolloClientを使っておく。

自動生成対象となるクエリをgqlでDocumentNodeに変換してからclientのqueryに渡すようにしておく。そうするとcodegenを実行した際に必要な型定義が作成の対象となる様子。

型定義の自動生成はサーバと変わらない。以下で実行する。

npx graphql-codegen --config codegen-client.ts

自動生成後は、 @apollo/client/coreからimportしていたgqlを自動生成したgqlに置き換えれば、生成された型が効くようになる。

import { gql } from '@apollo/client/core';
import { gql } from '../src/__generated__/gql';

クライアントのドキュメントはちょっとみつけられなかったので。React用のドキュメントを参考にしつつやってみたという形。 Generating types from a GraphQL schema - Apollo GraphQL Docs

GraphQLのクライアントを試してみる。

GraphQLの理解のため、まずはライブラリなど使わずfetchでGraphQLのクエリを発行してみる。

const query = gql`{ books { id } }`;

async function queryByFetch() {
  const res = await fetch('http://localhost:4000/graphql', {
    method: 'POST',
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query,
    })
  });
  if (!res.ok) {
    throw new Error();
  }

  const result = await res.json() as any;
  if (!('data' in result)) {
    throw new Error();
  }
  const { data } = result;
  console.log({ data });
}

void queryByFetch();

できたので、ApolloClientを使ってみる。node上で使うなら、@apollo/client/coreからインポートが必要なよう。

import { ApolloClient, InMemoryCache, gql } from '@apollo/client/core';

const query = gql`{ books { id } }`;

async function queryByClient() {
  const client = new ApolloClient({
    uri: 'http://localhost:4000/',
    cache: new InMemoryCache(),
  });

  const result = await client.query({ query });
  if (!('data' in result)) {
    throw new Error();
  }
  const { data } = result;
  console.log({ data });
}

void queryByClient();

viteのサーバ用にリバースプロキシのミドルウェアを書いた。

expressを使うと楽に実装できた。

まだ試行錯誤しているので、とりあえずvite.config.tsにべた書き。

import { ViteDevServer, PluginOption, defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

import express from 'express';
import type { IncomingMessage, ServerResponse } from 'http';
import { request } from 'http';

function createReverseProxyHandler(path: string, proxyHost: string, proxyPort: number) {
  const handler = function(proxyReq: IncomingMessage, proxyRes: ServerResponse) {
    const serverReq = request({
      host: proxyHost,
      port: proxyPort,
      method: proxyReq.method,
      path: proxyReq.url,
      headers: proxyReq.headers, 
    }).on('error', () => {
      proxyRes.writeHead(502).end();
    }).on('timeout', () => {
      proxyRes.writeHead(504).end();
    }).on('response', serverRes => {
      proxyRes.writeHead(serverRes.statusCode!, serverRes.headers)
      serverRes.pipe(proxyRes);
    })
    proxyReq.pipe(serverReq);
  }

  const router = express();
  router.use(path, handler);
  return router;
}

function ReverseProxyPlugin(props: {path: string, proxyHost: string, proxyPort: number}): PluginOption {
  const { path, proxyHost, proxyPort } = props;
  return {
    name: 'ReverseProxyPlugin',
    configureServer(server: ViteDevServer) {
      server.middlewares.use(createReverseProxyHandler(path, proxyHost, proxyPort));
    },
  } as const satisfies PluginOption;
}

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    ReverseProxyPlugin({
      path: '/api/',
      proxyHost: '127.0.0.1',
      proxyPort: 4000,
    }),
  ]
})

SuspenseをNextJSで使う。

追記:NextJS(AppRouter)でSuspenseを使うときはCSRにしようと書いたのだが、CSRだとNextJSのRouter Cacheと相性がわるそう。というか、最近NextJSはオーバースペックだなぁと感じている。


Viteで作っていたReactのプロトをNextjsに移植したところ、Suspense周りでトラブルが起きた。ReactQueryもSWRでもどちらでも同じ症状が起きた。

エラーメッセージを見る限り、原因はあきらかにSSRだと予測できたため、SSRを無効にして、CSRにすれば解決するだろうと思われた。ただ、思ったより苦戦した。

調べるとまず見つかるのは以下のおまじない。これをコードの1行目に記載する。

'use client'

これでエラーは起きるが一応表示はされるようにはなった。 完全にCSRになるのかと思ったが、そうではなく、SSRされることもある様子。

次にSSRを無効にする方法について調べると、以下のおまじないが見つかった。

export default dynamic(async () => MyApp, {ssr: false} );

これを適用したら、Suspenseがエラーなく期待通り動作するようになった。

追記1

SSRでは、fallbackDataを設定すればSuspenseのコード自体は動作したのだが、ローディング中にはfallbackDataが返ってくるようになってしまい、期待した動作にならなかった。SSRの制約として受け入れるしかないのかもしれないが、よくわからない。

追記2

fallbackDataを設定すれば、と書いたのだが、これは勘違いでご作法(loading.tsxなど)に沿って書けば、AppRouterのSSRでもSuspenseはfallbackDataの設定なしでも動作した。

Next.JSをAzureのWeb App Serviceにデプロイする

最近、新しいアプリを作ってみたらデプロイに苦労したのでメモを残しておく。 2023.9.13時点で、Next.JSのアプリは、VSCode拡張機能や、Web App Serviceから自動設定できるGitHub連携のデフォルトのGitHub Actionでは、Next.JSのデプロイに時間がかかりすぎたり、うまくデプロイできないといった問題が生じている。原因としては、非常に膨大なファイル数をアップロード/デプロイしているからの様子。例えばnode_modules以下のファイル群など。

対策としては、Next.JSをStandaloneモードでビルドし、必要最小限のファイルに絞ってデプロイすればよいのだが、ビルドにいろいろ手を加える必要がある。

Standaloneモードでビルドするには、next.config.jsのoutputにstandaloneを設定すればよい。またdistDirでビルド結果の出力先を指定しておくほうが、デプロイに必要なファイルを集めやすかった。そのため、buildに出力するとする。

/** @type {import('next').NextConfig} */
const nextConfig = {
  distDir: 'build',
  output: 'standalone',
}

module.exports = nextConfig

次に、package.jsonのscriptにpostbuildを追加する。postbuildはbuild終了時に実行したいコマンドを定義することができる。Next.JSではCDEに配置すべきファイルということでビルド時にいくつかのファイルは分けられているらしい。CDEを活用する場合は違った設定を考えたほうが良いのだろう。(ここではそこまで考えない)

また、実行時に不要なモジュールはdevDependenciesへ変更している。

{
  "name": "next-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "postbuild": "cp -r public build/standalone && mkdir -p build/standalone/public/_next && cp -r build/static build/standalone/public/_next/",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "13.4.19",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/node": "20.6.0",
    "@types/react": "18.2.21",
    "@types/react-dom": "18.2.7",
    "eslint": "8.49.0",
    "eslint-config-next": "13.4.19",
    "typescript": "5.2.2"
  }
}

最後に、GitHub Actionsのymlファイルを以下のように直す。

# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions

name: Build and deploy Node.js app to Azure Web App - next-sample-app

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js version
        uses: actions/setup-node@v3
        with:
          node-version: '20.x'

      - name: npm install, build, and test
        run: |
          npm install
          npm run build --if-present
          npm run test --if-present
          npm prune --production
          cd build/standalone
          zip -qr ../../package.zip .
          cd ../../
        env:
          DATABASE_URL: ${{ secrets.AZURE_DATABASE_URL }}

      - name: Upload artifact for deployment job
        uses: actions/upload-artifact@v3
        with:
          name: node-app
          path: package.zip

      - name: Clean
        run: |
          rm -rf build
          rm -rf package.zip
  
  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: 'Production'
      url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}

    steps:
      - name: Download artifact from build job
        uses: actions/download-artifact@v3
        with:
          name: node-app

      - name: 'Deploy to Azure Web App'
        id: deploy-to-webapp
        uses: azure/webapps-deploy@v2
        with:
          app-name: 'next-sample-app'
          slot-name: 'Production'
          publish-profile: ${{ secrets.XXXX }}
          package: package.zip

ポイントだけ説明する。

buildすると、build/standaloneフォルダに必要なファイル一式がそろうようにしている。(postbuildで必要なファイルのコピーを追加したため。) それをpackage.zipに圧縮している。

          cd build/standalone
          zip -qr ../../package.zip .
          cd ../../

アップロード/ダウンロード時間を軽減にはかなり効く。実際速くなっている。

本記事では、AzureのWeb App Serviceのデプロイを対象に説明したが、少しアレンジすれば、ほかの環境へのデプロイでも参考になるのではないかとは思う。

以上。

2023/10/29追記

スタンドアローンモードでビルド、実行するため、Web App Serviceのスタートアップコマンドは以下に変更する必要があった。

node server.js

Web App Serviceの場合、Webの管理画面から、「設定 > 構成 > 全般設定 > スタートアップ」で設定する必要があった。 変更していないままデプロイするとコマンドが見つからずエラーが起きる。

2024/2/10追記

Next.JSのデフォルトポートは3000だが、Web App Serviceのデフォルトは8080なので、対応させる必要がある。 これは「構成」からアプリケーション設定にPORTを3000として追加するだけでよい。

npmのpackageを作る?

いくつかのPJで共通した処理があり、それぞれで書き直すのもめんどくさいので、packageを作りたくなったのだが、そこでふと、packageってどうつくればいいのか?と思い調べてみた。

publicに運用したい場合

npmやgithub packageなどが選択肢になる。 ただし、一度公開すると消せないと思っておく必要があるので、積極的にpublicにしたいかというと。。。

privateに運用したい場合

実は、gitリポジトリを作るだけでよい。 実際には、タグでversionを振らないと使いづらいが。

以下のような感じでgitリポジトリはnpmでインストールできる。

npm install git+https://github.com/<user>/<repo>.git#<tag or commit>

前提としてgitリポジトリには、package.jsonなどは作られているとする。(npm initなどを実行するなどして)

バージョンを更新したいときも同様。 なお、package.jsonのバージョンを書き換えても自分はうまくいかなかった。

Typescriptでpackageを作る場合

TypescriptのパッケージをNextjsでimportして実行しようとすると、以下のようなエラーがでる。

You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders

JSにトランスパイルしたものをインポートしてもエラーは解決できるが、いろいろめんどくさい。 Typescriptのまま実行できるようにするには、トランスパイルの設定が必要なので、以下のパッケージをインストールする。

npm install --save next-transpile-modules

そして、next.config.jsを以下のように書き換える必要がある。

/** @type {import('next').NextConfig} */
const nextConfig = withTM({
  reactStrictMode: true,
  swcMinify: true,
  transpilePackages: ['lib-xxx']
})

module.exports = nextConfig

lib-xxxをロードしたいパッケージ名に置き換えればいけるはず。 nextConfigの設定をwithTMに通すところが差分。

その他

gitサブモジュールを使うという方法もちょっと探ってみたものの、いまのところnodejsのプロジェクトはpackageで管理するのがよいのではという結論。