Closed24

protovalidate触っていく

ぱんだぱんだ

Protobufのスキーマをバリデーションできるprotoc-gen-validate(PGV)というライブラリがあった。

Envoy ProxyプロジェクトがJSONスキーマからProtobufへの移行を開始し、制約を定義できる方法としてPGVが選ばれ、2019年にEnvoyに開発が移行した。そして、2022年Bufにメンテナンスが引き継がれた。

PGVは十分に安定しており、既にその役割を終えたとして、後継のprotovalidateの開発が進んでいる。

詳しくはこちらのブログ原文を

https://buf.build/blog/protoc-gen-validate-v1-and-v2/

ぱんだぱんだ

protovalidateについて

protovalidateの大きな特徴としてGoogleが開発したCEL(Common Expression Language)を採用し、より柔軟な検証ルールを定義できるようにしている。protovalidateの主な目的はコードを生成することなくデータの一貫性と整合性を確保できるようにすることとしている。

CELについては頑張って調べたのでこちらをどうぞ

https://zenn.dev/jy8752/scraps/333a66f90a23f4

protovalidateのバージョンはまだ1.0.0に達していないがBufは新規および既存のプロジェクトはPGVの代わりにprotovalidateを使うことを推奨している。

執筆している時点で対応している言語はGo, C++, Java, Pythonでいずれもベータリリースである。TypeScriptは近日リリース予定とのこと。

protovalidateはPGVのすべての機能をサポートしている。

ぱんだぱんだ

protovalidateの導入

mkdir proto
cd proto
buf mod init
mkdir -p example/hello/v1
touch example/hello/v1/hello.proto
tree .
.
├── buf.yaml
└── example
    └── hello
        └── v1
            └── hello.proto

buf.yamlにprotovalidateの依存関係を追加します。

buf.yaml
version: v1
+deps:
+  - buf.build/bufbuild/protovalidate
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

追加できたらbuf mod updateを実行してbuf.lockファイルを作成する。

buf mod update
tree .
.
├── buf.lock
├── buf.yaml
└── example
    └── hello
        └── v1
            └── hello.proto

次にhello.protoの中身を以下のように実装します。

hello.proto
syntax = "proto3";

package example.hello.v1;

import "buf/validate/validate.proto";

message Hello { string hello = 1 [ (buf.validate.field).string.min_len = 1 ]; }

[ (buf.validate.field).string.min_len = 1 ]の部分がprotovalidateの評価式で詳細は後述するが文字列の長さが1以上となるようなバリデーションルールです。

ちなみにVSCodeを使っていると以下のようなimportエラーがでるかもしれない。

これがでたらbufのVSCodeプラグインではなくvscode-proto3プラグイン起因でのエラーらしい。このエラーを消すにはBufのモジュールキャッシュをvscode-proto3プラグインのインポートに指定する。

  "protoc": {
    "options": ["-I=~/.cache/buf/v1/module/data/buf.build"]
  }

https://github.com/bufbuild/vscode-buf/issues/10#issuecomment-962526162

ぱんだぱんだ

protovalidate-go

https://github.com/bufbuild/protovalidate-go

上で作ったHello.protoのバリデーションを確認するためにprotovalidate-goの実装をして確認する。

go mod init protovalidate-demo
touch main.go
go get github.com/bufbuild/protovalidate-go

buf.gen.yamlを作成する。

buf.gen.yaml
version: v2
managed:
  enabled: true
  override:
    - file_option: go_package_prefix
      value: protovalidate-demo/gen
  disable:
    - module: buf.build/bufbuild/protovalidate
      file_option: go_package_prefix
plugins:
  - remote: buf.build/protocolbuffers/go:v1.34.1
    out: gen
    opt: paths=source_relative

protovalidateと関係ないがBuf CLIのyamlに指定するバージョンが1から2になっていた。

https://buf.build/blog/buf-cli-next-generation

なのでbuf.gen.yalはv2を指定して書いているためv1の書き方と少し違うけど内容は同じでより直感的にわかりやすく記述できるようになっている。

そういう書きづらさ、使いづらさを問題視してすぐさま改善するところがBufはすごいよね。本当にProtobufを使った開発体験について本気で向き合ってる感がすごい。

buf generate proto
生成されたhello.pb.go
gen/example/hello/v1/hello.pb.go
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// 	protoc-gen-go v1.34.1
// 	protoc        (unknown)
// source: example/hello/v1/hello.proto

package hellov1

import (
	_ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate"
	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
	reflect "reflect"
	sync "sync"
)

const (
	// Verify that this generated code is sufficiently up-to-date.
	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
	// Verify that runtime/protoimpl is sufficiently up-to-date.
	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)

type Hello struct {
	state         protoimpl.MessageState
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	Hello string `protobuf:"bytes,1,opt,name=hello,proto3" json:"hello,omitempty"`
}

func (x *Hello) Reset() {
	*x = Hello{}
	if protoimpl.UnsafeEnabled {
		mi := &file_example_hello_v1_hello_proto_msgTypes[0]
		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
		ms.StoreMessageInfo(mi)
	}
}

func (x *Hello) String() string {
	return protoimpl.X.MessageStringOf(x)
}

func (*Hello) ProtoMessage() {}

func (x *Hello) ProtoReflect() protoreflect.Message {
	mi := &file_example_hello_v1_hello_proto_msgTypes[0]
	if protoimpl.UnsafeEnabled && x != nil {
		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
		if ms.LoadMessageInfo() == nil {
			ms.StoreMessageInfo(mi)
		}
		return ms
	}
	return mi.MessageOf(x)
}

// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
func (*Hello) Descriptor() ([]byte, []int) {
	return file_example_hello_v1_hello_proto_rawDescGZIP(), []int{0}
}

func (x *Hello) GetHello() string {
	if x != nil {
		return x.Hello
	}
	return ""
}

var File_example_hello_v1_hello_proto protoreflect.FileDescriptor

var file_example_hello_v1_hello_proto_rawDesc = []byte{
	0x0a, 0x1c, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2f,
	0x76, 0x31, 0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10,
	0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2e, 0x76, 0x31,
	0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76,
	0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x26, 0x0a,
	0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x1d, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18,
	0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x05,
	0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x42, 0xb5, 0x01, 0x0a, 0x14, 0x63, 0x6f, 0x6d, 0x2e, 0x65, 0x78,
	0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2e, 0x76, 0x31, 0x42, 0x0a,
	0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x2f, 0x70, 0x72,
	0x6f, 0x74, 0x6f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2d, 0x64, 0x65, 0x6d, 0x6f,
	0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2f, 0x68, 0x65, 0x6c,
	0x6c, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x76, 0x31, 0xa2, 0x02, 0x03,
	0x45, 0x48, 0x58, 0xaa, 0x02, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x48, 0x65,
	0x6c, 0x6c, 0x6f, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65,
	0x5c, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x45, 0x78, 0x61, 0x6d,
	0x70, 0x6c, 0x65, 0x5c, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42,
	0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x12, 0x45, 0x78, 0x61, 0x6d, 0x70,
	0x6c, 0x65, 0x3a, 0x3a, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70,
	0x72, 0x6f, 0x74, 0x6f, 0x33,
}

var (
	file_example_hello_v1_hello_proto_rawDescOnce sync.Once
	file_example_hello_v1_hello_proto_rawDescData = file_example_hello_v1_hello_proto_rawDesc
)

func file_example_hello_v1_hello_proto_rawDescGZIP() []byte {
	file_example_hello_v1_hello_proto_rawDescOnce.Do(func() {
		file_example_hello_v1_hello_proto_rawDescData = protoimpl.X.CompressGZIP(file_example_hello_v1_hello_proto_rawDescData)
	})
	return file_example_hello_v1_hello_proto_rawDescData
}

var file_example_hello_v1_hello_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_example_hello_v1_hello_proto_goTypes = []interface{}{
	(*Hello)(nil), // 0: example.hello.v1.Hello
}
var file_example_hello_v1_hello_proto_depIdxs = []int32{
	0, // [0:0] is the sub-list for method output_type
	0, // [0:0] is the sub-list for method input_type
	0, // [0:0] is the sub-list for extension type_name
	0, // [0:0] is the sub-list for extension extendee
	0, // [0:0] is the sub-list for field type_name
}

func init() { file_example_hello_v1_hello_proto_init() }
func file_example_hello_v1_hello_proto_init() {
	if File_example_hello_v1_hello_proto != nil {
		return
	}
	if !protoimpl.UnsafeEnabled {
		file_example_hello_v1_hello_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
			switch v := v.(*Hello); i {
			case 0:
				return &v.state
			case 1:
				return &v.sizeCache
			case 2:
				return &v.unknownFields
			default:
				return nil
			}
		}
	}
	type x struct{}
	out := protoimpl.TypeBuilder{
		File: protoimpl.DescBuilder{
			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
			RawDescriptor: file_example_hello_v1_hello_proto_rawDesc,
			NumEnums:      0,
			NumMessages:   1,
			NumExtensions: 0,
			NumServices:   0,
		},
		GoTypes:           file_example_hello_v1_hello_proto_goTypes,
		DependencyIndexes: file_example_hello_v1_hello_proto_depIdxs,
		MessageInfos:      file_example_hello_v1_hello_proto_msgTypes,
	}.Build()
	File_example_hello_v1_hello_proto = out.File
	file_example_hello_v1_hello_proto_rawDesc = nil
	file_example_hello_v1_hello_proto_goTypes = nil
	file_example_hello_v1_hello_proto_depIdxs = nil
}

main.go
package main

import (
	"fmt"
	hellov1 "protovalidate-demo/gen/hello/v1"

	"github.com/bufbuild/protovalidate-go"
)

func main() {
	msg := &hellov1.Hello{
		Hello: "",
	}

	v, err := protovalidate.New()
	if err != nil {
		panic(err)
	}

	if err = v.Validate(msg); err != nil {
		fmt.Println("validation failed:", err)
	} else {
		fmt.Println("validation succeeded")
	}
}

go run main.go
validation failed: validation error:
 - hello: value length must be at least 1 characters [string.min_len]

Helloは1文字以上の文字列を指定する必要があるためバリデーションエラーになっているため想定通りに動作している

ぱんだぱんだ

protovalidateにおけるCEL

protovalidateではCELの以下の機能を使用して制約を記述することができる。

  • CELの組み込み関数
  • マクロ
  • CELのstring関数ライブラリ
  • PGVの機能のためのカスタムの拡張関数
ぱんだぱんだ

Conformance

https://github.com/bufbuild/protovalidate/blob/main/docs/conformance.md

あまり理解できなかったがprotovalidateのリポジトリ内のMakefileコマンドを実行して実行バイナリを得られ、これにバリデーションの期待する失敗テストをyamlに書いて渡してあげればテストスイートが実行できるよみたいな感じだろうか。

間違ってるかもしれないが雰囲気はそんな感じ

ぱんだぱんだ

制約(Constraints)

protovalidateで言うところの制約は2種類ある。protovalidateで用意されている標準制約とCELで独自に記述するカスタム制約である。

カスタム制約ではフィールドレベルの制約とメッセージレベルの制約があるが、メッセージレベルの制約をCELを使いカスタム制約として書くことでMessage内のフィールド同士に跨った制約なども書くことができる。

Constraintメッセージはメッセージの検証に使用される。Constraintメッセージは以下のフィールドを持つ。

  • id
  • message 検証エラー時の人間が読めるエラー文
  • expression CELによる検証式

実際の使用としては以下のようにProtobufのmessage内のoptionに指定したcelフィールドに上述のConstraintメッセージを指定する。

message Example{
  option (buf.validate.message).cel = {
    id: "message_expression_nested",
    message: "a must be greater than b",
    expression: "this.a > this.b"
  };

  int32 a = 1;
  int32 b = 2;
}

フィールドレベルのバリデーションは以下のように指定する。

message Product {
  double price = 1 [(buf.validate.field).cel = {
    id: "product.price_range",
    message: "Price must be between 0 and 1000",
    expression: "this >= 0 && this <= 1000",
  }];
}
ぱんだぱんだ

Error

Validationメッセージは1つの制約違反を表し、これは以下の3つのフィールドを持つ。

  • field_path バリデーションに失敗したフィールドへのパス
  • constraint_id 制約のID
  • message エラーメッセージ

Validationsメッセージは複数のValidationメッセージを持つ。

ぱんだぱんだ

Standard constratins

上述したようにprotovalidateはCELで独自実装しなくても、標準的なバリデーションロジックをメッセージレベルやフィールドレベルで用意してくれている。

Message

messageで使用できる制約は執筆時点でdisabledのみです。

message DisabledExample {
  option (buf.validate.message).disabled = true;
  // このバリデーションは無効化される
  string val = 1 [ (buf.validate.field).string.min_len = 1 ];
}

oneof

oneofのoptionを拡張してprotovalidateでは制約が用意されている。執筆時点でoneofで使用できる制約は一つだけ。

message OneofExample {
  oneof union {
    option (buf.validate.oneof).required = true;
    string val1 = 1;
    string val2 = 2;
  }
}
validation failed: validation error:
 - union: exactly one field is required in oneof [required]
ぱんだぱんだ

フィールド制約

google.protobuf.FieldOptionsを拡張してFieldConstraints型の新しいオプションの名前付きフィールドを追加することで、Messageのフィールドに対して制約を記述することができる。

message Hello { string hello = 1 [ (buf.validate.field).string.min_len = 1 ]; }
ぱんだぱんだ

string

string値に対してのバリデーション。

const

フィールドの値が指定の値と完全に一致しているか。

  // const_value = "const" ok
  // const_value = "const1" NG
  string const_value = 1 [ (buf.validate.field).string.const = "const" ];

len

文字列の長さ

  // len_value = "Hello!!" NG
 // len_value = "Hello" OK
  string len_value = 2 [ (buf.validate.field).string.len = 5 ];

min_len

文字列長の最小値

  // min_len_value = "Hi" NG
  string min_len_value = 3 [ (buf.validate.field).string.min_len = 5 ];

max_len

文字列長の最大値

  // max_len_value = "Hello!!" NG
  string max_len_value = 4 [ (buf.validate.field).string.max_len = 5 ];

len_bytes

文字列のバイト長

  // 2バイトの文字列である必要がある
  // len_bytes_value = "ab" OK
  // len_bytes_value = "abc" NG
  string len_bytes_value = 5 [ (buf.validate.field).string.len_bytes = 2 ];

min_bytes

最小のバイト長

  // min_bytes_value = "a" NG
  string min_bytes_value = 6 [ (buf.validate.field).string.min_bytes = 2 ];

max_bytes

最大のバイト長

  // max_bytes_value = "abc" NG
  string max_bytes_value = 7 [ (buf.validate.field).string.max_bytes = 2 ];

pattern

正規表現

  // pattern_value = "hi, world" NG
  // pattern_value = "hello, world" OK
  string pattern_value = 8
      [ (buf.validate.field).string.pattern = "^hello, .*$" ];

prefix

先頭文字マッチ

  // prefix_value = "Hello, World" OK
  string prefix_value = 9 [ (buf.validate.field).string.prefix = "Hello" ];

suffix

末尾マッチ

  // suffix_value = "Hello, World" OK
  // suffix_value = "Hello, Japan" NG
  string suffix_value = 10 [ (buf.validate.field).string.suffix = "World" ];

contains

指定の文字列をふくむかどうか

  // contains_value = "apple, banana, orange" OK
  // contains_value = "apple, orange" NG
  string contains_value = 11
      [ (buf.validate.field).string.contains = "banana" ];

not_contains

指定の文字列を含んでいない

  // not_contains_value = "apple, banana, orange" NG
  // not_contains_value = "apple, orange" OK
  string not_contains_value = 12
      [ (buf.validate.field).string.not_contains = "banana" ];

in

指定の文字列listに対象の文字列が含まれるかどうか

  // in_value = "Go" OK
  // in_value = "Rust" NG
  string in_value = 13 [
    (buf.validate.field).string.in = "Java",
    (buf.validate.field).string.in = "Kotlin",
    (buf.validate.field).string.in = "Go"
  ];

not_in

指定の文字列listに対象の文字列が含まれないかどうか

  // not_in_value = "Go" NG
  // not_in_value = "Rust" OK
  string not_in_value = 14 [
    (buf.validate.field).string.not_in = "Java",
    (buf.validate.field).string.not_in = "Kotlin",
    (buf.validate.field).string.not_in = "Go"
  ];

email

  // protovalidate@example.com OK
  // protovalidate.example.com NG
  string email_value = 15 [ (buf.validate.field).string.email = true ];

hostname

RFC1034で定義されているホスト名
これはprotovalidateで用意されているCEL関数のisHostname()を使って検証している。

  // 127.0.0.1 NG
  // https://example.com NG
  // example.com OK
  string hostname_value = 16 [ (buf.validate.field).string.hostname = true ];

ip

ipv4 or ipv6

  // 127.0.0.1 OK
  // ::192.0.2.33 OK
  // 2001:0db8:85a3:0000:0000:8a2e:0370:7334 OK
  // 255.255.255.256 NG
  string ip_value = 17 [ (buf.validate.field).string.ip = true ];

ipv4

  // 127.0.0.1 OK
  // 2001:0db8:85a3:0000:0000:8a2e:0370:7334 NG
  string ipv4_value = 18 [ (buf.validate.field).string.ipv4 = true ];

ipv6

  // 127.0.0.1 NG
  // 2001:0db8:85a3:0000:0000:8a2e:0370:7334 OK
  string ipv6_value = 19 [ (buf.validate.field).string.ipv6 = true ];

uri

RFC3986で定義されているURI形式
protovalidateが用意しているCELのカスタム関数isURI()を使用して検証している

  // https://example.com OK
  // example.com NG
  string uri_value = 20 [ (buf.validate.field).string.uri = true ];

uri_ref

相対パス含む

  // ./example.com OK
  string uri_ref_value = 21 [ (buf.validate.field).string.uri_ref = true ];

address

ホスト名もしくはIP

  // 127.0.0.1 OK
  // example.com OK
  string address_value = 22 [ (buf.validate.field).string.address = true ];

uuid

RFC4122

  // 550e8400-e29b-41d4-a716-446655440000 OK
  string uuid_value = 23 [ (buf.validate.field).string.uuid = true ];

tuuid

RFC4122

  // 550e8400e29b41d4a716446655440000 OK
  string tuuid_value = 24 [ (buf.validate.field).string.tuuid = true ];

以下IP関連のバリデーションはBBSakuraさんがPRを出して追加されたようです。

https://blog.bbsakura.net/posts/add-is-ip-prefix-to-protovalidate

ip_with_prefixlen

  // 255.255.255.0/24 OK
  // 255.255.255.0 NG
  string ip_with_preifxlen_value = 25
      [ (buf.validate.field).string.ip_with_prefixlen = true ];

ipv4_with_prefixlen

  // 255.255.255.0/24 OK
  // 255.255.255.0 NG
  string ipv4_with_preifxlen_value = 26
      [ (buf.validate.field).string.ipv4_with_prefixlen = true ];

ipv6_with_prefixlen

  // 2001:0db8:85a3:0000:0000:8a2e:0370:7334/24 OK
  // 2001:0db8:85a3:0000:0000:8a2e:0370:7334 NG
  string ipv6_with_preifxlen_value = 27
      [ (buf.validate.field).string.ipv6_with_prefixlen = true ];

ip_prefix

アドレス部分がネットワークアドレスになっていて、プレフィックス長が付いている値
ipv4もipv6も

  // 127.0.0.0/16 OK
  // 127.0.0.1/16 NG
  string ip_prefix_value = 28 [ (buf.validate.field).string.ip_prefix = true ];

ipv4_prefix

  // 127.0.0.0/16 OK
  // 127.0.0.1/16 NG
  string ip4_prefix_value = 29
      [ (buf.validate.field).string.ipv4_prefix = true ];

ipv6_prefix

  // 2001:db8::/48 OK
  // 2001:db8::1/48 NG
  string ip6_prefix_value = 30
      [ (buf.validate.field).string.ipv6_prefix = true ];

host_and_port

有効なホスト名もしくはIPとportの組み合わせ

  // 127.0.0.1:8080 OK
  // 127.0.0.1 NG
  // example.com:8080 OK
  // example.com NG
  // [::1]:1234 OK
  string host_and_port_value = 31
      [ (buf.validate.field).string.host_and_port = true ];

well_known_regex, strict

KnownRegexというenumを指定する。KownRegexは執筆時点で以下のような構造

enum KnownRegex {
    KNOWN_REGEX_UNSPECIFIED = 0,
    // RFC 7230で定義されているHTTPヘッダー名とその値の形式
    KNOWN_REGEX_HTTP_HEADER_NAME = 1,
    KNOWN_REGEX_HTTP_HEADER_VALUE = 2
}
  // KnownRegex enumを指定する
  // KNOWN_REGEX_HTTP_HEADER_NAME HTTPヘッダー名
  // KNOWN_REGEX_HTTP_HEADER_VALUE HTTPヘッダー値
  //
  // Content-Type OK
  // Content Type OK (strict = fasle)
  // Content Type NG (strict = true)
  string well_kown_regex_value = 32 [
    (buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME,
    (buf.validate.field).string.strict = false
  ];

strictを一緒に指定することでルールを緩めることお可能。デフォルトはtrue

ぱんだぱんだ

Bool

bool値が指定の真偽値であることを検証する。
これはgoogle.protobuf.BoolValueでも使用できる。

message BoolValidationExample {
    // true_value = true OK
    // true_value = false NG
  bool true_value = 1 [ (buf.validate.field).bool.const = true ];
    // false_value = true NG
    // false_value = false OK
  bool false_value = 2 [ (buf.validate.field).bool.const = false ];
}
ぱんだぱんだ

Byte

byte値に対するバリデーション。これはgoogle.protobuf.BytesValueでも使用できる。

const

指定のbyte値であることの検証

  // 1234 OK
  // 123 NG
  bytes const_value = 1
      [ (buf.validate.field).bytes.const = "\x01\x02\x03\x04" ];

len, min_len, max_len

  // 1234 OK
  // 123 NG
  bytes len_value = 2 [ (buf.validate.field).bytes.len = 4 ];
  // 123 OK
  // 1 NG
  bytes min_len_value = 3 [ (buf.validate.field).bytes.min_len = 2 ];
  // 12 OK
  // 123 NG
  bytes max_len_value = 4 [ (buf.validate.field).bytes.max_len = 2 ];

pattern

byte値をUTF-8文字列に変換したときに指定の正規表現とマッチするかどうか
CELの評価式は!string(this).matches(rules.pattern)となっている

  // 0x61 (a) OK
  // 0xe3, 0x81, 0x82 (あ) NG
  bytes pattern_value = 5
      [ (buf.validate.field).bytes.pattern = "^[a-zA-Z0-9]+$" ];

prefix, suffix

  // 0x01, 0x02, 0x03 OK
  // 0x01, 0x03, 0x02NG
  bytes prefix_value = 6 [ (buf.validate.field).bytes.prefix = "\x01\x02" ];
  // 0x01, 0x02, 0x03 OK
  // 0x02, 0x01, 0x03 NG
  bytes suffix_value = 7 [ (buf.validate.field).bytes.suffix = "\x02\x03" ];

contains

  // 0x01, 0x02, 0x03 OK
  // 0x01, 0x03 NG
  bytes contains_value = 8 [ (buf.validate.field).bytes.contains = "\x02" ];

in, not_in

  // 0x02, 0x03 OK
  // 0x01, 0x02, 0x03 NG
  bytes in_value = 9 [
    (buf.validate.field).bytes.in = "\x01\x02",
    (buf.validate.field).bytes.in = "\x02\x03",
    (buf.validate.field).bytes.in = "\x03\x04"
  ];
  // 0x02, 0x03 NG
  // 0x01, 0x02, 0x03 OK
  bytes not_in_value = 10 [
    (buf.validate.field).bytes.not_in = "\x01\x02",
    (buf.validate.field).bytes.not_in = "\x02\x03",
    (buf.validate.field).bytes.not_in = "\x03\x04"
  ];

ip, ipv4, ipv6

ipv4は4バイト、ipv6は16バイト

  // 0xFF, 0xFF, 0xFF, 0x00 (255.255.255.0) OK
  // \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
  // \xff\xff\xff\xff\xff\x00   (::ffff:ffff:ff00) OK
  // \x01\x02 NG
  bytes ip_value = 11 [ (buf.validate.field).bytes.ip = true ];
  // 0xFF, 0xFF, 0xFF, 0x00 (255.255.255.0) OK
  // \x01\x02 NG
  bytes ipv4_value = 12 [ (buf.validate.field).bytes.ipv4 = true ];
  // \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
  // \xff\xff\xff\xff\xff\x00   (::ffff:ffff:ff00) OK
  // \x01\x02 NG
  bytes ipv6_value = 13 [ (buf.validate.field).bytes.ipv6 = true ];
ぱんだぱんだ

Numeric constraints

  • Double
  • Float
  • Fixed
    • Fixed32
    • SFixed32
    • Fixed64
    • SFixed64
  • Int
    • Int32
    • Int64
    • SInt32
    • SInt64

const

  // 42.0 OK
  // 10.0 NG
  double const_value = 1 [ (buf.validate.field).double.const = 42.0 ];

lt, lte

ltとlteは同時に指定することはできない

  // 9.0 OK
  // 10.0 NG
  double lt_value = 2 [ (buf.validate.field).double.lt = 10.0 ];
  // 10.0 OK
  // 11.0 NG
  double lte_value = 3 [ (buf.validate.field).double.lte = 10.0 ];

gt, gte

gtとgteは同時に適用することはできない

  // 11.0 OK
  // 10.0 NG
  double gt_value = 4 [ (buf.validate.field).double.gt = 10.0 ];
  // 10.0 OK
  // 9.0 NG
  double gte_value = 5 [ (buf.validate.field).double.gte = 10.0 ];

in, not_in

  // 11.0 OK
  // 13.0 NG
  double in_value = 6 [
    (buf.validate.field).double.in = 10.0,
    (buf.validate.field).double.in = 11.0,
    (buf.validate.field).double.in = 12.0
  ];
  // 11.0 NG
  // 13.0 OK
  double not_in_value = 7 [
    (buf.validate.field).double.not_in = 10.0,
    (buf.validate.field).double.not_in = 11.0,
    (buf.validate.field).double.not_in = 12.0
  ];

finite

double型の値のみ。
infiniteとNaNのときエラーとなる

  // infinite or NaN NG double only
  double finite_value = 8 [ (buf.validate.field).double.finite = true ];
ぱんだぱんだ

enum

  enum MyEnum {
    MY_ENUM_UNSPECIFIED = 0;
    MY_ENUM_VALUE1 = 1;
    MY_ENUM_VALUE2 = 2;
    MY_ENUM_VALUE3 = 3;
  }

const

  // MY_ENUM_VALUE1 OK
  // MY_ENUM_VALUE2 NG
  MyEnum const_value = 1 [ (buf.validate.field).enum.const = 1 ];

defined_only

enumで定義されている値のみ

  // Undefined Value 4 NG
  MyEnum defined_only_value = 2
      [ (buf.validate.field).enum.defined_only = true ];

in, not_in

  // MY_ENUM_VALUE1 OK
  // MY_ENUM_VALUE3 NG
  MyEnum in_value = 3
      [ (buf.validate.field).enum.in = 1, (buf.validate.field).enum.in = 2 ];
  // MY_ENUM_VALUE1 NG
  // MY_ENUM_VALUE3 OK
  MyEnum not_in_value = 4 [
    (buf.validate.field).enum.not_in = 1,
    (buf.validate.field).enum.not_in = 2
  ];
ぱんだぱんだ

map

min_pairs, max_pairs

最小、最大要素数

  // {"key1": "value1", "key2": "value2"} OK
  // {"key1": "value1"} NG
  map<string, string> min_pairs_value = 1
      [ (buf.validate.field).map.min_pairs = 2 ];
  // {"key1": "value1", "key2": "value2"} OK
  // {"key1": "value1", "key2": "value2", "key3": "value3"} NG
  map<string, string> max_pairs_value = 2
      [ (buf.validate.field).map.max_pairs = 2 ];

keys, values

key, valueそれぞれのデータ型に対しての制約

  // {"a": "value1"} NG
  // {"abcdefghijk": "value1"} NG
  // {"key1": "value1"} OK
  map<string, string> keys_value = 3
      [ (buf.validate.field).map.keys = {string : {min_len : 3 max_len : 10}} ];
  // {"key1": "a"} NG
  // {"key1": "abcdefghijk"} NG
  // {"key1": "value1"} OK
  map<string, string> values_value = 4 [
    (buf.validate.field).map.values = {string : {min_len : 3 max_len : 10}}
  ];
ぱんだぱんだ

repeated

min_items, max_items

  // ["elm1", "elm2"] OK
  // ["elm1"] NG
  repeated string min_items_value = 1
      [ (buf.validate.field).repeated .min_items = 2 ];
  // ["elm1", "elm2"] OK
  // ["elm1", "el2", "el3"] NG
  repeated string max_items_value = 2
      [ (buf.validate.field).repeated .max_items = 2 ];

unique

  // ["elm1", "elm2"] OK
  // ["elm1", "elm2", "elm2"] NG
  repeated string unique_value = 3
      [ (buf.validate.field).repeated .unique = true ];

items

  // ["a"] NG
  // ["abcdefghijk"] NG
  repeated string items_value = 4 [
    (buf.validate.field).repeated .items = {string : {min_len : 3 max_len : 10}}
  ];
ぱんだぱんだ

google.protobuf.Any

in, not_in

指定は型の完全修飾名で

  // google.protobuf.Int32Value OK
  // google.protobuf.BoolValue NG
  google.protobuf.Any in_value = 1 [
    (buf.validate.field).any.in =
        "type.googleapis.com/google.protobuf.Int32Value",
    (buf.validate.field).any.in =
        "type.googleapis.com/google.protobuf.StringValue"
  ];
  // google.protobuf.Int32Value NG
  // google.protobuf.BoolValue OK
  google.protobuf.Any not_in_value = 2 [
    (buf.validate.field).any.not_in =
        "type.googleapis.com/google.protobuf.Int32Value",
    (buf.validate.field).any.not_in =
        "type.googleapis.com/google.protobuf.StringValue"
  ];
ぱんだぱんだ

google.protobuf.Duration

message Duration {
  // Signed seconds of the span of time. Must be from -315,576,000,000
  // to +315,576,000,000 inclusive. Note: these bounds are computed from:
  // 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years
  int64 seconds = 1;

  // Signed fractions of a second at nanosecond resolution of the span
  // of time. Durations less than one second are represented with a 0
  // `seconds` field and a positive or negative `nanos` field. For durations
  // of one second or more, a non-zero value for the `nanos` field must be
  // of the same sign as the `seconds` field. Must be from -999,999,999
  // to +999,999,999 inclusive.
  int32 nanos = 2;
}

const

  // <Go> durationpb.New(5 * time.Second) OK
  google.protobuf.Duration const_value = 1
      [ (buf.validate.field).duration.const = {seconds : 5} ];

lt, lte

  // <Go> durationpb.New(4 * time.Second) OK
  google.protobuf.Duration lt_value = 2
      [ (buf.validate.field).duration.lt = {seconds : 5} ];
  // <Go> durationpb.New(5 * time.Second) OK
  google.protobuf.Duration lte_value = 3
      [ (buf.validate.field).duration.lte = {seconds : 5} ];

gt, gte

  // <Go> durationpb.New(6 * time.Second) OK
  google.protobuf.Duration gt_value = 4
      [ (buf.validate.field).duration.gt = {seconds : 5} ];
  // <Go> durationpb.New(5 * time.Second) OK
  google.protobuf.Duration gte_value = 5
      [ (buf.validate.field).duration.gte = {seconds : 5} ];

in, not_in

  // <Go> durationpb.New(5 * time.Second) OK
  google.protobuf.Duration in_value = 6 [
    (buf.validate.field).duration.in = {seconds : 5},
    (buf.validate.field).duration.in = {seconds : 6},
    (buf.validate.field).duration.in = {seconds : 7}
  ];
  // <Go> durationpb.New(8 * time.Second) OK
  google.protobuf.Duration not_in_value = 7 [
    (buf.validate.field).duration.not_in = {seconds : 5},
    (buf.validate.field).duration.not_in = {seconds : 6},
    (buf.validate.field).duration.not_in = {seconds : 7}
  ];
ぱんだぱんだ

google.protobuf.Timestamp

message Timestamp {
  // Represents seconds of UTC time since Unix epoch
  // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
  // 9999-12-31T23:59:59Z inclusive.
  int64 seconds = 1;

  // Non-negative fractions of a second at nanosecond resolution. Negative
  // second values with fractions must still have non-negative nanos values
  // that count forward in time. Must be from 0 to 999,999,999
  // inclusive.
  int32 nanos = 2;
}

const

  //  date -u -j -f "%Y-%m-%d %H:%M:%S" "2024-06-03 12:00:00" +%s
  google.protobuf.Timestamp const_value = 1
      [ (buf.validate.field).timestamp.const = {seconds : 1717416000} ];

lt, lte, lt_now

  // date -j -f "%Y-%m-%d %H:%M:%S" "2024-06-03 11:00:00" +%s
  // > 1717412400 OK
  google.protobuf.Timestamp lt_value = 2
      [ (buf.validate.field).timestamp.lt = {seconds : 1717416000} ];
  // date -j -f "%Y-%m-%d %H:%M:%S" "2024-06-03 12:00:00" +%s
  // > 1717416000 OK
  google.protobuf.Timestamp lte_value = 3
      [ (buf.validate.field).timestamp.lte = {seconds : 1717416000} ];
  google.protobuf.Timestamp lt_now_value = 4
      [ (buf.validate.field).timestamp.lt_now = true ];

gt, gte, gt_now

  // date -j -f "%Y-%m-%d %H:%M:%S" "2024-06-03 13:00:00" +%s
  // > 1717419600 OK
  google.protobuf.Timestamp gt_value = 5
      [ (buf.validate.field).timestamp.gt = {seconds : 1717416000} ];
  // date -j -f "%Y-%m-%d %H:%M:%S" "2024-06-03 12:00:00" +%s
  // > 1717416000 OK
  google.protobuf.Timestamp gte_value = 6
      [ (buf.validate.field).timestamp.gte = {seconds : 1717416000} ];
  google.protobuf.Timestamp gt_now_value = 7
      [ (buf.validate.field).timestamp.gt_now = true ];

within

現在時刻から指定のduration以内であること

  // バリデーション時の現在時刻から前後1時間以内の時刻であること
  google.protobuf.Timestamp within_value = 8
      [ (buf.validate.field).timestamp.within = {seconds : 3600} ];
ぱんだぱんだ

Other

今までに紹介してきたデータ型のルールはFieldConstraintsというmessageのフィールドの一部として定義されている。FieldConstraintsには他にも指定できるフィールドが存在する。

cel

CEL式をid, messageとともにフィールドの値に指定できる。

  // 2 OK
  // 3 NG
  int32 even_value = 1 [ (buf.validate.field).cel = {
    id : "int32.even",
    message : "value must be even number",
    expression : "this % 2 == 0",
  } ];

required

適用するデータ型によって振る舞いが変わる

  message MyValue { int32 value = 1; }
  enum Status {
    STATUS_UNSPECIFIED = 0;
    STATUS_OK = 1;
  }

  // 値の指定がないとerror
  MyValue required_message_value = 2 [ (buf.validate.field).required = true ];
  // デフォルト値(空文字)だとエラー
  string required_string_value = 3 [ (buf.validate.field).required = true ];
  // デフォルト値(0)だとエラー
  int32 required_int32_value = 4 [ (buf.validate.field).required = true ];
  // 0がだめなのでenumの場合、未定義がエラーになる
  Status required_enum_value = 5 [ (buf.validate.field).required = true ];
  // 要素が0のときエラー
  repeated string required_repeated_value = 6
      [ (buf.validate.field).required = true ];
  // 要素が0のときエラー
  map<string, string> required_map_value = 7
      [ (buf.validate.field).required = true ];

ignore

  // 値が指定されていない時にはemail制約を無視する
  string ignore_value = 8 [
    (buf.validate.field).string.email = true,
    (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED
  ];
ぱんだぱんだ

Conformance test

https://github.com/bufbuild/protovalidate/blob/main/docs/conformance.md

protovalidateで制約をつけたProtobufのテストに使うツールかと思っていたがこれはprotovalidateをサポートする各プログラミング言語ごとのライブラリの受け入れテストツールのよう。

protovalidate側で全ての制約を網羅したテストスイートを用意しており、それをTestConformanceRequestとして標準入力から受け取るようなExecuterと呼ばれる実行プログラムを用意し、Executer側でバリデーションを実行しテストがパスすることを確認するようだ。

protovalidate-goでは以下のようなExecuterを用意しているのでもし他の言語でprotovalidate対応ライブラリを開発する場合、同じようなExecuterを用意してテストする必要がある。

https://github.com/bufbuild/protovalidate-go/blob/main/internal/cmd/protovalidate-conformance-go/main.go

また、もしprotovalidateにPRを送り、新しい制約を追加してもらった場合、テストスイートも追加する必要がある。

ここら辺の話もBBSakuraさんのIPプレフィックスのPRの話が参考になる。

https://blog.bbsakura.net/?page=1703233061#:~:text=っています。-,最後に,-機能追加自体

このスクラップは3ヶ月前にクローズされました