前提環境
- go version1.17.10 linux/amd64
- grpc-ecosystem/grpc-gateway version2.3.0
概要
前提としてスキーマ駆動開発、コード自動生成、golangの静的型付けによる安全性といった恩恵を受けるためにProtocol Buffersを使用してAPIの定義を行なっています。
このAPIの仕様を他開発者も読むため、定義したprotoファイルからswaggerを自動で生成できるようにProtocol Buffersのコマンドを設定しています。
しかしこのswaggerに関して、細かい仕様定義ができずに意思疎通ができないという問題がありました。
grpc-ecosystem/grpc-gateway
に内包されているprotoc-gen-openapiv2プラグインを使用して自動生成されるswaggerをカスタマイズすることができました。
日本語記事があまりなく、grpc-gatewayのドキュメントなどにも詳しく載っていないため備忘録として残そうと思います。
発生した問題
デフォルトのswagger自動生成で発生した意思疎通の齟齬では下記のような問題が発生していました。
- 各APIにおいて発生しうるステータスコードがわからない
- リクエストの必須項目やどんなバリデーションがなされるのかがわからない
前提
ディレクトリ構造
- 作業の前下記のようなディレクトリ構造にしています。
- 実務ではもっとファイル数が多いのですがqiitaのために省略しています。
-
proto
: プロジェクトを構成するために用意したprotoファイルを格納しています。 -
proto_generated
: protoファイルから生成されたファイルを格納しています。今回はswaggerだけなのでopenapiディレクトリだけを記載しています。
project
│
├── 色々省略....
├── proto
│ ├── third_party
│ │ ├── google
│ │ │ ├── api
│ │ │ │ ├── annotations.proto
│ │ │ │ ├── field_behavior.proto
│ │ │ │ ├── http.proto
│ │ │ │ └── httpbody.proto
│ │ │ └── protobuf
│ │ │ ├── any.proto
│ │ │ ├── descriptor.proto
│ │ │ └── field_mask.proto
│ │ └── grpc
│ │ └── health
│ │ └── v1
│ │ └── health.proto
│ └── user_service.proto
├── proto_generated
│ └── openapi
│ └── api_definition.swagger.json
protoファイル
- ひとまずわかりやすくするためにuser_service.protoとhealth.protoのみです。
- user_service.protoは下記になります。
user_service.proto
syntax = "proto3";
package qiita.service;
option go_package = "github.com/qiita/proto";
import "third_party/google/api/annotations.proto";
service UserService {
rpc Create(CreateRequest) returns (CreateResponse) {
option (google.api.http) = {
post: "/v1/users/create"
};
}
rpc Read(ReadRequest) returns (ReadResponse) {
option (google.api.http) = {
get: "/v1/users"
};
}
}
message CreateRequest {
string email = 1;
string name = 2;
}
message CreateResponse {
}
message ReadRequest {}
message ReadResponse {
repeated User users = 1;
}
message User {
string user_id = 1;
string email = 2;
string name = 3;
}
生成コマンド
- makefileで実行しています。
PROTO_FILES = $(shell find ./proto -name "*.proto" -type f)
.PHONY: protoc
protoc:
rm -rf ./proto_generated/*
mkdir ./proto_generated/openapi
protoc -I ./proto --openapiv2_out proto_generated/openapi --openapiv2_opt logtostderr=true \
--openapiv2_opt disable_default_errors=true \
--openapiv2_opt allow_merge=true \
--openapiv2_opt merge_file_name="api_definition.yml" ./proto/*.proto $(PROTO_FILES)
生成されるswagger
{
"swagger": "2.0",
"info": {
"title": "user_service.proto",
"version": "version not set"
},
"tags": [
{
"name": "UserService"
},
{
"name": "Health"
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/health": {
"get": {
"operationId": "Health_Check",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1HealthCheckResponse"
}
}
},
"parameters": [
{
"name": "service",
"in": "query",
"required": false,
"type": "string"
}
],
"tags": [
"Health"
]
}
},
"/v1/users": {
"get": {
"operationId": "UserService_Read",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/serviceReadResponse"
}
}
},
"tags": [
"UserService"
]
}
},
"/v1/users/create": {
"post": {
"operationId": "UserService_Create",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/serviceCreateResponse"
}
}
},
"tags": [
"UserService"
]
}
}
},
"definitions": {
"HealthCheckResponseServingStatus": {
"type": "string",
"enum": [
"UNKNOWN",
"SERVING",
"NOT_SERVING",
"SERVICE_UNKNOWN"
],
"default": "UNKNOWN"
},
"serviceCreateResponse": {
"type": "object"
},
"serviceReadResponse": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/serviceUser"
}
}
}
},
"serviceUser": {
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"email": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"v1HealthCheckResponse": {
"type": "object",
"properties": {
"status": {
"$ref": "#/definitions/HealthCheckResponseServingStatus"
}
}
}
}
}
エラーレスポンスがわからない
- protoファイルから生成されたswaggerをみるとエラーレスポンスが定義されていません。
- そのため別開発者が各APIで発生しうるステータスコードを考慮できないという問題がありました。
- そこで冒頭で記載したprotoc-gen-openapiv2プラグインを使用します。
使用方法
protoファイルのDLと配置
-
protoc-gen-openapiv2のoptionsディレクトリから、
annotations.proto
とopenapiv2.proto
をダウンロードします。 - 今回は下記のようなディレクトリになるように配置しました。
├── proto
│ ├── third_party
│ │ ├── google
│ │ │ ├── api
│ │ │ │ ├── annotations.proto
│ │ │ │ ├── field_behavior.proto
│ │ │ │ ├── http.proto
│ │ │ │ └── httpbody.proto
│ │ │ └── protobuf
│ │ │ ├── any.proto
│ │ │ ├── descriptor.proto
│ │ │ └── field_mask.proto
│ │ └── grpc
│ │ ├── health
│ │ │ └── v1
│ │ │ └── health.proto
│ │ └── openapiv2
│ │ └── options
│ │ ├── annotations.proto ※ここ
│ │ └── openapiv2.proto ※ここ
│ └── user_service.proto
├── proto_generated
└── openapi
└── api_definition.swagger.json
- ちなみに配置した
annotations.proto
とopenapiv2.proto
のコメントに使用方法が記載されています。
protoファイルのインポートと変更
- まず全てのAPIで500エラーは返り得るので、全てのAPIに500エラーを追加するところから始めます。
- 実施する作業は下記
- protoファイルに
import "third_party/grpc/openapiv2/options/annotations.proto"
を記述して、先ほどDLしたprotoファイルを読み込めるようにする -
grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger
アノテーションを使用してカスタマイズする
- protoファイルに
実際にカスタマイズしたファイル
user_service.proto
syntax = "proto3";
package qiita.service;
option go_package = "github.com/qiita/proto";
import "third_party/google/api/annotations.proto";
import "third_party/grpc/openapiv2/options/annotations.proto";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
responses: {
key: "500";
value: {
description: "Internal Server Error";
}
}
};
service UserService {
以下略...
これにより、swagger上の全てのAPIに500エラーの定義が追加されることがわかるかと思います。
レスポンスの定義を使用する
- 今はレスポンス自体の定義を行なっていないため、値がdescriptionのみになっています。
- そんな時にはschema機能を使用します。
- 下記のように書き換えることで、レスポンスの定義を使用できるかと思います。
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
responses: {
key: "500";
value: {
description: "サーバーエラー";
schema: {
json_schema: {
ref: ".ErrorResponse";
}
}
}
}
};
message ErrorResponse {
int32 status = 1;
string title = 2;
string detail = 3;
}
exampleがわからない
- 今回は500エラーだけですが、レスポンスの中のエラーコードなどをexampleで表現したい場合もあるかと思います。
- そんな時はexamplesを使用することで解決できます。
- examplesはstringで認識されるため、面倒ではあるのですがJSON構造をstringで構築する必要があります。
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
responses: {
key: "500";
value: {
description: "サーバーエラー"
schema: {
json_schema: {
ref: ".ErrorResponse";
}
}
examples: {
key: "application/json"
value: "{\"status:\": 1, \"title\": \"titleExample\", \"detail\": \"detailExample\"}"
}
}
}
};
API単位でカスタマイズしたい
- 今までの例は全てのAPIに関連するようなカスタマイズ設定でした。
- もちろんですが、API固有の設定をすることもできます。
- その際は
grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation
アノテーションで実現できます。 - 例えば、Createを実行した場合にユーザーの重複により409 Conflictを返却したい場合は下記のように記述します。
service UserService {
rpc Create(CreateRequest) returns (CreateResponse) {
option (google.api.http) = {
post: "/v1/users/create"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "409"
value: {
description: "ユーザの重複時に返却"
schema: {
json_schema: {
ref: ".ErrorResponse";
}
}
examples: {
key: "application/json"
value: "{\"status:\": 1, \"title\": \"titleExample\", \"detail\": \"detailExample\"}"
}
}
}
};
}
rpc Read(ReadRequest) returns (ReadResponse) {
option (google.api.http) = {
get: "/v1/users"
};
}
}
まとめ
- 今回は
openapiv2_swagger
とopenapiv2_operation
に絞って紹介しましたが、openapiv2_schema
,openapiv2_tag
,openapiv2_field
など他にもカスタマイズ機能が用意されています。 - protoファイルからのgoコードの自動生成、swaggerの自動生成は非常に便利だと感じています。
- ただその反面、痒いところに手が届かなかったりということは起きてしまうのかなと感じました。
- このカスタマイズが個人的には少し手間だと感じているので、もう少しいいやり方があればいいなぁと思っています。
参考
- https://techblog.cartaholdings.co.jp/entry/protoc-gen-swagger
- https://rotational.io/blog/documenting-grpc-with-openapi/
さいごに
トレタでは一緒に開発する仲間を募集しています。
興味がある方は是非カジュアル面談へお越しください!