自動生成されたhttpエンドポイントごとにカスタムミドルウェアを挿入したい話 ~実装編~

はじめに
この記事は、Go Conference 2024 で発表した「自動生成されたhttpエンドポイントごとにカスタムミドルウェアを挿入したい話」にて、スライドには収まらなかったサンプル実装について書きます。
概要をざっくり掴みたい方は、スライドも併せてご覧ください 👋
参加レポも書いたので、こちらもチェックよろしくです 👍
発表で話したことのおさらい
OpenAPI定義からGoコードを生成するツールにoapi-codegenがあります。oapi-codegenでは現状、個別のエンドポイントに対してミドルウェア処理を行う方法がサポートされていません。
そのため、発表ではOpenAPI側からのアプローチと独自のContextを用意することで解決した話をしました 👋
方針
発表で説明したとおり、個別のエンドポイントに対してミドルウェアを設定するように実装します。
最終的なコードはこちらです。
OpenAPI
通常のエンドポイントに加えて、Admin用のエンドポイントを用意しています。
ここで注目するのは secrutiry
と securitySchemes
の部分です。
paths:
/things:
get:
security:
- Role:
- "normal"
responses:
200:
description: a list of things
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ThingWithID'
/admin/things:
get:
security:
- Role:
- "admin"
responses:
200:
description: a list of things
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ThingWithID'
components:
securitySchemes:
Role:
type: http
scheme: bearer
どちらもOpenAPI 3.0 で定義されているキーワードです。
securitySchemes
キーワードを使って、任意の名前でスコープを作成します。
作成したスコープは、 security
キーワードを使って各APIに対して設定します。
上記の内容で、 oapi-codegen
にてGoコードを生成します。
生成したコードには指定したルーティングエンジンの処理が含まれます。
サンプルでは、ルーティングエンジンは Echo
を指定しています。
RegisterHandlers
を呼び出すことで、エンドポイントとハンドラ関数の紐づけを行います。
// RegisterHandlers adds each server route to the EchoRouter.
func RegisterHandlers(router EchoRouter, si ServerInterface) {
RegisterHandlersWithBaseURL(router, si, "")
}
// Registers handlers, and prepends BaseURL to the paths, so that the paths
// can be served under a prefix.
func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) {
wrapper := ServerInterfaceWrapper{
Handler: si,
}
router.GET(baseURL+"/admin/things", wrapper.GetAdminThings)
router.GET(baseURL+"/things", wrapper.GetThings)
}
ルーティングされるハンドラ関数も以下のように生成されます。
// ServerInterfaceWrapper converts echo contexts to parameters.
type ServerInterfaceWrapper struct {
Handler ServerInterface
}
// GetAdminThings converts echo context to params.
func (w *ServerInterfaceWrapper) GetAdminThings(ctx echo.Context) error {
var err error
ctx.Set(RoleScopes, []string{"admin"})
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetAdminThings(ctx)
return err
}
// GetThings converts echo context to params.
func (w *ServerInterfaceWrapper) GetThings(ctx echo.Context) error {
var err error
ctx.Set(RoleScopes, []string{"normal"})
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetThings(ctx)
return err
}
OpenAPIで設定したスコープが設定されていることが確認できます。
スコープは、 RoleScopes
という名前で ctx
に格納されます。
アプリケーションではスコープを取り出して、スコープに紐づくミドルウェアを実行するような実装を行います。
また、上記のハンドラ関数で呼び出している w.Handler
の型はServereInterface
です。
これはルーティング関数、ハンドラ関数と同様に生成されたインタフェースです。
// ServerInterface represents all server handlers.
type ServerInterface interface {
// (GET /admin/things)
GetAdminThings(ctx echo.Context) error
// (GET /things)
GetThings(ctx echo.Context) error
}
アプリケーションは、このインタフェースを満たす実装を用意していきます。
アプリケーション
生成されたインタフェースを満たすように実装したコードは以下のとおりです。
var _ gen.ServerInterface = (*Server)(nil)
type Server struct {}
func NewServer() Server {
return Server{}
}
func (s Server) GetAdminThings(ctx echo.Context) error {
log.Printf("GetAdminThings")
return nil
}
func (s Server) GetThings(ctx echo.Context) error {
log.Printf("GetThings")
return nil
}
上記の実装と生成されたコードを、main関数にて紐づけを行います。
func main() {
s := server.NewServer()
e := echo.New()
gen.RegisterHandlers(e, s)
// And we serve HTTP until the world ends.
log.Fatal(e.Start("0.0.0.0:8080"))
}
独自のContext
各エンドポイントごとのミドルウェア実行を実現するために、独自のContextとそれに紐づくレシーバメソッドを用意します。
実装は、以下の記事を参考にしています。
type (
Middleware func()
Middlewares map[string][]Middleware
)
type OriginalContext struct {
echo.Context
}
func NewOriginalContext(ctx echo.Context) *OriginalContext {
return &OriginalContext{ctx}
}
func (c OriginalContext) BindValidate(m Middlewares, i interface{}) error {
scopes, ok := c.Get(gen.RoleScopes).([]string)
if !ok {
scopes = []string{}
}
for _, scope := range scopes {
if middleware, ok := m[scope]; ok {
for _, mw := range middleware {
mw()
}
}
}
if i == nil {
return nil
}
if err := c.Bind(i); err != nil {
return err
}
if err := c.Validate(i); err != nil {
return err
}
return nil
}
独自のContextであるOriginalContext
は、 echo.Context
をラップしています。
生成されたコードで確認した通り、echo.Context
からOpenAPIで定義したスコープを取得できます。
取得したスコープをもとに、割り当てたミドルウェアのリストを取り出して順次実行することができます。
ミドルウェアは以下のように、サーバを初期化するときにDIすることができます。
type Server struct {
m Middlewares
}
func NewServer() Server {
m := Middlewares{
"admin": []Middleware{
func() {
log.Printf("admin middleware")
},
func() {
log.Printf("admin middleware 2")
},
},
"normal": []Middleware{
func() {
log.Printf("normal middleware")
},
},
}
return Server{m: m}
}
func (s Server) GetAdminThings(ctx echo.Context) error {
log.Printf("GetAdminThings")
c := ctx.(*OriginalContext)
if err := c.BindValidate(s.m, nil); err != nil {
return err
}
return nil
}
func (s Server) GetThings(ctx echo.Context) error {
log.Printf("GetThings")
c := ctx.(*OriginalContext)
if err := c.BindValidate(s.m, nil); err != nil {
return err
}
return nil
}
まとめ
発表では説明できなかった具体的な実装について紹介しました。
あくまで oapi-codegen
で現状サポートされていないという前提のアプローチなので、参考程度に見ていただけると幸いです。
さいごに
引き続き、issueの動向を見守りつつ、アップデートがあれば共有しますので、ぜひフォローもよろしくお願いします 👋
感想・ご意見ありましたら、メンション・DMでコメントよろしくお願いします(引用ポストでも泣いて喜びます)。
採用情報
Finatext グループでは一緒に働く仲間を募集中です。
会社のことも含めてカジュアルにお話したい方はこちらのフォームからもご応募お待ちしております 👍