こんにちは、バックエンドエンジニアの鈴木(善)です。
弊社はOpenAPIでのスキーマ駆動開発をしており、サーバーサイドのコードジェネレーターとしてoapi-codegenを利用しています。
chiで受けたHTTPリクエストをスキーマに基づきバリデーションする機能が、v1.5.0で実装されました。本記事ではこれを試してみた結果をまとめました。
なお、今回試したときの各種バージョンは以下のとおりです。
- golang:
go version go1.16.2 darwin/amd64
- oapi-codegen:
v1.6.1
【注意】oapi-codegenはchiの他にもechoとの組み合わせもサポートしていますが、そちらについては本記事の対象外となっております🙏
TL;DR
- OpenAPIの主要なプロパティは概ねサポートされている
- リクエストボディだけでなく、URLパスパラメータやクエリパラメータもバリデーション可能
- 独自のバリデーションを定義することもできる
- バリデーション不正時のレスポンスフォーマットが固定なのはやや不便かも
サンプルコード
本記事の完全なサンプルコードは以下を参照ください。
バリデーションの仕組み
oapi-codegenでは、OpenAPIに絡むところはkin-openapiというライブラリが使われています。バリデーションもこちらを使って実現されています。
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 }
バリデーションの挙動のカスタマイズ
バリデーションの挙動を変更する仕組みとして、Options
という構造体が用意されています。
ただこれは今のところ、openapi3filter.Options
という構造体を埋め込んでいるだけのようです。
// Options to customize request validation, openapi3filter specified options will be passed through. type Options struct { Options openapi3filter.Options }
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... }
まず、値の型に応じて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... } }
倍数は浮動小数点数で計算されているようです。
そのため、以下のように少数を指定しても、問題なくバリデーションされました。
■スキーマ(抜粋)
#... 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... }
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... }
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... }
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... }
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... } }
コメント(下記)を読むと、文字数をカウントするときはUTF-16として扱っているようですね。
// JSON schema string lengths are UTF-16, not UTF-8!
utf16.IsSurrogate
は、サロゲートペアの場合に登場する0xd800
〜0xe000
の文字コードであれば真を返す関数です。この関数が真のとき、文字列は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... } }
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... }
要素数は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... }
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... }
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... } }
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... }
formatプロパティによるバリデーション
formatプロパティに基づくバリデーションも実装されているようなので、こちらも試してみました。
以下の表は、format
プロパティの値とそれがバリデーション可能かを示したものです。
Support
がo
のものはバリデーションが可能でした。
表の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 |
---|---|---|
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... }
format
の値をキーとして、SchemaStringFormats
というmapからバリデーターが取り出されます。
バリデーターには「正規表現」方式と「関数コールバック」方式があり、どちらかの方式で検証が行われます。
逆に言うと、SchemaStringFormats
にバリデーターさえセットしておけば、独自のformat
によるバリデーションも可能です。この例は後述します。
以降では、次の順に試した結果を列挙しています。
- oapi-codgenが提供するバリデーション
- 独自で定義したバリデーション
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+/\\-_]*=*$)")
byte
はOpenAPIv3.0.3の仕様によると、次のように書かれています。
base64 encoded characters
実行結果をみると、不正な値の場合は正規表現にマッチしていない旨のエラーになりました。
■バリデーターを定義しているコード
正規表現方式で実装されています。
// Base64 // The pattern supports base64 and b./ase64url. Padding ('=') is supported. DefineStringFormat("byte", `(^$|^[a-zA-Z0-9+/\-_]*=*$)`)
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)$")
date
はOpenAPIv3.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)$`)
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-time
はOpenAPIv3.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})?$`)
■スキーマ(抜粋)
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]+$`)
コメントに、「メールのフォーマットをより厳密にチェックしたい場合は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) }
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) }
独自のバリデーター(正規表現方式)
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... }
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でのスキーマ駆動開発をしていてバリデーションも入れたいなぁ、という方の参考になれば幸いです。