OpenAPIのTagsを使って特定のエンドポイントに対してのみミドルウェアを適用する

# はじめに
こんにちは、kenです。
先日、システム内で異なる権限を持つユーザーに対して、一部叩けるAPIに制限をかける必要がありました。その際OpenAPIのyamlに書かれる「Tags」機能を使うことでこの問題を解決したので、本記事ではこの方法の詳細と実際の実装サンプルをご紹介したいと思います。これは比較的簡単で、かつメンテナンスが容易な方法だと思います。

本記事はHRBrain Advent Calendar 2023の14日目の記事です。

https://qiita.com/advent-calendar/2023/hrbrain

# 経緯
経緯については差し障りがないよう一部情報をぼかして書きますが、先日業務の中で「とある条件Aを満たしているユーザーについては一部のAPIを叩けないようにする」という対応をする必要がありました。

身近な例で例えると、一般会員とプレミアム会員のように会員の中でグレードがあるようなシステム体系のなかで、プレミアム会員にだけ許されているAPIを一般会員は叩けないようにするというシチュエーションです。わかりやすさのため、以下ではこのシチュエーションで話を進めさせてください。つまり「先程の条件A = 一般会員」ということです。
<br>

今回制限するAPIのエンドポイントというのは今後増える可能性があったため、メンテナンス性を考慮すると次のことが求められました。
- 一般会員が叩けないAPIを一覧で確認できる
- ひとつのAPIについて制限をかける/かけないの切り替えを簡単に行うことができる


最初に思い浮かんだのはプレミアム会員のみに許されているエンドポイントを叩いたユーザーが本当にプレミアム会員かを逐一確認するという方法です。しかしこれだと制限をかけているAPIを一覧性をもって確認したいという要件には応えられません。制限をかけているAPIかどうかを確かめるにはわざわざアプリケーションのコードを確認する必要があるからです。

<br>

アプリケーションコードの中で制御をかけるのが難しいとなれば、APIスキーマであるところのyamlファイルで叩けるAPIの制御をかけたいところです。もしそれが実現できればyamlのAPI設計書としての役割を十分に果たすことができますし、また上であげた2つの要件についても達成できます。すなわち
- 一般会員が叩けないAPIを一覧で確認できる→**Swagger UIをつかえばTagごとにカテゴライズされたAPIの一覧を見ることができる**
- ひとつのAPIについて制限をかける/かけないの切り替えを簡単に行うことができる→**Tagをつけるのはyamlファイルにたった一行追加するだけなので簡単**



しかしとはいえ本当にyamlから叩けるAPIの制御などできるものなのでしょうか…？？
# 解決
結論から書くとできました。
私の所属しているチームではOpenAPIのyamlから[oapi-codegen](https://github.com/deepmap/oapi-codegen)を用いてルーティング周りのコードを自動生成しています。

https://github.com/deepmap/oapi-codegen

そしてこのoapi-codegenではyamlファイルの情報を構造化された形で復元することができます。

これにより予めプレミアム会員にのみ許されるAPIにはそれを示すようなTag[^1]をつけておけば、その復元したyamlから叩かれたエンドポイントについているTagsを確認することで、プレミアム会員にだけ許されているAPIかどうかを判断できます。もしそうなのであればログイン情報を確認して一般会員であればforbiddenを返すようにすればいいのです。
[^1]: TagというのはAPIのオペレーションをグルーピングするためのOpenAPIの機能です。詳しくは[こちら](https://swagger.io/docs/specification/grouping-operations-with-tags/)をご確認ください

<br>
文字だけだと何をいっているのかピンとこないと思いますので、実装のサンプルを用いて説明します。
以下のサンプルコードでは2つのエンドポイントが用意されており、1つは私のプロフィール情報、もう1つは私の恥ずかしいプロフィール情報を取得できます。ただし恥ずかしいプロフィール情報は見られたくないのでプレミアム会員しかアクセスできないようにしたいです。
<br>

そこで`OnlyPremium`というタグを使用し、このタグが付いたAPIはmiddlewareの段階でプレミアム会員かどうかをチェックし、そうでないならforbiddenを返すようにします。

https://github.com/ken-hashimoto/qiita-example-oapi-codegen-tags

↑サンプルコードのリポジトリです。以下では重要な部分だけを抜粋して貼り付けます。
```yaml
openapi: 3.0.0
info:
  version: 1.0.0
  title: Example
  description: 記事用のAPI
tags:
  - name: OnlyPremium
paths:
  /profile:
    get:
      operationId: GetAuthorProfile
      summary: 私のプロフィール情報を返すAPI（誰でも叩くことができる）
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Profile'
  /profile/secret:
    get:
      operationId: GetAuthorSecretProfile
      summary: 私の秘密のプロフィール情報を返すAPI（プレミアム会員のみ叩くことができる）
      tags:
        - OnlyPremium # このタグがついているAPIはプレミアム会員のみ叩くことができる!
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Profile'
components:
  schemas:
    Profile:
      type: object
      properties:
        name:
          type: string
        self_introduce:
          type: string
      required:
        - name
        - self_introduce
```

```main.go
package main

import (
	"log"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/ken-hashimoto/oapi-codegen-swagger/handler"
	"github.com/ken-hashimoto/oapi-codegen-swagger/middleware"
	"github.com/ken-hashimoto/oapi-codegen-swagger/router"
)

func main() {
	r := chi.NewRouter()
	middleware := middleware.NewMiddlewares()
	var apiHandler = handler.NewApiHandler(
		middleware,
	)

	swagger, err := router.GetSwagger()
	if err != nil {
		log.Fatalf("failed to get swagger spec: %v\n", err)
	}
	m := apiHandler.Middleware
	r.Use(m.ForbidMySecret(swagger))
	router.HandlerFromMux(apiHandler, r)
	http.ListenAndServe(":8080", r)
}
```

```middleware.go
package middleware

import (
	"log"
	"net/http"

	"golang.org/x/exp/slices"

	"github.com/getkin/kin-openapi/openapi3"
	"github.com/getkin/kin-openapi/routers/gorillamux"
	"github.com/go-chi/render"
)

type Middleware interface {
	ForbidMySecret(swagger *openapi3.T) func(next http.Handler) http.Handler
}

type middleware struct{}

func NewMiddlewares() Middleware {
	return middleware{}
}

func (m middleware) ForbidMySecret(swagger *openapi3.T) func(next http.Handler) http.Handler {
	// Swagger仕様を基にしたrouterを生成
	router, err := gorillamux.NewRouter(swagger)
	if err != nil {
		panic(err)
	}

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// 受け取ったHTTPリクエストに基づいて、対応するルートを探す
			route, _, err := router.FindRoute(r)
			if err != nil {
				log.Fatal(err)
				return
			}
			// ルートが見つかった場合、そのルートのHTTPメソッドに関連付けられているタグを取得
			tags := route.PathItem.GetOperation(r.Method).Tags
			// タグが「OnlyPremium」を含むかどうかを確認
			isOnlyPremium := slices.Contains(tags, "OnlyPremium")
			if isOnlyPremium {
				/*
					ここで本当ならプレミアム会員かどうかを確認
					（ここではその実装を割愛して常にforbiddenを返す）
				*/
				render.Status(r, 403)
				render.JSON(w, r, "見せられないよ！")
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

```

```handler.go
package handler

import (
	"net/http"

	"github.com/go-chi/render"
	"github.com/ken-hashimoto/oapi-codegen-swagger/middleware"
	"github.com/ken-hashimoto/oapi-codegen-swagger/router"
)

func NewApiHandler(
	middleware middleware.Middleware,
) *ApiHandler {
	return &ApiHandler{
		Middleware: middleware,
	}
}

type ApiHandler struct {
	Middleware middleware.Middleware
}

func (h ApiHandler) GetAuthorProfile(w http.ResponseWriter, r *http.Request) {
	profile := router.Profile{
		Name:          "ken",
		SelfIntroduce: "こんにちは、kenです。好きなアニメはBANANA FISHです。",
	}
	render.JSON(w, r, profile)
}

func (h ApiHandler) GetAuthorSecretProfile(w http.ResponseWriter, r *http.Request) {
	profile := router.Profile{
		Name:          "ken",
		SelfIntroduce: "こんにちは、kenです。最近12の倍数の年齢のときに歳男になるのだと気づきました。",
	}
	render.JSON(w, r, profile)
}
```

実際にサンプルコードを動かしてみてリクエストを送ってみます。
```
$ go run main.go
```
普通のプロフィール情報は取得できますが…
```
$ curl 'http://localhost:8080/profile'
{"name":"ken","self_introduce":"こんにちは、kenです。好きなアニメはBANANA FISHです。"}
```
秘密のプロフィール情報は取得することができなくなっています！！これでたしかにOpenAPIのyamlに書いたTagsでの制御が効いていることが確認できました。
```
$ curl 'http://localhost:8080/profile/secret'
"見せられないよ！"
```

また、「一般会員が叩けないAPIを一覧で確認したい」という要件もSwagger UIをつかえばこのように確認することが可能です。
![スクリーンショット 2023-12-07 16.21.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649218/54636f24-eafa-900b-4221-6e45f44cf627.png)


# おわりに
今回はOpenAPIのTagsを用いて叩けるAPIを制御する方法についてご紹介しました。
oapi-codegenで生成されるコードをうまく使えばTagsの情報まで取得することができるんですね。
この記事が誰かの役に立てば幸いです。

株式会社HRBrainでは新しいメンバーを募集しています。

https://www.hrbrain.co.jp/recruit

働きやすく、コミュニケーションがとりやすい職場ですので弊社に興味を持った方がいればぜひご応募ください、ここまで読んでいただきありがとうございました！