gqlgen + @graphql-codegen/typescript-react-query 環境でセッションを張る

gqlgen + @graphql-codegen/typescript-react-query 環境でセッションを張る方法のメモ。

開発環境

バックエンド

  • go version go1.18.1 linux/amd64
  • github.com/99designs/gqlgen v0.17.13
  • github.com/gorilla/sessions v1.2.1
  • gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b

フロントエンド

  • react: 18.2.0
  • graphql: 16.5.0
  • @tanstack/react-query: 4.0.10
  • @graphql-codegen/cli: 2.11.3
  • @graphql-codegen/typescript-operations: 2.5.2
  • @graphql-codegen/typescript-react-query: 4.0.0

本文

Redis の設定

バックエンド側では gorilla/sessions を使ってセッションを張る。
gorilla/sessions のバックエンドとして Redis を使うので準備する。

compose.yaml

これを使う

Dockerfile

FROM redis:7.0-alpine
COPY redis.conf /usr/local/etc/redis/redis.conf

redis.conf は Redis configuration からサンプルをダウンロードできる。
今回は redis.conf for Redis 7.0 を使う。

################################## NETWORK #####################################
(中略)
bind 127.0.0.1 -::1

今回は Docker Compose でやるので、ここを bind 0.0.0.0 に変更する。

env

REDIS_PASSWORD というキーでパスワードを記載しておく。

フロントエンドの設定

React Query を使用していて、GraphQL Code Generator の @graphql-codegen/typescript-react-query プラグインを使っているとする。

React Query でキャッシュを活用・Authorization ヘッダの入れ方 の「Authorization ヘッダの入れ方」で描いたように

codegen.yaml

overwrite: true
schema: src/graph/schema.graphqls
generates:
  src/graph/types.generated.ts:
    plugins:
      - typescript
  src/graph/:
    documents: src/graph/*.graphql
    preset: near-operation-file
    presetConfig:
      baseTypesPath: types.generated.ts
    plugins:
      - typescript-operations
      - typescript-react-query
    config:
      fetcher:
        func: './Fetchers#requireAuthFetchData'

このような感じで fetcher を自分で書くようにする。

実際の fetcher メソッドで fetch のオプションに credentials: 'include' を入れる。

/src/graph/Fetchers.ts

// 前略

export const requireAuthFetchData = <TData, TVariables>(
  query: string,
  variables?: TVariables,
  options?: RequestInit['headers'],
): (() => Promise<TData>) => {
  return async () => {
    const user = await getCurrentUser()
    const token = (await user?.getIdToken()) ?? ''
    const res = await fetch(process.env.NEXT_PUBLIC_API_BASE_URL as string, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
        ...options,
      },
      credentials: 'include',
      body: JSON.stringify({
        query,
        variables,
      }),
    })

// 以下略

バックエンドの設定:ヘッダの指定

まずレスポンスヘッダに Access-Control-Allow-Credentials: true を指定する。

私は下記のような Middleware を作っている。

/internal/middleware/cors/cors.go

package cors

import (
	"net/http"
)

func Cors(origin string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", origin)
		w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "content-type, Authorization")
		w.Header().Set("Access-Control-Allow-Credentials", "true")
		next.ServeHTTP(w, r)
	})
}

前述のフロントエンドの設定+このレスポンスヘッダの設定が無いと Set-Cookie が無視される。

バックエンドの設定:Redis への接続設定

env

REDIS_PASSWORD というキーでパスワードを記載しておく。これは compose.yaml 側の env で指定したパスワードと同一の値にする必要がある。
また SESSION_KEY というキーでセッションキーを記載しておく。

server.go

server.go 全文

下記のような関数で Redis に接続できて gorilla/sessions のバックエンドとして使えるようになるはず。
“gql_sololog_redis:6379” の部分は compose.yaml で指定したコンテナ名・ポート番号に合わせる。

import (
	"os"

	"github.com/joho/godotenv"
	redistore "gopkg.in/boj/redistore.v1"
)

func connectRedis() (*redistore.RediStore, error) {
    err := godotenv.Load(".env")
	if err != nil {
		clog.Emergency(err)
	}
    sessionKey := os.Getenv("SESSION_KEY")
	redisPassword := os.Getenv("REDIS_PASSWORD")
	rs, err := redistore.NewRediStore(100, "tcp", "gql_sololog_redis:6379", redisPassword, []byte(sessionKey))
	if err != nil {
		return nil, err
	}
	return rs, nil
}

バックエンドの設定: セッションの取得や保存のためのミドルウェア

gqlgen では resolver に http.Requst, http.ResponseWriter の参照が無いので自分で参照を保持しておく必要がある。
そのためのミドルウェアを作成する。ここでは session.go とする。

参考:Question: Setting cookies #567

/internal/middleware/session/session.go

package session

import (
	"context"
	"net/http"

	"github.com/gorilla/sessions"
	redistore "gopkg.in/boj/redistore.v1"
)

type SessionContext struct {
	req   *http.Request
	resp  *http.ResponseWriter
	store *redistore.RediStore
}

func Session(redis *redistore.RediStore, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), sessionKey, &SessionContext{
			req:   r,
			resp:  &w,
			store: redis,
		})
		r = r.WithContext(ctx)
		next.ServeHTTP(w, r)
	})
}

type ctxKey int

const (
	sessionKey ctxKey = iota
)

func GetSession(ctx context.Context, name string) (*sessions.Session, error) {
	store := ctx.Value(sessionKey).(*SessionContext).store
	req := ctx.Value(sessionKey).(*SessionContext).req
	return store.Get(req, name)
}

func SaveSession(ctx context.Context, s *sessions.Session) error {
	sessionContext := ctx.Value(sessionKey).(*SessionContext)
	return s.Save(sessionContext.req, *sessionContext.resp)
}

バックエンドの設定: Resolver 内でのセッションの取得・保存

/graph/schema.resolvers.go

値の取得時

s, err := sessionMiddleware.GetSession(ctx, "your-session-name")
if err != nil {
	// エラー処理
}
testValue := s.Values["testkey"]

値のセット時

s.Values["testkey"] = "test"
s.Options = &sessions.Options{
	Path:     "/",
	MaxAge:   3600,
	HttpOnly: true,
	SameSite: http.SameSiteLaxMode,
	Secure:   true,
}
err = sessionMiddleware.SaveSession(ctx, s)
if err != nil {
	// エラー処理
}

上記の設定でフロントエンドとバックエンドの間にセッションが張られる。

関係ない部分も含めた全ソースコード

変更点の全部が入ったコミット

以上