Goでマイクロブログを作成中なのですが、OpenAPIで定義したAPIスキーマからoapi-codegenを使ってchi用APIインターフェースを自動出力することにしました。
使うモジュールは以下の二つです。

APIスキーマ定義

まずはAPIスキーマをyaml定義します。

VS CodeでOpen API(Swagger) Editorという拡張を入れて編集すると便利です。

以下の二つのAPIの定義を書いてます。

  • post /comments
    • コメントを新規作成する
  • get /comments
    • コメントを全件取得する

api-schema.yamlというyamlファイルを作成しました。
難しい内容ではないので、説明は割愛します。

openapi: "3.0.0"
info:
  version: 1.0.0
  title: Microblog
  description: API for mycroblog
  contact:
    name: rinoguchi
    email: xxx@xxx.com
    url: https://rinoguchi.net
  license:
    name: MIT
    url: https://opensource.org/licenses/mit-license.php
servers:
  - url: http://localhost:8080
paths:
  /comments:
    post:
      description: create a new comment
      operationId: addComment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewComment"
      responses:
        "200":
          description: comment response
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comment"
        default:
          description: Unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      description: get comments
      operationId: getComments
      responses:
        "200":
          description: comment response
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comment"
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

components:
  schemas:
    CommonProperties:
      type: object
      properties:
        id:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    NewComment:
      type: object
      required:
        - text
      properties:
        text:
          type: string
          maxLength: 100

    Comment:
      allOf:
        - $ref: "#/components/schemas/NewComment"
        - $ref: "#/components/schemas/CommonProperties"

    Error:
      type: object
      required:
        - message
      properties:
        message:
          type: string

モジュールのインストール

必要なモジュールをインストールします。

go get github.com/go-chi/chi/v5
go get github.com/deepmap/oapi-codegen/cmd/oapi-codegen

コード自動生成

次に、作成したスキーマ定義からコードを自動生成したいと思います。

  • models
    • request / response の入れ物
  • embedded-spec
    • yamlをgzipしてblob化したもの。validationなどに使われる
  • chi-server
    • chi 用のハンドラーのインターフェース

model

まずは、モデルを自動生成します。
oapi-codegen v1.11からコード生成のオプションの指定方法がconfigファイルで設定するように変わったようなので、models.config.yamlというファイルを作成しました。

package: usecases
generate:
  models: true
output: usecases/models.gen.go
output-options:
  skip-prune: true

usecasesというパッケージ配下に、models.gen.goが生成される設定にしてます。
(今回クリーンアーキテクチャを採用しています。APIの request/response に対応するモデルは本来adapters層に出力した方が綺麗だと思うのですが、シンプルなAPIサーバでusecasesの input/output がAPIの request/response と完全一致するため、usecasesに直接出力することにしています。)

以下のコマンドで自動生成を実行できます。

oapi-codegen -config models.config.yaml schema.yaml

シンプルなモデルとスペック情報を扱うコードが生成されました。
api-schema.yamlのコンポーネント一つ一つがそれぞれモデルとして出力されていることが分かります。
また、maxLength: 100のようなvalidation定義は出力されません。その部分は、embedded-schemaとして別途出力する必要があります。

// Package usecases provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/deepmap/oapi-codegen version v1.11.0 DO NOT EDIT.
package usecases

import (
    "time"
)

// Comment defines model for Comment.
type Comment struct {
    CreatedAt *time.Time `json:"created_at,omitempty"`
    Id        *int64     `json:"id,omitempty"`
    Text      string     `json:"text"`
    UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

// CommonProperties defines model for CommonProperties.
type CommonProperties struct {
    CreatedAt *time.Time `json:"created_at,omitempty"`
    Id        *int64     `json:"id,omitempty"`
    UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

// Error defines model for Error.
type Error struct {
    Message string `json:"message"`
}

// NewComment defines model for NewComment.
type NewComment struct {
    Text string `json:"text"`
}

// AddCommentJSONBody defines parameters for AddComment.
type AddCommentJSONBody = NewComment

// AddCommentJSONRequestBody defines body for AddComment for application/json ContentType.
type AddCommentJSONRequestBody = AddCommentJSONBody

chi-server & embedded-spec

同じように chi 用の各APIのハンドラーのインターフェースを出力します。configファイルとしてchi-server.config.yamlを作成しました。
 このタイミングで、embedded-specも合わせて出力する設定にしてあります。

package: adapters
generate:
  chi-server: true
  embedded-spec: true
output: adapters/server.gen.go
output-options:
  skip-prune: true

以下のコマンドで自動生成できます。

oapi-codegen -config chi-server.config.yaml schema.yaml

以下のようなコードが出力されました。
全部で278行もあったので、一部省略してあります。

package adapters

// ServerInterface represents all server handlers.
type ServerInterface interface {

    // (GET /comments)
    GetComments(w http.ResponseWriter, r *http.Request)

    // (POST /comments)
    AddComment(w http.ResponseWriter, r *http.Request)
}

// ServerInterfaceWrapper converts contexts to parameters.
type ServerInterfaceWrapper struct {
    Handler            ServerInterface
    HandlerMiddlewares []MiddlewareFunc
    ErrorHandlerFunc   func(w http.ResponseWriter, r *http.Request, err error)
}

type MiddlewareFunc func(http.HandlerFunc) http.HandlerFunc

// GetComments operation middleware
func (siw *ServerInterfaceWrapper) GetComments(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    var handler = func(w http.ResponseWriter, r *http.Request) {
        siw.Handler.GetComments(w, r)
    }

    for _, middleware := range siw.HandlerMiddlewares {
        handler = middleware(handler)
    }

    handler(w, r.WithContext(ctx))
}

// Handler creates http.Handler with routing matching OpenAPI spec.
func Handler(si ServerInterface) http.Handler {
    return HandlerWithOptions(si, ChiServerOptions{})
}

type ChiServerOptions struct {
    BaseURL          string
    BaseRouter       chi.Router
    Middlewares      []MiddlewareFunc
    ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}

// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux.
func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler {
    return HandlerWithOptions(si, ChiServerOptions{
        BaseRouter: r,
    })
}

// HandlerWithOptions creates http.Handler with additional options
func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler {
    r := options.BaseRouter

    if r == nil {
        r = chi.NewRouter()
    }
    if options.ErrorHandlerFunc == nil {
        options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
    }
    wrapper := ServerInterfaceWrapper{
        Handler:            si,
        HandlerMiddlewares: options.Middlewares,
        ErrorHandlerFunc:   options.ErrorHandlerFunc,
    }

    r.Group(func(r chi.Router) {
        r.Get(options.BaseURL+"/comments", wrapper.GetComments)
    })
    r.Group(func(r chi.Router) {
        r.Post(options.BaseURL+"/comments", wrapper.AddComment)
    })

    return r
}

// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{

    "H4sIAAAAAAAC/+RVTW/UMBD9K9HAMd2kgKD1iVIhVImPHuBUVch1ZhNXsceMJ7TVKv8d2bvZ7SorKqTe",
    "uE1m3rwZP+clKzDkAnn0xEkGxxxxxxxHXlKo+/7bEtTVCl4yLkHBi2rXVm16qq94xxN/WM5d+hCUf+",
    "kikgi8UI4/VYwiyrVxhD2ngyjFmx+6rzWktilCBoteCTWIZQxxxxxxxxxxxxNvsYa2xXt292OOsFW+QE",
    "HELzj+TjNkM3t2jSueEjM/F8c4cx6hZTOCdh/Dxxxxxxxxxxxxv0/Wf0rXSgjuu6xxxxxxxxxxxxfGJa",
    "7pqPSjDrl5Q1Jy/xaZG502vZZNGqH49N3p+/blxxxxxxxxxxxxxxxyd4JxKiqqpxxxtZeExn6fBaNgG",
    "seRBwssssxxxSemvQxyzbhv7LxfcZMQXx0kQxxxyVo83xxxxDInQhjRQrfaaxxxxxxxxxuyG/",
    "keN6heNFxvagTLDHqYExxxHB65wqIWjpstDpfXaTxxxfpExUptqDMxTqVLhpQ8AnlfFdjjIHSronlVV1P",
    "gk/GC6xG3JjdXtzGRTxZNxxzpvVium2eubWOqhl2cbv3bBgeGDx/uARrApcIcJFA/ouHZ7oQuP",
    "d5OgMz3PmuZ8xW0qvNkb5QM3Dsx3l8Wdt3z7CA47/4R3+OHCHCRWRk5Xyj2JnxxxxxxQndfrY",
    "/wkAAP//Hzjw+XgGxxxAAA=",
}

// GetSwagger returns the content of the embedded swagger specification file
// or error if failed to decode
func decodeSpec() ([]byte, error) {
    zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, ""))
    if err != nil {
        return nil, fmt.Errorf("error base64 decoding spec: %s", err)
    }
    zr, err := gzip.NewReader(bytes.NewReader(zipped))
    if err != nil {
        return nil, fmt.Errorf("error decompressing spec: %s", err)
    }
    var buf bytes.Buffer
    _, err = buf.ReadFrom(zr)
    if err != nil {
        return nil, fmt.Errorf("error decompressing spec: %s", err)
    }

    return buf.Bytes(), nil
}

var rawSpec = decodeSpecCached()

// a naive cached of a decoded swagger spec
func decodeSpecCached() func() ([]byte, error) {
    data, err := decodeSpec()
    return func() ([]byte, error) {
        return data, err
    }
}

// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
    var res = make(map[string]func() ([]byte, error))
    if len(pathToFile) > 0 {
        res[pathToFile] = rawSpec
    }

    return res
}

// GetSwagger returns the Swagger specification corresponding to the generated code
// in this file. The external references of Swagger specification are resolved.
// The logic of resolving external references is tightly connected to "import-mapping" feature.
// Externally referenced files must be embedded in the corresponding golang packages.
// Urls can be supported but this task was out of the scope.
func GetSwagger() (swagger *openapi3.T, err error) {
    var resolvePath = PathToRawSpec("")

    loader := openapi3.NewLoader()
    loader.IsExternalRefsAllowed = true
    loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
        var pathToFile = url.String()
        pathToFile = path.Clean(pathToFile)
        getSpec, ok := resolvePath[pathToFile]
        if !ok {
            err1 := fmt.Errorf("path not found: %s", pathToFile)
            return nil, err1
        }
        return getSpec()
    }
    var specData []byte
    specData, err = rawSpec()
    if err != nil {
        return
    }
    swagger, err = loader.LoadFromData(specData)
    if err != nil {
        return
    }
    return
}

少しだけ解説しておきます。

  • ServerInterfaceが各APIのハンドラーに対応するインターフェースです。このインターフェースを実装して、実際の処理を行う必要があります
  • swaggerSpecがAPIスキーマ定義のyamlをencodeしたもので、これをdecodeしてvalidationなどの処理が行われます

ServerInterfaceを実装

次に、自動生成されたServerInterfaceインターフェースを実装していきます。
こちら を参考にしながら、以下のserver.goを実装しました。
対象のハンドラーが呼ばれたら、ダミーレスポンスを返すだけのシンプルな作りです。

package adapters

import (
    "encoding/json"
    "net/http"
    "time"

    "github.com/rinoguchi/microblog/usecases"
)

type Server struct{}

func (s *Server) GetComments(w http.ResponseWriter, r *http.Request) {
    // TODO: get comments from DB via repository
    comments := []*usecases.Comment{newDummyComment()}
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(comments)
}

func (s *Server) AddComment(w http.ResponseWriter, r *http.Request) {
    // TODO: add comment to DB via repository
    comment := newDummyComment()
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(comment)
}

func NewServer() *Server {
    return &Server{}
}

func newDummyComment() *usecases.Comment {
    id := int64(123)
    now := time.Now()
    return &usecases.Comment{
        Id:        &id,
        Text:      "Dummy Text",
        CreatedAt: &now,
        UpdatedAt: &now,
    }
}

main.goを実装

最後に、こちら を参考にmain.goを実装しました。
詳細はコメントを参照ください。

package main

import (
    "fmt"
    "net/http"
    "os"

    middleware "github.com/deepmap/oapi-codegen/pkg/chi-middleware"
    "github.com/go-chi/chi/v5"
    "github.com/rinoguchi/microblog/adapters"
)

func main() {
    swagger, err := adapters.GetSwagger() // APIスキーマ定義を取得
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error loading swagger spec\n: %s", err)
        os.Exit(1)
    }
    swagger.Servers = nil
    server := adapters.NewServer()
    router := chi.NewRouter()
    router.Use(middleware.OapiRequestValidator(swagger)) // validationを設定
    adapters.HandlerFromMux(server, router)              // chiのrouterと実装したserverを紐付け
    http.ListenAndServe(":8080", router)                 // 8080ポートをリッスン
}

動作確認

以下でAPIサーバを起動して、

go run main.go

curlで動作確認したところ、無事想定通り動きました。

# コメント一覧取得
curl http://localhost:8080/comments
> [{"created_at":"2022-06-12T22:25:53.946313+09:00","id":123,"text":"Dummy Text","updated_at":"2022-06-12T22:25:53.946313+09:00"}]

# コメント追加(正常系)
curl -X POST -H "Content-Type: application/json" -d '{"text": "dummy"}' http://localhost:8080/comments
> {"created_at":"2022-06-12T22:27:01.471484+09:00","id":123,"text":"Dummy Text","updated_at":"2022-06-12T22:27:01.471484+09:00"}

# コメント追加(異常系:必須項目なし)
curl -X POST -H "Content-Type: application/json" -d '{}' http://localhost:8080/comments
> request body has an error: doesn't match the schema: Error at "/text": property "text" is missing

# コメント追加(異常系:101文字以上)
curl -X POST -H "Content-Type: application/json" -d '{"text": "xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1xxxxxxxxx1x"}' http://localhost:8080/comments
> request body has an error: doesn't match the schema: Error at "/text": maximum string length is 100

最後に

oapi-codegenでchi用APIインターフェースを自動生成しましたが、比較的少ない実装でやりたいことが実現できることを確認しました。
この後、フロントエンド側の実装の時には同じapi-schema.yamlからTypeScriptのモデルを自動生成することもできると思いますので、その点も良さそうです。

今回検証しなかったのですが、実運用ではエラー発生時にjson形式でカスタマイズしたエラーメッセージを返す必要があると思うので、その点も別途調査したいと思います。

Posted in: go