OpenAPIスキーマ駆動開発におけるoapi-codegenを用いたリクエストバリデーション

こんにちは、バックエンドエンジニアの鈴木(善)です。
弊社はOpenAPIでのスキーマ駆動開発をしており、サーバーサイドのコードジェネレーターとしてoapi-codegenを利用しています。

github.com

chiで受けたHTTPリクエストをスキーマに基づきバリデーションする機能が、v1.5.0で実装されました。本記事ではこれを試してみた結果をまとめました。

github.com

なお、今回試したときの各種バージョンは以下のとおりです。

  • golang: go version go1.16.2 darwin/amd64
  • oapi-codegen: v1.6.1

【注意】oapi-codegenはchiの他にもechoとの組み合わせもサポートしていますが、そちらについては本記事の対象外となっております🙏

TL;DR

  • OpenAPIの主要なプロパティは概ねサポートされている
  • リクエストボディだけでなく、URLパスパラメータやクエリパラメータもバリデーション可能
  • 独自のバリデーションを定義することもできる
  • バリデーション不正時のレスポンスフォーマットが固定なのはやや不便かも

サンプルコード

本記事の完全なサンプルコードは以下を参照ください。

github.com

バリデーションの仕組み

oapi-codegenでは、OpenAPIに絡むところはkin-openapiというライブラリが使われています。バリデーションもこちらを使って実現されています。

github.com

oapi-codegen公式のサンプルコードを見るを実装する方法がわかります。(ドキュメントとしては見つけられず…)

それをもとに実装したサンプルコードは以下です。

func main() {
    // (1)コード生成のもとになったスキーマを取得
    swagger, err := oapi.GetSwagger()
    // ...snip...
    // (2)ここがセットされていると、ホストの検証も行われるようだが、必要でなければnilにしておく
    swagger.Servers = nil
    // ...snip...
    r := chi.NewRouter()
    // (3)先程取得したスキーマと一緒にミドルウェアを設定することで、スキーマベースのバリデーションが有効になる
    r.Use(middleware.OapiRequestValidator(swagger))
    // ...snip...
    if err := http.ListenAndServe(addr, r); err != nil {
        // ...snip...
    }
}

上記コードを補足していきます。

oapi-codgenには、コード生成する際にスキーマの情報も埋め込むspecというオプションがあります。

spec: embed the OpenAPI spec into the generated code as a gzipped blob. This

埋め込んだスキーマの情報は一緒に生成されるGetSwaggerという関数で取得することができます。(コメント(1)の部分)
取得したスキーマ情報を引数として、今回のPRで追加されたmiddleware.OapiRequestValidatorというミドルウェアをchiにセットすることで、リクエストをバリデーションできるようになります。(順番が前後しますが、コメント(3)の部分)

なお、サーバーのホスト名に関するバリデーションが不要な場合は、swagger.Serversを空(nil)にしておきます。(コメント(2)の部分)

バリデーションでエラーになったときのレスポンスはどうなる?

http.Error関数を使ってエラーメッセージをプレーンテキストで返しています。
残念ながら、レスポンスのフォーマットを指定できるような機構はなさそうです。

           // validate request
            if statusCode, err := validateRequest(r, router, options); err != nil {
                http.Error(w, err.Error(), statusCode)
                return
            }

https://github.com/deepmap/oapi-codegen/blob/d860c6345eac3dece9bb3ebe8919534b7bcd6233/pkg/chi-middleware/oapi_validate.go#L41

バリデーションの挙動のカスタマイズ

バリデーションの挙動を変更する仕組みとして、Optionsという構造体が用意されています。
ただこれは今のところ、openapi3filter.Optionsという構造体を埋め込んでいるだけのようです。

// Options to customize request validation, openapi3filter specified options will be passed through.
type Options struct {
    Options openapi3filter.Options
}

https://github.com/deepmap/oapi-codegen/blob/d860c6345eac3dece9bb3ebe8919534b7bcd6233/pkg/chi-middleware/oapi_validate.go#L19

openapi3filter.Optionsでは、以下の挙動を変えることができます。

フィールド 概要 デフォルト
ExcludeRequestBody リクエストボディのバリデーションをスキップする false
MultiError バリデーションの結果、複数のエラーがあった場合に、その全てを返すようにする false
AuthenticationFunc OpenAPIv3のSecurity Requirement Objectの検証を行う関数を指定する nil

他にも以下のフィールドがありますが、これらは指定しても使われません。
理由は、oapi-codgenはレスポンスのバリデーションを行っていないためです。

  • ExcludeResponseBody:レスポンスボディのバリデーションをスキップする
  • IncludeResponseStatus:レスポンスのステータスコードがOpenAPIスキーマ上、未定義の場合にエラーにする

オプションを適用にするにはOapiRequestValidatorWithOptions関数を使います。第2引数でオプションを渡すことで有効になります。以下はその例です。

func main() {
    swagger, err := oapi.GetSwagger()
    // ...snip...
    r := chi.NewRouter()
    r.Use(middleware.OapiRequestValidatorWithOptions(swagger, &middleware.Options{
        Options: openapi3filter.Options{
            MultiError: true,
        },
    }))
    // ...snip...
}

バリデーションの例

では、実際にどんな感じでスキーマを書くと、どのようにバリデーションされるのかを見ていきます。
今回は以下の3点について調べています。

  • 型のバリデーション
  • Schema Objectの各プロパティによるバリデーション
  • formatプロパティによるバリデーション

型のバリデーション

リクエストの値が、スキーマのtypeで定義した型と異なると弾かれます。

■スキーマ(抜粋)

#...
properties:
  title:
    type: string

■実行結果

❯ curl -d "{\"title\":1}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/title": Field must be set to string or not be present

string型のtitleに対して数値の1を送った結果、stringをセットすべき旨のエラーとなりました。

■バリデーションしているコード

func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value float64) error {
    var me MultiError
    schemaType := schema.Type
    if schemaType == "integer" {
        // ...snip...
    } else if schemaType != "" && schemaType != "number" {
        return schema.expectedType(settings, "number, integer")
    }
    // ...snip...
}

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L966

まず、値の型に応じてvisitJSONXxxxが呼ばれます(この例では値が数値なのでvisitJSONNumberが呼ばれる)。
ただ、schema.Typeから取得した型はstringなので、型が一致せずエラーとなっています。

すべての型では試せていませんが、コードを見る限り他の型も同じ仕組みでチェックされているようです。

Schema Objectの各プロパティによるバリデーション

OpenAPI v3.0.3 の Schema Object > Properties で定義されているプロパティのうち、以下のプロパティについて試しています。

  • multipleOf
  • maximum
  • exclusiveMaximum
  • minimum
  • exclusiveMinimum
  • maxLength
  • minLength
  • pattern
  • maxItems
  • minItems
  • uniqueItems
  • required
  • enum

他にも以下のプロパティがありますが、こちらは利用予定がとくにないため今回は外しています。

  • title
  • maxProperties
  • minProperties

multipleOf

■スキーマ(抜粋)

#...
      properties:
        #...
        test_multiple_of:
          type: integer
          multipleOf: 256

■実行結果

❯ curl -d "{\"test_multiple_of\":512}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_multiple_of\":513}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_multiple_of": Doesn't match schema "multipleOf"

スキーマでは256を指定しているので、倍数ではない513は期待通りエラーになります。

■バリデーションしているコード

    if v := schema.MultipleOf; v != nil {
        // "A numeric instance is valid only if division by this keyword's 
        //   value results in an integer." 
      if bigFloat := big.NewFloat(value / *v); !bigFloat.IsInt() {
          // ...snip...
        }
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1061

倍数は浮動小数点数で計算されているようです。
そのため、以下のように少数を指定しても、問題なくバリデーションされました。

■スキーマ(抜粋)

#...
properties:
  #...
  test_multiple_of_decimals:
    type: number
    multipleOf: 0.2

■実行結果

❯ curl -d "{\"title\":\"a\", \"test_multiple_of_decimals\":0.4}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"title\":\"a\", \"test_multiple_of_decimals\":0.41}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_multiple_of_decimals": Doesn't match schema "multipleOf"

0.2の倍数の0.4は成功し、0.41エラーとなります。

maximum

■スキーマ(抜粋)

#...
      properties:
        #...
        test_maximum:
          type: integer
          maximum: 100

■実行結果

❯ curl -d "{\"test_maximum\":100}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_maximum\":101}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_maximum": number must be most 100

スキーマに指定した100までは許容され、101は期待通りエラーとなります。

■バリデーションしているコード

    // "maximum" 
    if v := schema.Max; v != nil && !(*v >= value) {
        //...snip... 
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1041

exclusiveMaximum

exclusiveMaximumを指定すると、maximumの値は含まれなくなります。

■スキーマ(抜粋)

#...
      properties:
        #...
        test_exclusive_maximum:
          type: integer
          maximum: 100
          exclusiveMaximum: true

■実行結果

❯ curl -d "{\"test_exclusive_maximum\":99}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_exclusive_maximum\":100}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_exclusive_maximum": number must be less than 100

exclusiveMaximumを指定しているので、100は期待通りエラーとなりました。

■バリデーションしているコード

    // "exclusiveMaximum"
    if v := schema.ExclusiveMax; v && !(*schema.Max > value) {
      // ...snip...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1007

minimum

■スキーマ(抜粋)

#...
      properties:
        #...
        test_minimum:
          type: integer
          minimum: 10

■実行結果

❯ curl -d "{\"test_minimum\":10}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_minimum\":9}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_minimum": number must be at least 10

スキーマで指定した最小値10を下回る値9は、期待通りエラーとなります。

■バリデーションしているコード

   // "minimum"
    if v := schema.Min; v != nil && !(*v <= value) {
        // ...snip...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1024

exclusiveMaximum

■スキーマ(抜粋)

#...
      properties:
        #...
        test_exclusive_minimum:
          type: integer
          minimum: 10
          exclusiveMinimum: true

■実行結果

❯ curl -d "{\"test_exclusive_minimum\":11}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_exclusive_minimum\":10}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_exclusive_minimum": number must be more than 10

10は最小値として許容されなくなりました。

■バリデーションしているコード

    // "exclusiveMinimum"
    if v := schema.ExclusiveMin; v && !(*schema.Min < value) {
      // ...snip...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L990

maxLength

■スキーマ(抜粋)

#...
      properties:
        #...
        test_max_length:
          type: string
          maxLength: 10

■実行結果

❯ curl -d "{\"test_max_length\":\"1234567890\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_max_length\":\"1234567890A\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_max_length": maximum string length is 10

スキーマでは最大文字列長を10と指定したので、それを超える11文字は期待通りエラーとなりました。

■バリデーションしているコード

   // "minLength" and "maxLength"
    minLength := schema.MinLength
    maxLength := schema.MaxLength
    if minLength != 0 || maxLength != nil {
        // JSON schema string lengths are UTF-16, not UTF-8!
        length := int64(0)
        for _, r := range value {
            if utf16.IsSurrogate(r) {
                length += 2
            } else {
                length++
            }
        }
        // ...snip...
        if maxLength != nil && length > int64(*maxLength) {
            // ...snip...
        }
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1124

コメント(下記)を読むと、文字数をカウントするときはUTF-16として扱っているようですね。

// JSON schema string lengths are UTF-16, not UTF-8!

utf16.IsSurrogateは、サロゲートペアの場合に登場する0xd8000xe000の文字コードであれば真を返す関数です。この関数が真のとき、文字列は2文字としてカウントされるようです。
文字数のバリデーションは、サロゲートペアとして表現される文字もテストしておいたほうが良いかもしれません。

minLength

■スキーマ(抜粋)

#...
      properties:
        #...
        test_min_length:
          type: string
          minLength: 5

■実行結果

❯ curl -d "{\"test_min_length\":\"12345\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_min_length\":\"1234\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_min_length": minimum string length is 5

期待通り、最小文字列長(5)を下回る4文字はエラー。

■バリデーションしているコード https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1109

   // "minLength" and "maxLength"
    minLength := schema.MinLength
    maxLength := schema.MaxLength
    if minLength != 0 || maxLength != nil {
        // JSON schema string lengths are UTF-16, not UTF-8!
        length := int64(0)
        for _, r := range value {
            if utf16.IsSurrogate(r) {
                length += 2
            } else {
                length++
            }
        }
        if minLength != 0 && length < int64(minLength) {
            // ...snip...
        }
        // ...snip...
    }

文字数のカウントの仕方はmaxLengthのときと同じ。

pattern

■スキーマ

#...
      properties:
        #...
        test_pattern:
          type: string
          pattern: '^[A-Za-z]+'

■実行結果

❯ curl -d "{\"test_pattern\":\"a\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_pattern\":\"0a\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_pattern": string doesn't match the regular expression "^[A-Za-z]+"

■バリデーションしているコード

    // "pattern"
    if pattern := schema.Pattern; pattern != "" && schema.compiledPattern == nil {
        var err error
        if schema.compiledPattern, err = regexp.Compile(pattern); err != nil {
          // ...snip...
        }
        if cp := schema.compiledPattern; cp != nil && !cp.MatchString(value) {
          // ...snip...
        }
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1142

OpenAPIv3.0.3のpatternの説明には次のように書かれており、Ecma-262 Edition 5.1 regular expressionに従うとあります。

pattern (This string SHOULD be a valid regular expression, according to the Ecma-262 Edition 5.1 regular expression dialect)

ただ、バリデーションの実装上は、regexpパッケージが使っているRE2のシンタックスで解釈されます。

maxItems

■スキーマ(抜粋)

#...
      properties:
        #...
        test_max_items:
          type: array
          maxItems: 5
          items:
            type: integer

■実行結果

❯ curl -d "{\"test_max_items\":[1,2,3,4,5]}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_max_items\":[1,2,3,4,5,6]}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_max_items": maximum number of items is 5

スキーマで指定した最大要素数5を超えると、期待通りエラーとなりました。

■バリデーションしているコード

   if v := schema.MaxItems; v != nil && lenValue > int64(*v) {
        // ...snip...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1241

要素数はint64で扱っているので、その範囲内の要素数であれば問題なく比較ができます。(現実的にそこまで使うことはないとは思いますが)

minItems

■スキーマ(抜粋)

#...
      properties:
        #...
        test_min_items:
          type: array
          minItems: 2
          items:
            type: integer

■実行結果

❯ curl -d "{\"test_min_items\":[1,2]}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_min_items\":[1]}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_min_items": minimum number of items is 2

最小要素数2を下回る配列は期待通りエラーとなりました。

■バリデーションしているコード

   if v := schema.MinItems; v != 0 && lenValue < int64(v) {
        // ...snip...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1224

uniqueItems

■スキーマ(抜粋)

#...
      properties:
        #...
        test_unique_items:
          type: array
          uniqueItems: true
          items:
            type: integer

■実行結果

❯ curl -d "{\"test_unique_items\":[1,2,3]}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_unique_items\":[1,2,3,2]}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_unique_items": duplicate items found

要素が超複数配列(この例では2が該当)では、期待通りエラーとなります。

■バリデーションしているコード

   // "uniqueItems"
    if sliceUniqueItemsChecker == nil {
        sliceUniqueItemsChecker = isSliceOfUniqueItems
    }
    if v := schema.UniqueItems; v && !sliceUniqueItemsChecker(value) {
        // ...snip...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1257

required

■スキーマ(抜粋)

#...
      properties:
        title:
          type: string
#...
      required:
        - title

【注意】一時的に必須にして検証しています。GitHubのサンプルにはコミットされていません

■実行結果

❯ curl -d "{\"title\":\"hello world\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/title": property "title" is missing

titleを指定しない場合は期待通りエラーとなりました。

■バリデーションしているコード

   // "required"
    for _, k := range schema.Required {
        if _, ok := value[k]; !ok {
            if s := schema.Properties[k]; s != nil && s.Value.ReadOnly && settings.asreq {
                continue
            }
            if s := schema.Properties[k]; s != nil && s.Value.WriteOnly && settings.asrep {
                continue
            }
// ...snip...
        }
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1421

enum

■スキーマ(抜粋)

#...
      properties:
        #...
        test_enum:
          type: string
          enum:
            - dog
            - cat
            - bird

■実行結果

❯ curl -d "{\"test_enum\":\"dog\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_enum\":\"pig\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_enum": value is not one of the allowed values

enumプロパティの選択肢に無いpigは、期待通りエラーとなりました。

■バリデーションしているコード

   if enum := schema.Enum; len(enum) != 0 {
        for _, v := range enum {
            if value == v {
                return
            }
        }
        // ...snip...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L801

formatプロパティによるバリデーション

formatプロパティに基づくバリデーションも実装されているようなので、こちらも試してみました。

以下の表は、formatプロパティの値とそれがバリデーション可能かを示したものです。
Supportoのものはバリデーションが可能でした。

表の3列目は、バリデーションが最初から有効になっているものと、そうでないものを示しています。
値がDefaultのものは最初から有効です。具体的にはinit関数で設定する処理が行われることで実現してます。 Optionalは特定の関数を事前に呼ぶことで有効になるものです。

OpenAPIv3.0.3で定義されているもの

Format Support Default or Optional
byte o Default
binary x -
date o Default
date-time o Default
password x -

JSON Schemaで定義されているもの

Format Support Default or Optional
email o Default
idn-email x -
hostname x -
idn-hostname x -
ipv4 o Optional
ipv6 o Optional
uri x -
uri-reference x -
iri x -
iri-reference x -
uuid x -
uri-template x -
json-pointer x -
relative-json-pointer x -
regex x -

■バリデーションしているコード

formatの種類に関係なく、バリデーションは以下のコードで行われています。

    // "format"
    var formatErr string
    if format := schema.Format; format != "" {
        if f, ok := SchemaStringFormats[format]; ok {
            switch {
            case f.regexp != nil && f.callback == nil:
                if cp := f.regexp; !cp.MatchString(value) {
                    formatErr = fmt.Sprintf("string doesn't match the format %q (regular expression %q)", format, cp.String())
                }
            case f.regexp == nil && f.callback != nil:
                if err := f.callback(value); err != nil {
                    formatErr = err.Error()
                }
            default:
                formatErr = fmt.Sprintf("corrupted entry %q in SchemaStringFormats", format)
            }
        }
    }
    if formatErr != "" {
      // ...ship...
    }

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema.go#L1175

formatの値をキーとして、SchemaStringFormatsというmapからバリデーターが取り出されます。 バリデーターには「正規表現」方式と「関数コールバック」方式があり、どちらかの方式で検証が行われます。

逆に言うと、SchemaStringFormatsにバリデーターさえセットしておけば、独自のformatによるバリデーションも可能です。この例は後述します。

以降では、次の順に試した結果を列挙しています。

  1. oapi-codgenが提供するバリデーション
  2. 独自で定義したバリデーション

byte

■スキーマ(抜粋)

      properties:
        #...
        test_format_byte:
          type: string
          format: byte

■実行結果

❯ curl -d "{\"test_format_byte\":\"aGVsbG8gd29ybGQ=\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_byte\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_byte": string doesn't match the format "byte" (regular expression "(^$|^[a-zA-Z0-9+/\\-_]*=*$)")

byteOpenAPIv3.0.3の仕様によると、次のように書かれています。

base64 encoded characters

実行結果をみると、不正な値の場合は正規表現にマッチしていない旨のエラーになりました。

■バリデーターを定義しているコード

正規表現方式で実装されています。

   // Base64
    // The pattern supports base64 and b./ase64url. Padding ('=') is supported.
    DefineStringFormat("byte", `(^$|^[a-zA-Z0-9+/\-_]*=*$)`)

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema_formats.go#L87

date

■スキーマ(抜粋)

      properties:
        #...
        test_format_date:
          type: string
          format: date

■実行結果

❯ curl -d "{\"test_format_date\":\"2021-05-31\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_date\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_date": string doesn't match the format "date" (regular expression "^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)$")

dateOpenAPIv3.0.3の仕様によると、次のように書かれています。

As defined by full-date - RFC3339

こちらも正規表現でチェックされているようです。

■バリデーターを定義しているコード

正規表現方式で実装されています。

   // date
    DefineStringFormat("date", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)$`)

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema_formats.go#L90

date-time

■スキーマ(抜粋)

      properties:
        #...
        test_format_datetime:
          type: string
          format: date-time

■実行結果

❯ curl -d "{\"test_format_datetime\":\"2021-05-31T16:27:35+09:00\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_datetime\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_datetime": string doesn't match the format "date-time" (regular expression "^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(.[0-9]+)?(Z|(\\+|-)[0-9]{2}:[0-9]{2})?$")

date-timeOpenAPIv3.0.3の仕様によると、次のように書かれています。

As defined by date-time - RFC3339

■バリデーターを定義しているコード

正規表現方式で実装されています。

   // date-time
    DefineStringFormat("date-time", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`)

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema_formats.go#L93

email

■スキーマ(抜粋)

      properties:
        #...
        test_format_datetime:
          type: string
          format: email

■実行結果

❯ curl -d "{\"test_format_email\":\"test@example.com\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_email\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_email": string doesn't match the format "email" (regular expression "^[^@]+@[^@<>\",\\s]+$")

■バリデーターを定義しているコード

正規表現方式で実装されています。

   // This pattern catches only some suspiciously wrong-looking email addresses.
    // Use DefineStringFormat(...) if you need something stricter.
    DefineStringFormat("email", `^[^@]+@[^@<>",\s]+$`)

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema_formats.go#L83

コメントに、「メールのフォーマットをより厳密にチェックしたい場合はDefineStringFormatを使うこと」とあります。バリデーターを差し替えられる仕組みが整っているのは嬉しいですね。

ipv4

ipv4のバリデーターはOptionalなため、有効にしたい場合は事前にopenapi3.DefineIPv4Format関数を呼び出しておく必要があります。
これが呼ばれると、前述したSchemaStringFormatsというmapにバリデーターがセットされます。

func main() {
    // ...snip...
    openapi3.DefineIPv4Format()

    r := chi.NewRouter()
    r.Use(middleware.OapiRequestValidator(swagger))
    // ...snip...
}

■スキーマ(抜粋)

      properties:
        #...
        test_format_datetime:
          type: string
          format: ipv4

■実行結果

❯ curl -d "{\"test_format_ipv4\":\"127.0.0.1\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_ipv4\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_ipv4": Not an IP address

■バリデーターを定義しているコード

// DefineIPv4Format opts in ipv4 format validation on top of OAS 3 spec
func DefineIPv4Format() {
    DefineStringFormatCallback("ipv4", validateIPv4)
}

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema_formats.go#L98

ipv4の場合は、正規表現ではなくvalidateIPv4をコールバック関数としてセットし、それが呼び出されることでバリデーションを実現しています。

ipv6

ipv4同様、ipv6も有効にするには事前に関数(DefineIPv6Format)を呼び出しておく必要があります。

func main() {
    // ...snip...
    openapi3.DefineIPv6Format()

    r := chi.NewRouter()
    r.Use(middleware.OapiRequestValidator(swagger))
    // ...snip...
}

■スキーマ(抜粋)

      properties:
        #...
        test_format_datetime:
          type: string
          format: ipv6

■実行結果

❯ curl -d "{\"test_format_ipv6\":\"2001:0db8:0000:0000:1235:0000:0000:0abc\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_ipv6\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_ipv6": Not an IP address

■バリデーターを定義しているコード

// DefineIPv6Format opts in ipv6 format validation on top of OAS 3 spec
func DefineIPv6Format() {
    DefineStringFormatCallback("ipv6", validateIPv6)
}

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3/schema_formats.go#L103

独自のバリデーター(正規表現方式)

openapi3.DefineStringFormat関数を使うことで、正規表現ベースの独自のバリデーターを定義することができます。

今回はpostalというformatで、郵便番号をバリデーションするものを用意してみます。

バリデーターとそれを有効化するコードは以下の通りです。

func DefinePostalFormat() {
    openapi3.DefineStringFormat("postal", `^[0-9]{3}-[0-9]{4}$`)
}

func main() {
    // ...snip...
    DefinePostalFormat()

    r := chi.NewRouter()
    r.Use(middleware.OapiRequestValidator(swagger))
    // ...snip... 
}

■スキーマ(抜粋)

      properties:
        #...
        test_format_datetime:
          type: string
          format: postal

■実行結果

❯ curl -d "{\"test_format_postal\":\"123-4567\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_postal\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_postal": string doesn't match the format "postal" (regular expression "^[0-9]{3}-[0-9]{4}$")

正規表現にマッチしないものはエラーになっていることがわかります。簡単ですね。

独自のバリデーター(コールバック方式)

次は関数をコールバックする形のバリデーターを用意してみます。

この例ではUUIDを検証できるようにするバリデーターを作ってみました。 openapi3.DefineStringFormatCallback関数の第2引数にコールバックしてほしい関数を渡しています。

func DefineUUIDFormat() {
    openapi3.DefineStringFormatCallback("uuid", func(uuidStr string) error {
        _, err := uuid.Parse(uuidStr)
        return err
    })
}

func main() {
    // ...snip...
    DefineUUIDFormat()

    r := chi.NewRouter()
    r.Use(middleware.OapiRequestValidator(swagger))
    // ...snip... 
}

■スキーマ(抜粋)

properties:
  #...
  test_format_uuid:
    type: string
    format: uuid

■実行結果

❯ curl -d "{\"test_format_uuid\":\"6ff11118-cb7f-48ac-b284-a6c8062c8acc\"}" -H "Content-Type: application/json" http://localhost:8000/posts
success

❯ curl -d "{\"test_format_uuid\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
request body has an error: doesn't match the schema: Error at "/test_format_uuid": invalid UUID length: 4

エラーメッセージの最後に、エラーの戻り値のメッセージ(この例だとinvalid UUID length: 4)が入るようです。

複数がエラーとなるような場合はどうなる?

前述の「バリデーションの挙動のカスタマイズ」で述べたMultiErrorというフラグの設定次第で結果が変わります。

このフラグがfalseの場合は、最初に検証されたときのエラーメッセージのみが返ります。(デフォルトはこちらです)
trueの場合は、すべてのエラーが返ります。

例:

❯ curl -d "{\"test_maximum\":101,\"test_format_uuid\":\"😀\"}" -H "Content-Type: application/json" http://localhost:8000/posts
error validating route: request body has an error: doesn't match the schema: Error at "/test_maximum": number must be most 100
Schema:
  {
    "maximum": 100,
    "type": "integer"
  }

Value:
  101
 | Error at "/test_format_uuid": invalid UUID length: 4
Schema:
  {
    "format": "uuid",
    "type": "string"
  }

Value:
  "😀"
 |  |

URLパスパラメータやクエリパラメータもバリデーションされる?

されます。

具体的には、ValidateRequest関数の中でValidateParameter関数が呼ばれ、そこでチェックされています。

func ValidateRequest(ctx context.Context, input *RequestValidationInput) error {
    // ...snip...
    // ↓がURLパスパラメータのバリデーション
    // For each parameter of the PathItem
    for _, parameterRef := range pathItemParameters {
        // ...snip...
        if err = ValidateParameter(ctx, input, parameter); err != nil && !options.MultiError {
            return err
        }
        // ...snip...
    }

    // ↓がクエリパラメータのバリデーション
    // For each parameter of the Operation
    for _, parameter := range operationParameters {
        if err = ValidateParameter(ctx, input, parameter.Value); err != nil && !options.MultiError {
            return err
        }
        // ...snip...
    }
    // ...snip...
}

https://github.com/getkin/kin-openapi/blob/93b779808793a8a6b54ffc1f87ba17d0ffa12b70/openapi3filter/validate_request.go#L52

URLパスパラメータ

■スキーマ(抜粋)

#...
paths:
#...
  '/posts/{id}':
    parameters:
      - schema:
          type: integer
          minimum: 1
        name: id
        in: path
        required: true
#...

■実行結果

❯ curl http://localhost:8000/posts/1
success

❯ curl http://localhost:8000/posts/0
parameter "id" in path has an error: number must be at least 1

idは最小を1と定義したので、0はエラーとなっています。

クエリパラメータ

■スキーマ(抜粋)

#...
paths:
  /posts:
    get:
      summary: Return all posts
#...
      parameters:
        - schema:
            type: integer
            maximum: 100
          in: query
          name: limit

■実行結果

❯ curl "http://localhost:8000/posts?limit=100"
success

❯ curl "http://localhost:8000/posts?limit=101"
parameter "limit" in query has an error: number must be most 100

limitに指定できる最大値を100と定義したので、101はエラーとなっています。

まとめ

調べ始めたら長くなってしまいましたが、リクエストバリデーションは概ね問題なくできそうなことを確認できました。
ただ、 バリデーションエラーになった場合のリクエストフォーマットが固定なのは少し不便なので、改善を考えたいところです。
バリデーションを入れることによる性能への影響もどこかで計測したいなと思います。

OpenAPIでのスキーマ駆動開発をしていてバリデーションも入れたいなぁ、という方の参考になれば幸いです。