makeshopの新決済画面 SmartCheckoutでのOpenAPI(3.0)活用事例

こんにちは、プロダクト開発部コアグループの井上です。
コアグループでは、次世代ECの開発を行っています。

現在makeshopでは、決済画面をリニューアルするプロジェクトが進行しており、4月に第一弾がリリースがされました。 今回はその中でのOpenAPI(3.0)を活用した開発を紹介したいと思います。

概要

今回のプロジェクトでは、私の所属するチームはAPI部分を担当しています。 そのBFFではOpenAPIとコード生成によるスキーマ駆動開発を行っています。

コード生成

コード生成にはoapi-codegenを利用しています。 下記のコマンドをdockerコンテナ上で実行してSchemaの型とechoのinterfaceなどを生成しています。

oapi-codegen \
  -package=frontapi \
  -generate=types,server \
   ./swagger_definition.yaml > ./generated/server.gen.go

生成されたinterfaceの実装をRegisterHandlersで登録することでリクエストが通るようになります。

func NewServer() *echo.Echo {
    e := echo.New()
    e.Use(middleware.RootMiddleware()...)

    apiGroup := e.Group("/api")
    apiGroup.Use(middleware.ApiMiddleware()...)
    apiHandler := handler.NewOpenApiHandler()
    frontapi.RegisterHandlers(apiGroup, apiHandler)

    return e
}

packageの指定

package swaggerDefinition

デフォルトでは生成されるコードのpackageは定義ファイルの名前になってしまいますが、 -package オプションを使用することで任意のpackage名を指定することができます。

生成対象の指定

-generateオプションでは、生成する対象を指定することができます。 今回はサーバーサイドのみ必要だったためserver, typesを指定しました。
serverはデフォルトでechoが利用されます。他にはchi-serverや、アーカイブから復活したgorillaが利用できます。
また、Go1.22以降であれば、新しく追加されたnet/http.ServeMuxが利用できるようです。

フィールドのオプション

OpenAPIでは拡張機能としてx-から始まる独自のプロパティを記述することができ、追加機能を記述するために使用できます。

OpenAPI Extensions

oapi-codegenにも拡張機能のオプションがいくつか用意されています。

x-go-type

x-go-typeでGoの型を指定することができます。OpenAPIの仕様上integerのフォーマットにはint32, int64しか用意されていませんが、こちらのオプションを使うことで、生成されるフィールドの型にuintなどを指定することができます。

Pet:
  type: object
  properties:
    int64Field:
      type: integer
      format: int64
    uint64Field:
      type: integer
      x-go-type: uint64

また、README.mdにも記載がありますが、x-go-type-importと合わせて利用することで、Go標準以外の型も指定することができます。

Pet:
  type: object
  properties:
    age:
      x-go-type: myuuid.UUID
      x-go-type-import:
        name: myuuid
        path: github.com/google/uuid

x-oapi-codegen-extra-tags

x-oapi-codegen-extra-tagsを利用することで、下記のように任意の構造体タグを追加することができます。 今回はバリデーションに使用しているgo-playground/validatorのタグを追加するために使用しました。

SenderAddressRequest:
  type: object
  properties:
    name:
      type: string
      description: 注文者名
      x-oapi-codegen-extra-tags:
        validate: "required,max=20"
  required:
    - name
// SenderAddressRequest defines model for SenderAddressRequest.
type SenderAddressRequest struct {
    // Name 注文者名
    Name string `json:"name" validate:"required,max=20"`
}

ドキュメント

OpenAPIのみならず、スキーマ駆動開発の良いところですが、スキーマ定義からドキュメントを自動生成できるエコシステムが揃っています。 今回はSwaggerUIをGithub Pagesにデプロイして、リポジトリを見れるメンバーであれば閲覧可能なドキュメント環境を構築しました。

また、serversには複数のエンドポイントのベースURLを定義することができる為、Github Pages上のSwaggerUIから開発環境でのレスポンスを確認することもできるようにしました。

servers:
  - url: http://localhost:8080/api
    description: Local
  - url: https://<development-host>/api
    description: dev

今回のプロジェクトでは初めて今まで関わりのなかったチームとも開発をすることになったこともあり、ドキュメントの共有が今まで以上に重要でした。 makeshopではOpenAPIの私が知る限り使用実績はないため、多少のフォローが必要になる場面はありましたが、自動生成できる恩恵は大きかったです。

また、社内の別チームが実施する脆弱性診断実施のための資料・診断環境としても利用され、当初想定していなかった生産性向上の恩恵を受けることができました。

コード生成のカスタマイズ

今回、stringのフィールドはmakeshop内部で利用できる文字コードかのチェックをする必要がありました。 また、利用できない場合はどのフィールドにリクエストされたかをわかる形でエラーレスポンスを返却する必要がありました。

今回利用している、go-playground/validatorでは以下のように独自のバリデーションを追加することができ、発生したエラーをフィールドごとに取得できます。
oapi-codegenの生成したコードの構造体タグをカスタマイズできれば、今回やりたいことは実現できそうです。

package main

import (
    "context"
    "fmt"
    "unicode"

    "github.com/go-playground/validator"
)

var v *validator.Validate

func init() {
    validate := validator.New()
    if err := validate.RegisterValidation("katakana", validationCustomFuncKatakana); err != nil {
        panic(err)
    }
    v = validate
}

func validationCustomFuncKatakana(fl validator.FieldLevel) bool {
    if fl.Field().String() == "" {
        return true
    }

    for _, r := range fl.Field().String() {
        if !unicode.In(r, unicode.Katakana) {
            return false
        }
    }

    return true
}

func main() {
    type User struct {
        Name     string `validate:"required,max=20"`
        NameKana string `validate:"required,max=20,katakana"`
        Age      uint8  `validate:"required,max=130"`
    }

    user := User{
        Name:     "aaaaaaaaaaaaaaaaaaaaa",
        NameKana: "漢字",
        Age:      200,
    }

    if err := v.StructCtx(context.Background(),user); err != nil {
        fmt.Println(err) 
        // Key: 'User.Name' Error:Field validation for 'Name' failed on the 'max' tag
        // Key: 'User.NameKana' Error:Field validation for 'NameKana' failed on the 'katakana' tag
        // Key: 'User.Age' Error:Field validation for 'Age' failed on the 'max' tag
    }
}

コード生成のカスタマイズにあたって、

  1. oapi-codegenのカスタムテンプレートを利用する
  2. 生成されたgoのコードをいじる

以上の2つの方法を検討しました。

1. カスタムテンプレートを利用する

-templatesオプションを利用することでカスタムテンプレートを渡すことができます。 こちらを利用することで実現できなくはなさそうでしたが、今回は採用しませんでした。

$ oapi-codegen \
    -templates my-templates/ \
    -generate types,client \
    petstore-expanded.yaml

カスタムテンプレートについてはこちらの記事が参考になりました。 qiita.com

2. 生成されたGoのコードをいじる

今回はこちらを採用しました。
やりたいこととしては、特定のフィールドにタグを足すだけなので、
oapi-codegenの挙動自体をを変えるよりも、出来上がっているGoのコードをいじる方がoapi-codegenのアップデートにも左右されずシンプルになると考えました。

生成したgoファイルを静的解析してリクエストの構造体からstring, *string, []stringなどのフィールドを(structなら再帰的に)拾ってvalidateタグにcharsetを足して愚直に置換していきます。

func main() {
    serverGo, err := os.ReadFile("path/to/server.go")
    if err != nil {
        panic(err)
    }

    serverGo, _ = format.Source(serverGo)
    fset := token.NewFileSet()
    parsedFile, err := parser.ParseFile(fset, "", serverGo, parser.ParseComments)
    if err != nil {
        panic(err)
    }

    var genDecls []*goast.GenDecl
    typeMap := make(map[string]*goast.StructType)
    goast.Inspect(parsedFile, func(node goast.Node) bool {
        switch decl := node.(type) {
        case *goast.GenDecl:
            genDecls = append(genDecls, decl)
        }
        return true
    })

    goast.Inspect(parsedFile, func(node goast.Node) bool {
        switch t := node.(type) {
        case *goast.TypeSpec:
            if s, ok := t.Type.(*goast.StructType); ok {
                if strings.HasSuffix(t.Name.Name, "Request") {
                    typeMap[t.Name.Name] = s
                    for name, st := range getStructFields(s, genDecls) {
                        typeMap[name] = st
                    }
                }
            }
        }
        return true
    })

    for structName, s := range typeMap {
            //...
    }
}

func getStructFields(s *goast.StructType, genDecls []*goast.GenDecl) map[string]*goast.StructType {
    result := make(map[string]*goast.StructType)
    for _, f := range s.Fields.List {
        switch ft := f.Type.(type) {
        case *goast.Ident:
            if ft.Obj == nil {
                continue
            }
            if ts, ok := ft.Obj.Decl.(*goast.TypeSpec); ok {
                if structType, ok := ts.Type.(*goast.StructType); ok {
                    result[ts.Name.Name] = structType
                    for name, st := range getStructFields(structType, genDecls) {
                        result[name] = st
                    }
                }
            }
        case *goast.StarExpr:
            if ident, ok := ft.X.(*goast.Ident); ok && ident.Obj != nil {
                if ts, ok := ident.Obj.Decl.(*goast.TypeSpec); ok {
                    if structType, ok := ts.Type.(*goast.StructType); ok {
                        result[ts.Name.Name] = structType
                        for name, st := range getStructFields(structType, genDecls) {
                            result[name] = st
                        }
                    }
                }
            }
        case *goast.ArrayType:
            if ident, ok := ft.Elt.(*goast.Ident); ok && ident.Obj != nil {
                for _, decl := range genDecls {
                    for _, spec := range decl.Specs {
                        if typeSpec, ok := spec.(*goast.TypeSpec); ok {
                            if typeSpec.Name.Name == ident.Name {
                                if structType, ok := typeSpec.Type.(*goast.StructType); ok {
                                    result[ident.Name] = structType
                                    for name, st := range getStructFields(structType, genDecls) {
                                        result[name] = st
                                    }
                                }
                                break
                            }
                        }
                    }
                }
            }
        }
    }

    return result
}

ハマったところ

若干ハマったところを紹介します。
下記のようにvalidateタグがすでにある場合はその最後にタグを追加する必要があった為、 reflect.StructTag.Lookup()で値を取得しようとしましたが、思った通りに取得できませんでした。

type PetRequest struct {
    Field1 string `json:"field1" validate:"required,max=20"`
}
// ↓
type PetRequest struct {
    Field1 string `json:"field1" validate:"required,max=20,charset"`
}
func getTargetFields(s *goast.StructType) []*stringField {
    var fields []*stringField

    for _, f := range s.Fields.List {
        switch ft := f.Type.(type) {
        case *goast.Ident:
            if ft.Name == "string" {
                fields = append(fields, &stringField{
                    Tag: reflect.StructTag(f.Tag.Value),
                    Pos: f.Pos(),
                    End: f.End(),
            })
        }
    }

    // fields[0].Tag.Lookup("validate")
    // "", false

    return fields
}

解決策としては、(*ast.Field).Tag.Valueの値は「`」で囲まれた文字列なので、
reflect.StructTagにキャストする際に除去しておく必要があるという話でした。

`json:"field1" validate:"required,max=20"`
fields = append(fields, &stringField{
    Tag: reflect.StructTag(strings.Trim(f.Tag.Value, "`")),
    Pos: f.Pos(),
    End: f.End(),
})

最後に、oapi-codegenでのコード生成ととvalidateタグ追加のgo実行を1つのスクリプトにまとめて完成です。
無事に文字コードチェックのバリデーションが追加されるようになりました。

type SenderAddressRequest struct {
    // Name 注文者名
    Name string `json:"name" validate:"required,max=20,charset"`
}

画面には下記のようなレスポンスを返すことができるようになりました。

{
    "code": "missing_or_invalid_parameter_value",
    "fields": [
        {
            "code": "common.max_len_error",
            "field": "name",
            "message": "error: field validation for 'name' failed on the 'max'",
            "value": "20"
        },
        {
            "code": "common.invalid_charset",
            "field": "nameKana",
            "message": "error: field validation for 'nameKana' failed on the 'charset'"
        }
    ]
}

まとめ

OpenAPIを採用したことで、新決済画面のAPI開発においても効率的な開発とドキュメント共有を実現できましたが、以下の課題も浮き彫りになりました。

pathを考えるのがしんどい

OpenAPIの問題ではないですが、URL設計に非常に苦労しました。長時間悩んでしまったり、他メンバーへのレビュー時も、これは明らかに違うけど代替案が思い浮かばない... という場面が多々ありました。

namespace, packageに相当するものがない

OpenAPIにはnamespace, packageに相当するものがないため、規模が大きくなることが想定される場合はOperationIdやSchema名はこれを意識した設計が必要だったなと反省しています。
開発当初のswaggerは1,000行程度でしたが5月時点では4,000行を超え、今後もさらに増えていく予定です。
AzureのRESTApiのスキーマ定義 ではApiVersion_, Environment_のような機能ごと(?)のプレフィックスがつけられており、今後これを参考に整理していきたいと考えています。

他にもまだまだ課題はありますが、より快適なお買い物体験を実現できるよう、OpenAPIのメリットを最大限に活かし、より効率的なAPI開発を目指していきます。