Goでマイクロブログを作成中なのですが、OpenAPIで定義したAPIスキーマからoapi-codegenを使ってchi用APIインターフェースを自動出力することにしました。
使うモジュールは以下の二つです。
- oapi-codegen v1.11.0
- chi v5.0.7
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形式でカスタマイズしたエラーメッセージを返す必要があると思うので、その点も別途調査したいと思います。