go OpenAPI Specification middleware

【Go】OpenAPI Generatorを用いて自動で生成したAPIサーバーのコードにミドルウェア層を導入する

go OpenAPI Specification middleware

こんにちは。ホスティング事業部の@matsusukeです。

今年もサッカーのプレミアリーグが開幕しましたね。 日本代表の冨安選手が在籍しているアーセナルを高校生の頃から応援しています。 今年はワールドカップも開催されるので、より一層活躍してほしいですね。

今回は、OpenAPI Generator を用いて自動で生成したAPIサーバーのコードにミドルウェア層を導入した事例を紹介します。

ミドルウェア層を導入する理由

Webアプリケーション開発において、開発の初期段階からプロジェクトの規模が大きくなってコードの量が増えると、各APIの処理の共通化を行えそうなコードが発生します。
例えば、「認証ができなかった場合、その後のアプリケーションロジックの処理を行わず、エラーを返す」といった認証処理が例として挙げられます。

上記のようなケースだと、そもそも認証ができなかった場合にはアプリケーションロジックが関わるコードを呼ばずにエラーを返した方がオーバーヘッドが少なくなるといったメリットがあります。

OpenAPI Generator を用いたGoのコード生成

OpenAPI Generatorは、yamlファイルにAPIの仕様を定義することで各開発言語でAPIクライアント/サーバーのコードを自動で生成できるツールです。

下記のような、OpenAPI Specification v3の仕様に沿ったドキュメントから自動でコードを生成できます。

# api.yaml
openapi: 3.0.3
info:
  version: 0.0.1
  title: XXX API
  description: XXXについてのAPI
tags:
  - name: Login
    description: Login
paths:
  /login:
    get:
      summary: Login
      description: ログイン
      tags:
        - Login
      operationId: startLogin
      responses:
        '200':
          description: OK
        '400':
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/defaultErrorResponse'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/loginBody'
components:
  schemas:
    loginBody:
      type: object
      properties:
        id:
          type: string
    defaultErrorResponse:
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              message:
                type: string
              detail:
                type: object
            required:
              - message
      required:
        - errors

今回はDocker Imageを用いてコードを生成する方法をご紹介します。

コードを生成するプロジェクト上で

docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli:$(GENERATOR_VERSION) generate \
     -i /local/spec/api.yaml 
     -g go-server 
     -p packageVersion=0.0.1 
     -o /local

を実行します。 上記のコマンドの各オプションの意味は以下の通りです。

  • i
    • 使用するyamlファイルの指定をします。
    • 今回、使用するyamlファイルは/spec/api.yamlとしています。
  • g
    • 生成するコードの指定。go-serverは、Go言語でAPIサーバーのコードを生成するための指定ワードです。
  • p
    • プロパティを追加することができます。ここではpackageVersionをプロパティに指定していますが、このプロパティはgo-serverでコードを生成する時に使用できるプロパティです。
    • go-serverで設定できるプロパティの一覧についてはgo-serverのCONFIG OPTIONSでご参照ください。

OpenAPI GeneratorはMacを使っている場合、Homebrewでinstallすることも可能です。 詳しくはGitHubをご参照ください。

また、生成されるコードについてはテンプレートの仕組みを用いてカスタマイズすることも可能です。
コードは以下のような形式で生成されます。

/project
├─ .openapi-generator
│    └─ VERSION
├─ api
│   └─ openapi.yaml
├─ go
│   ├─ api_login_service.go
│   ├─ api_login.go
│   ├─ api.go
│   ├─ error.gp
│   ├─ helpers.go
│   ├─ impl.go
│   ├─ logger.go
│   ├─ model_default_error_response_errors.go│   
│   ├─ model_default_error_response.go
│   └─ routers.go
├─ .openapi-generator-ignore
├─ go.mod
├─ go.sum
├─ main.go
└─ README.md

api_login_service.goではLoginApiService構造体が定義され、「引数として受け取った情報をもとにAPIで返したいデータを作る」というビジネスロジックをLoginApiService構造体のメソッドとして記述します。

// api_login_service.go

// LoginApiService is a service that implements the logic for the LoginApiServicer
// This service should implement the business logic for every endpoint for the LoginApi API.
// Include any external packages or services that will be required by this service.
type LoginApiService struct {
}

// StartLogin - Login
func (s *LoginApiService) StartLogin(ctx context.Context, loginBody LoginBody) (ImplResponse, error) {
  // TODO - update StartLogin with the required logic for this service method.
  // Add api_login_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation.
  
  //TODO: Uncomment the next line to return response Response(200, {}) or use other options such as http.Ok ...
  //return Response(200, nil),nil
  
  //TODO: Uncomment the next line to return response Response(400, DefaultErrorResponse{}) or use other options such as http.Ok ...
  //return Response(400, DefaultErrorResponse{}), nil
  
  // リクエストパラメータのloginBody構造体からIDを取得
  id := loginBody.id

  // idを使って何らかの処理を行うビジネスロジックを記述する

  return Response(http.StatusNotImplemented, nil), errors.New("StartLogin method not implemented")
}

go-serverの特徴として、各APIはapi.yaml

tags:
  - Login

で指定したtagのService構造体のメソッドとして定義されます。 tagがLoginのAPIを増やすと、LoginApiService構造体にメソッドが随時生成されます。

別のService構造体にメソッドを生成したい場合、APIのtagを任意のものに指定することで、メソッドを生成することができます。

生成したコードのルーティング

api_login.goは「/loginのパスにGETリクエストがきたら、所定のapi_login_service.goStartLoginメソッドを呼び出し、レスポンスのもとになるデータを作り、返す」という処理を担っています。

// api_login.go

// LoginApiController binds http requests to an api service and writes the service results to the http response
type LoginApiController struct {
  service      LoginApiServicer
  errorHandler ErrorHandler
}

func (c *LoginApiController) StartLogin(w http.ResponseWriter, r *http.Request) {
  loginBodyParam := LoginBody{}
  d := json.NewDecoder(r.Body)
  d.DisallowUnknownFields()
  if err := d.Decode(&loginBodyParam); err != nil {
    c.errorHandler(w, r, &ParsingError{Err: err}, nil)
    return
  }
  if err := AssertLoginBodyRequired(loginBodyParam); err != nil {
    c.errorHandler(w, r, err, nil)
    return
  }
  result, err := c.service.StartLogin(r.Context(), loginBodyParam)
  // If an error occurred, encode the error with the status code
  if err != nil {
    c.errorHandler(w, r, err, &result)
    return
  }
  // If no error, encode the body and the result code
  EncodeJSONResponse(result.Body, &result.Code, result.Headers, w)
}

LoginApiController構造体のメソッドとして、各API(StartLogin)が定義されています。

また、LoginApiController構造体にはRoutes()メソッドも定義されています。

// api_login.go

func (c *LoginApiController) Routes() Routes {
  return Routes {
    {
      Name:        "StartLogin",
      Method:      strings.ToUpper("Get"),
      Pattern:     "/api/login/",
      HandlerFunc: c.StartLogin,
    },
  }
}

/api/login/のパスにGETリクエストが来ると、controllerのStartLoginを呼び出す」、といった処理が記載されています。

router.goの中で、この対応関係から実際のルーティングを生成する関数が定義されています。
go-serverは、特に何も設定しなければ、デフォルトのルーターとしてgithub.com/gorilla/muxが使用され、それを組み立てるNewRouter関数が生成されます。

// router.go

func NewRouter(routers ...Router) *mux.Router {
  router := mux.NewRouter().StrictSlash(true)
  for _, api := range routers {
    for _, route := range api.Routes() {
      var handler http.Handler
      handler = route.HandlerFunc
      handler = Logger(handler, route.Name)

      router.
        Methods(ro.Method).
        Path(ro.Pattern).
        Name(ro.Name).
        Handler(handler)
    }
  }
  return router
}

NewRouterメソッドの戻り値である*mux.Router型はgithub.com/gorilla/mux

type Router struct {
  // 省略
}

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  // 省略
}

のように定義されています。

また、Goの標準パッケージであるnet/httpのinterfaceであるhttp.Handlerは下記のように定義されています。

type Handler interface {
  ServeHTTP(ResponseWriter, *Request)
}

ResponseWriter*Requestを引数に持つServeHTTP()というメソッドを持つ構造体であれば、 このhttp.Handlerinterfaceを満たします。
つまり、ServeHTTP(w http.ResponseWriter, req *http.Request)メソッドを持つ*mux.Router型はhttp.Handlerinterfaceを満たしています。 (net/httpについてはGoでHTTPサーバーを作る時には必須と言っても過言ではないパッケージですが、ここでは詳細の説明は割愛します)

サーバーを動かす処理については、net/httpで定義されているListenAndServeメソッドを使って実装します。

// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {
  server := &Server{Addr: addr, Handler: handler}
  return server.ListenAndServe()
}

ListenAndServeは第2引数にhttp.Handlerinterfaceを取るので、NewRouterメソッドの戻り値を使用することが可能です。

go-serverがデフォルトで生成するmain.goでは、

// main.go

func main() {
  log.Printf("Server started")
  
  LoginApiService := openapi.NewLoginApiService()
  LoginApiController := openapi.NewLoginApiController(LoginApiService)
  
  router := openapi.NewRouter(LoginApiController)
  log.Fatal(http.ListenAndServe(":8080", router))
}

のように記述されており、ルーティングを実装しています。

ミドルウェア層の実装

開発の規模が大きくなると、APIの数が増え、api_*_service.goが肥大化していきます。 ミドルウェア層を導入することで、認証処理などの各APIで共通している処理を統合・リファクタリングすることができます。 ミドルウェアの実装はGoのミドルウェアパターンとしてよく知られる方法を用いますが、改めて下記で詳細を説明しようと思います。

Goのミドルウェアパターンについて

Goのミドルウェアパターンを満たす最低限の実装は

func exampleMiddleware(next http.Handler) http.Handler {
  return next
}

のように、http.Handlerinterfaceを引数として受け取り、http.Handlerinterfaceを返却する関数として定義します。

http.Handlerと似たような名前のhttp.HandlerFuncnet/httpで定義されている型です。 http.HandlerFunc型については、下記のように定義されています。

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
  f(w, r)
}

http.HandlerFunc型はResponseWriter*Requestを引数に取るServeHTTP()というメソッドを持つ構造体です。 そのため、http.HandlerFunc型はhttp.Handlerinterfaceを満たしていることになります。

また、http.HandlerFunc型の説明文を日本語訳すると、

HandlerFunc 型は、通常の関数を HTTP ハンドラとして使用できるようにするためのアダプタです。f が適切なシグネチャを持つ関数である場合、HandlerFunc(f) はf を呼び出すハンドラです。

と書かれています。

これは、http.HandlerFuncResponseWriter*Requestを引数に取る関数であり、ある関数fResponseWriter*Requestを引数に取る場合、http.HandlerFunc(f)fを呼び出す関数ということになります。

つまり、exampleMiddlewareについては、

  • 引数・戻り値はhttp.Handlerinterfaceを満たしているため、http.HandlerFunc型の関数でも良い。
  • 戻り値のhttp.HandlerFunc型の関数については、「任意の処理を行う関数(f)を呼び出すhttp.HandlerFunc型の関数」でもよい ということになり、下記のようなコードで表現できます。
func exampleMiddleware(next http.HandlerFunc) http.HandlerFunc {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // 任意の処理を記述する
    
    // 引数で渡したhttp.HandlerFuncを実行する
    next(w, r)
  })
}

このようなミドルウェアを作ることで、「任意の処理を実行した後に、次の処理を呼ぶ」という実装を表現することができます。

生成したmux.Routerに対してのミドルウェアパターンの導入

router.goNewRouterで生成した*mux.Routerに対して、どのようにミドルウェアパターンを導入するのかについてご紹介します。

// router.go

func NewRouter(routers ...Router) *mux.Router {
  router := mux.NewRouter().StrictSlash(true)
  for _, api := range routers {
    for _, route := range api.Routes() {
      var handler http.Handler
      handler = route.HandlerFunc
      handler = Logger(handler, route.Name)

      router.
        Methods(route.Method).
        Path(route.Pattern).
        Name(route.Name).
        Handler(handler)
    }
  }
  return router
}

router.go内では

router := mux.NewRouter().StrictSlash(true)

という記述で、github.com/gorilla/muxのRouter型のrouter変数を定義しています。
ループ内のhandler変数は、http.Handlerinterfaceで初期化後、http.HandlerFunc型のroute.HandlerFuncが代入されています。

また、生成したデフォルトのrouter.go内ではLogger()メソッドを使うことでミドルウェアパターンを既に実装しています。

// logger.go

func Logger(inner http.Handler, name string) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    start := time.Now()

    inner.ServeHTTP(w, r)

    log.Printf(
      "%s %s %s %s",
      r.Method,
      name,
      time.Since(start),
    )
  })
}

Logger()メソッドは「引数のhttp.HandlerinterfaceのServeHTTP()メソッドを実行後にlogの出力を行う、http.HandlerFunc型の関数を返す」関数です。

つまり、戻り値のhandler変数はこの段階で「引数のhandlerを実行後logの出力を行う、http.HandlerFunc型の関数」となっています。

さらに、このhandler変数を引数にして

router.
  Methods(route.Method).
  Path(route.Pattern).
  Name(route.Name).
  Handler(handler)

routerHandler()メソッドを呼び出しています。 routergithub.com/gorilla/muxのRouter型で定義されていましたが、Methods()メソッドが

// Methods registers a new route with a matcher for HTTP methods.
// See Route.Methods().
func (r *Router) Methods(methods ...string) *Route {
  return r.NewRoute().Methods(methods...)
}

// NewRoute registers an empty route.
func (r *Router) NewRoute() *Route {
  // initialize a route with a copy of the parent router's configuration
  route := &Route{routeConf: copyRouteConf(r.routeConf), namedRoutes: r.namedRoutes}
  r.routes = append(r.routes, route)
  return route
}

と定義されているため、Handler()メソッドを呼び出している型はgithub.com/gorilla/muxのRoute型になっています。
github.com/gorilla/muxのRoute型のHandler()メソッドは

// Handler sets a handler for the route.
func (r *Route) Handler(handler http.Handler) *Route {
  if r.err == nil {
    r.handler = handler
  }
  return r
}

と定義されており、引数はhttp.Handlerinterfaceとなっています。

これらのことから、Handler()メソッドの引数については、http.Handlerinterfaceを満たすものであればよい、ということがわかりました。

Handler()メソッドの引数にはLogger()メソッドのようなミドルウェアパターンを使えることがわかったので、共通化したい処理をミドルウェアとして新たに作成します。
今回は例として、認証処理のミドルウェアであるAuthMiddlewareを実装します。

// middleware/auth_middleware.go

func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
  // http.HandlerFunc 型の関数を返す処理
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // リクエストヘッダーからアクセストークンなどを取得
    ah := r.Header.Get("Authentication")

    // アクセストークンなどを使って認証処理
    if err := service.Auth(ah); err != nil {
      // アクセストークンを使った認証に失敗した場合、errorを返す
      api.EncodeJSONResponse(w, r, errors.New("unauthorized"), nil)
      return
    }

    // 認証に成功した場合、引数であるhttp.HandlerFuncを実行する
    next(w, r)
  })
}

AuthMiddlewareのポイントは、「認証に成功した場合は、引数のhttp.HandlerFuncをコールし、認証に失敗した場合は、エラーを返す」関数を作っている、という点です。

// router.go

func NewRouter(routers ...Router) *mux.Router {
  router := mux.NewRouter().StrictSlash(true)
  for _, api := range routers {
    for _, route := range api.Routes() {
      router.
        Methods(route.Method).
        Path(route.Pattern).
        Name(route.Name).
        Handler(middleware.AuthMiddleware(route.HandlerFunc))
    }
  }
  return router
}

AuthMiddlewareの戻り値はhttp.Handlerinterfaceを満たすため、Handler()メソッドの引数として上記のように使用することができます。

ミドルウェアのテスト

middleware/auth_middleware.goAuthMiddlewareのテストを、「認証に成功した場合は、mockしたhttp.HandlerFuncが呼ばれているかどうか、失敗した場合は呼ばれていないかどうか」という観点で書いてみます。

// middleware/auth_middleware_test.go

func TestAuthMiddleware(t *testing.T) {
 const okMessage = "OK"
 const errMessage = "unauthorized"
  tests := []struct {
    name               string
    accessToken        string
    isValidAccessToken bool
    message            string
  }{
    {"認証に成功した場合、mockしたハンドラが呼ばれる", "validAccessToken", true, okMessage},
    {"認証に失敗した場合、mockしたハンドラが呼ばれない", "invalidAccessToken", false, errMessage},
  }
  for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
      // http.HandlerFunc型であるAuthMiddlewareを引数に、テストサーバを作る
      ts := httptest.NewServer(AuthMiddleware(getMockHandler()))
      t.Cleanup(func() {
        ts.Close()
      })
      // リクエストを作る
      p := new(RequestParam)
      jsonStr, err := json.Marshal(p)
      if err != nil {
        t.Error(err)
      }
      req, err := http.NewRequest(
        "GET",
        ts.URL+"/api/login",
        bytes.NewBuffer([]byte(jsonStr)),
      )
      if err != nil {
        t.Error(err)
      }
      // Content-Type 設定
      req.Header.Set("Content-Type", "application/json")
      // AccessToken 設定
      req.Header.Set("Authentication", "Bearer "+tt.accessToken)

      // リクエスト
      client := &http.Client{}
      resp, err := client.Do(req)
      if err != nil {
        t.Error(err)
      }

      t.Cleanup(func() {
        err := resp.Body.Close()
        if err != nil {
          t.Error(err)
        }
      })

      // モックした getMockHandler が呼ばれているかどうかの検証
      // AccessTokenが正当なものである場合、後続のhandlerがcallされる
      // AccessTokenが不正なものである場合、後続のhandlerがcallされず、errorが返却される
      {
        var got string
        if tt.isValidAccessToken {
          buf, err := io.ReadAll(resp.Body)
          if err != nil {
            t.Error(err)
          }
          got = string(buf)
        } else {
          buf, err := io.ReadAll(resp.Body)
          if err != nil {
            t.Error(err)
          }
          var errRespBody errRespBody
          if json.Unmarshal(buf, &errRespBody) != nil {
            t.Error(err)
          }
          got = errRespBody.errors[0].message
        }
        if want := tt.message; got != want {
          t.Errorf("\ngot %v\nwant %v\n", got, want)
        }
      }
    })
  }
}

type errRespBody struct {
  errors []respError `json:"errors"`
}

type respError struct {
  message string `json:"message"`
}

func getMockHandler() http.HandlerFunc {
  fn := func(w http.ResponseWriter, r *http.Request) {
    // レスポンスにメッセージを記録する
    w.Write([]byte("OK"))
  }
  return http.HandlerFunc(fn)
}

net/http/httptestパッケージのNewServerでテスト用のモックサーバーを立ち上げます。 NewServerの引数にはhttp.Handlerをとるため、モックハンドラーを返すミドルウェアを引数に使ってモックサーバを作ることができます。

テストではこのモックサーバに対してリクエストを行います。
認証に成功すればモックしたgetMockHandler()がコールされ、失敗すればコールされない、というテストを書くことで、ミドルウェアの正当性を担保します。

複数のミドルウェアの導入

開発が進むと、認証処理以外にも、ロギングやレート制限など、アプリケーションロジック以外に共通化したい処理が複数出てくるケースもあると思います。

// router.go

func NewRouter(routers ...Router) *mux.Router {
  router := mux.NewRouter().StrictSlash(true)
  for _, api := range routers {
    for _, route := range api.Routes() {
      router.
        Methods(route.Method).
        Path(route.Pattern).
        Name(route.Name).
        Handler(middleware.LoggingMiddleware(middleware.RateLimitMiddleware(middleware.AuthMiddleware(route.HandlerFunc))))
    }
  }
  return router
}

上記のように、ミドルウェアをネストして複数のミドルウェアを実装することも可能です。
しかし、可読性が低く、新しくミドルウェアを追加するたびにrouter.goを更新しなければならないため、実装コストが高くなってしまいます。

そこで、使用するミドルウェアの定義と複数のミドルウェアを使うための関数をミドルウェア層に切り出します。

// middleware/middleware.go

// http.HandlerFuncを引数に取り、http.HandlerFuncを返す型を定義する
type middleware func(next http.HandlerFunc) http.HandlerFunc

type middlewares []middleware

func FetchMiddlewares() middlewares {
  var ms middlewares

  ms = append(ms, loggingMiddleware)
  ms = append(ms, rateLimitMiddleware)
  ms = append(ms, authMiddleware)
}

func (m middlewares) Then (h http.HandlerFunc) http.HandlerFunc {
  for i := range m {
    h = m[len(m)-1-i](h)
  }
  return h
}

FetchMiddlewares()で使用するミドルウェアの一覧を定義し、Then()で、定義したすべてのミドルウェアを使用した後、最後にAPIのメソッドを呼ぶという実装を行うことができます。

// router.go

func NewRouter(routers ...Router) *mux.Router {
  ms := middleware.FetchMiddlewares()
  router := mux.NewRouter().StrictSlash(true)
  for _, api := range routers {
    for _, route := range api.Routes() {
      r.
        Methods(route.Method).
        Path(route.Pattern).
        Name(route.Name).
        Handler(ms.Then(route.HandlerFunc))
    }
  }
}

router.goの可読性が高まっただけでなく、使用するミドルウェアが何であるかがrouter.goからは隠蔽された形になり、使用するミドルウェアに変更があった場合にrouter.goが影響を受けることがなくなりました。

まとめ

開発を進めていく上で処理を共通化し、リファクタリングを適宜進めるのは開発スピードを維持するためにもとても重要なことです。既存の設計に新しくミドルウェア層を導入することで、処理の共通化とアプリケーションロジック/認証の切り離しをうまく進めることができました。

今後、さらに新しい共通処理を作る必要があった場合にも、開発コストをあまりかけずにミドルウェアを導入できるようになったので、とてもいい取り組みができたかなと感じました。