goで作られた既存のweb APIに後付けでopenAPI docを付けたかった

github.com

最近、reflect-openapiというパッケージを作っています。まだまだ荒削りですがある程度動く感じにはなったので紹介します。

goで作られた既存のweb APIに対してopenAPI docが見れるようにしたい、というの作りはじめた動機です。

なんで新しく作ったの?

なんで新しく作ったのか?というと、既存の内部定義を可能な限り流用したかったからです。

これはgoaであってもgo-swaggerであってもoai-codegenであってもできないことです。structの定義などを生成してしまうので。これらは、内側ではなく外側の定義から実装していくという感じになります。一方で今回は既に存在しているweb APIに対してopenAPI docを作りたいという形なので。

また、gqlgenのauto bindのような仕組みを使っては?という話については、個人的にあのインターフェイス(yamlであれこれ指定すること)が好きではないので避けたいです。

静的解析? reflect?

静的解析で行くかreflectを使うかで考えてみたのですが、今回はreflectで行くことにしました。

openAPIがweb APIを定義する関係上必ずendpointのmethodとpathが必要になるのですが、この定義を静的解析に持ってくるのが微妙な点と、あと、こちらは小さめの理由ですが、最近大きめなコードベースに対する静的解析でgo/pakcagesなどを利用したコードの実行にかかる時間にイライラしている面があったからでした1

静的解析をやる分には、依存のないprimitiveな定義だけを参照する形にすると綺麗に行くのですが、既存の実装がある中でやろうとすると中々うまくいかない感じになります。ぶっちゃければ綺麗じゃないので綺麗にしてから頑張るか、上手く取り扱おうとコードベースの海をさまよう感じになります。もちろん時間とリソースが許せば綺麗にしてから理想形として作れれば最高ですが、元々既存のコードにはあまり手を入れたくないという気持ちから始まっているので。

endpointのpathとmethodの話については、例えばインターフェイスで受け取る様な形にしたとしても、コメントを解析するというようなswaggoが使うような手段を使う必要が出てきてしまいます。個人的には、フリーハンドのlinterもcompilerも読まない部分に自由に記述するスタイルがお気に召さないので避けました2

# このGETと/users/{user_id}をどうにかこうにか取ってこなくてはいけない

GET /users/{user_id} <handler>

framework agnostic?

何かのフレームワークに依存しているかというと、一応非依存でいけるように調整しています。そんなわけで利用例のコードでは、以下のフレームワーク(及びrouterライブラリ)を利用した例を書いてみています。

go-chiはrouterライブラリ、echoとgo-chiはnet/httpを利用、fiberはfasthttpを利用しているフレームワークだったりしています。

基本的には、routingの定義を自作してもらって、そこで定義と同時にopenAPI docを生成するためのあれこれをやってもらおうという感じの使い方になります。

実際の使い方

実際にはstructの定義からschemaを関数の定義からoperation(paths)の定義を推測するという形で利用します。

// structの定義をschemaとして読み込ませる
s := visitor.VisitType(User)

// 関数の定義をoperationとして読み込ませる
op := visitor.VisitFunc(GetUser)
doc.AddOperation("/users/{userId}", "GET", op)

doc, visitorについては後述。作られたdocをJSONとして出力するとそれがopenAPI docになります。

go-chiのREADMEの例から

例として、go-chiのREADMEの冒頭にあるwelcomeのコードから始めてみます。

package main

import (
    "net/http"

    "github.com/go-chi/chi"
    "github.com/go-chi/chi/middleware"
)

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Get("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("welcome"))
    })
    http.ListenAndServe(":3000", r)
}

chi.NewRouter()で作られるのはchi.Routerなのですが、chi.Router.Get()は内部でchi.Router.Method("GET", ...)を呼んでいます。このようなroutingを定義するための記述を探すところがスタートです。

空のドキュメント

その前に空のdocumentを生成してみましょう。

reflect-openapiは、Configオブジェクトから使う様なインターフェイスになっています。そして、Config.BuildDoc()*openapi3.Swaggerオブジェクトを生成します。これをencoding/jsonなどでJSONにしてあげるとopenAPI docが手に入ります。

var c reflectopenapi.Config
doc, err := c.BuildDoc(context.Background(), func(m *reflectopenapi.Manager){
  // なにかやる
})

routing定義の変更

先程話したとおり、r.Get("/", ...) の部分を書き換えます。go-chiの場合、chi.Router.Method()を使い、net/http.HandlerFuncを受け取るのでそれも受け取れるようにします。

さて、そしてそのままでは、戻り値のresponseを考える事ができません。そんなわけでinteractorという引数を取ることにします3。このinteractorで受け取った関数の戻り値がそのままresponseで返される値ということになります。 (理想的な話をするならここでのinteractorはhandlerで使われるはずの関数です)

type Setup struct {
    Router  chi.Router
    Manager *reflectopenapi.Manager
}

func (s *Setup) AddEndpoint(method, path string, interactor interface{}, handle http.HandlerFunc) {
    s.Router.Method(method, path, handle)
    op := s.Manager.Visitor.VisitFunc(interactor)
    s.Manager.Doc.AddOperation(path, method, op)
}

あとは、コレを無理やりつなげる様な形にして完成です。とりあえずDOCGEN=1と共に実行した場合にはopenAPI docを出力してみることにしました。けっこう長くなってしまいましたが。まぁそんな感じです。

ルーティング部分だけをいじれば良いので、副作用無しで設定できるんじゃないかと思います。

package main

import (
    "context"
    "encoding/json"
    "net/http"
    "os"
    "strconv"

    "github.com/go-chi/chi"
    "github.com/go-chi/chi/middleware"
    reflectopenapi "github.com/podhmo/reflect-openapi"
)

type Setup struct {
    Router  chi.Router
    Manager *reflectopenapi.Manager
}

func (s *Setup) AddEndpoint(method, path string, interactor interface{}, handle http.HandleFunc) {
    s.Router.Method(method, path, func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("welcome"))
    })
    op := s.Manager.Visit.VisitFunc(interactor)
    s.Manager.doc.AddOperation(path, method, op)
}

func (s *Setup) SetupRoutes() {
    s.AddOperation(
        "GET", "/",
        func() string { return "" }, // この関数からoperationを導出
        func(w http.ResponseWriter, r *http.Request) {
            w.Write([]byte("welcome"))
        },
    )
}

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    c := reflectopenapi.Config{}

    doc, err := c.BuildDoc(context.Background(), func(m *reflectopenapi) {
        s := &Setup{Manager: m, Router: r}
        s.SetupRoutes()
    })
    if _, ok := strconv.ParseBool(os.Getenv("DOCGEN")); ok {
        enc := json.NewEncoder(os.Stdout)
        enc.SetIndent("", "  ")
        enc.Encode(doc)
        return
    }

    http.ListenAndServe(":3000", r)
}

実際に実行してみると以下のようなJSONを出力します。

$ DOCGEN=1 go run main.go > openapi.json

openapi.json

{
  "components": {},
  "info": {
    "description": "-",
    "title": "Sample API",
    "version": "0.0.0"
  },
  "openapi": "3.0.0",
  "paths": {
    "/": {
      "get": {
        "operationId": "main.(*Setup).SetupRoutes.func1",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": ""
          },
          "default": {
            "description": ""
          }
        }
      }
    }
  },
  "servers": [
    {
      "url": "http://localhost:8888",
      "description": "local development server"
    }
  ]
}

追加の機能はある?

細々とした機能を紹介するのは辛いので、箇条書きにしておきます。

  • 引数からrequest.Bodyのschemaが導出されます
  • requiredの判定を行う関数を追加することで、schemaにrequiredを付与することができます
  • フィールドのタグにopneapiとして、query,path,cookie,headerを指定するとopenAPI docに載ります
  • additionalPropertiesをデフォルトでfalseにする機能があります
  • defaultErrorを設定すると、全てのendpointのdefaultにそれが使われるようになります
  • sliceはarrayとして扱われます
  • mapはadditionalProtpertiesとして扱われます
  • 関数のコメントからdescriptionを取り出す機能があります(path部分のみ)

機能紹介の例としてはこのあたりが参考になるかもしれません。

validationなどはあるの?

現状validationなどを追加するイメージはあまりないです。というのも既存のweb APIならまともにvalidationが実装されているはずなので。

一方で、昨今、AWSのAPI gatewayGCPのAPI gatewayでもopenAPI越しに設定ができたりするのでその辺りのための不足部分を埋めていい感じに扱えるようにしたいという気持ちはあったりしますね4。その上でこれらのgatewayフォーマットチェックだけはやってくれるようになると、down streamへの不要な通信をカットしてくれる感じになるかもしれません。

gist


  1. もちろん静的解析で上手くやる方法はあるし、静的解析を辞めればこの種の問題が解決するわけではないけれど

  2. そういう面もあってdocoptだとかpythondoctestもあんまり好きではなかったりする

  3. 個々では深入りしませんが、理想の話をすれば、handlerはUI層、それで取り出されたアプリケーション層での実行を担うような層が存在していて、interactorに類するような関数が定義されていると綺麗です。interactorという名前自体はhanamiから取ってきました。interactorについては前回の記事でも少し触れています。

  4. 場合によってはopenAPI3.0.xではなく2.0をサポートする必要があるかもしれない?