はじめに
こんにちは、kenです。
先日、システム内で異なる権限を持つユーザーに対して、一部叩けるAPIに制限をかける必要がありました。その際OpenAPIのyamlに書かれる「Tags」機能を使うことでこの問題を解決したので、本記事ではこの方法の詳細と実際の実装サンプルをご紹介したいと思います。これは比較的簡単で、かつメンテナンスが容易な方法だと思います。
本記事はHRBrain Advent Calendar 2023の14日目の記事です。
経緯
経緯については差し障りがないよう一部情報をぼかして書きますが、先日業務の中で「とある条件Aを満たしているユーザーについては一部のAPIを叩けないようにする」という対応をする必要がありました。
身近な例で例えると、一般会員とプレミアム会員のように会員の中でグレードがあるようなシステム体系のなかで、プレミアム会員にだけ許されているAPIを一般会員は叩けないようにするというシチュエーションです。わかりやすさのため、以下ではこのシチュエーションで話を進めさせてください。つまり「先程の条件A = 一般会員」ということです。
今回制限するAPIのエンドポイントというのは今後増える可能性があったため、メンテナンス性を考慮すると次のことが求められました。
- 一般会員が叩けないAPIを一覧で確認できる
- ひとつのAPIについて制限をかける/かけないの切り替えを簡単に行うことができる
最初に思い浮かんだのはプレミアム会員のみに許されているエンドポイントを叩いたユーザーが本当にプレミアム会員かを逐一確認するという方法です。しかしこれだと制限をかけているAPIを一覧性をもって確認したいという要件には応えられません。制限をかけているAPIかどうかを確かめるにはわざわざアプリケーションのコードを確認する必要があるからです。
アプリケーションコードの中で制御をかけるのが難しいとなれば、APIスキーマであるところのyamlファイルで叩けるAPIの制御をかけたいところです。もしそれが実現できればyamlのAPI設計書としての役割を十分に果たすことができますし、また上であげた2つの要件についても達成できます。すなわち
- 一般会員が叩けないAPIを一覧で確認できる→Swagger UIをつかえばTagごとにカテゴライズされたAPIの一覧を見ることができる
- ひとつのAPIについて制限をかける/かけないの切り替えを簡単に行うことができる→Tagをつけるのはyamlファイルにたった一行追加するだけなので簡単
しかしとはいえ本当にyamlから叩けるAPIの制御などできるものなのでしょうか…??
解決
結論から書くとできました。
私の所属しているチームではOpenAPIのyamlからoapi-codegenを用いてルーティング周りのコードを自動生成しています。
そしてこのoapi-codegenではyamlファイルの情報を構造化された形で復元することができます。
これにより予めプレミアム会員にのみ許されるAPIにはそれを示すようなTag1をつけておけば、その復元したyamlから叩かれたエンドポイントについているTagsを確認することで、プレミアム会員にだけ許されているAPIかどうかを判断できます。もしそうなのであればログイン情報を確認して一般会員であればforbiddenを返すようにすればいいのです。
文字だけだと何をいっているのかピンとこないと思いますので、実装のサンプルを用いて説明します。 以下のサンプルコードでは2つのエンドポイントが用意されており、1つは私のプロフィール情報、もう1つは私の恥ずかしいプロフィール情報を取得できます。ただし恥ずかしいプロフィール情報は見られたくないのでプレミアム会員しかアクセスできないようにしたいです。
そこでOnlyPremium
というタグを使用し、このタグが付いたAPIはmiddlewareの段階でプレミアム会員かどうかをチェックし、そうでないならforbiddenを返すようにします。
↑サンプルコードのリポジトリです。以下では重要な部分だけを抜粋して貼り付けます。
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
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)
}
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)
})
}
}
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をつかえばこのように確認することが可能です。
おわりに
今回はOpenAPIのTagsを用いて叩けるAPIを制御する方法についてご紹介しました。
oapi-codegenで生成されるコードをうまく使えばTagsの情報まで取得することができるんですね。
この記事が誰かの役に立てば幸いです。
株式会社HRBrainでは新しいメンバーを募集しています。
働きやすく、コミュニケーションがとりやすい職場ですので弊社に興味を持った方がいればぜひご応募ください、ここまで読んでいただきありがとうございました!