grpc-gatewayでMetadataに詰めたエラーの内容をJSONに詰めてRESTクライアントに返したい

引き続きgRPCの話。

gRPCでエラーをクライアントに返したい場合、通常だとステータスコードとエラーメッセージしか返せない。例えばアプリケーションレベルのバリデーションエラーみたいなものを返したい時、メルカリさんの資料によるとMetadataに詰めて送ると良いらしい、

speakerdeck.com

gRPCがクライアントのときは詰めたMetadataを読み出せばいいんだけど、grpc-gatewayでRESTのクライアントに返す時にはどうしたらよいか調べた。

grpc-gatewayのエラーハンドリングをカスタマイズする

実はGitHubのwikiに How to customize your gateway というのがあって、そこに結構色々と書かれている。

How to customize your gateway · grpc-ecosystem/grpc-gateway Wiki · GitHub

で、Wikiから辿った先にあるブログに実際に結構丁寧にエラーレスポンスのカスタマイズ方法が乗ってるので、それを参考にすればよい。

My Code Smells!

fun run() error {
    runtime.HTTPError = CustomHTTPError
    // 省略
}

type errorBody struct {
    Error string `json:”error"`
    ErrorDetails []ErrorDetail `json:”errorDetails”`
}

type errorDetails struct {
    Field string `json:”field”`
    Message string `json:”message”`
}

// おもにgRPCのMetadataをerrorBodyのような構造に変換し、クライアントに返すためのカスタムハンドラー
func CustomHTTPError(ctx context.Context, _ *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
    const fallback = `{"error": "failed to marshal error message"}`

    w.Header().Set("Content-type", marshaler.ContentType())
    w.WriteHeader(runtime.HTTPStatusFromCode(grpc.Code(err)))

    eb := errorBody{
        Err: grpc.ErrorDesc(err),
    }

    md, _ := runtime.ServerMetadataFromContext(ctx)
    for k, v := range md.TrailerMD {
        eb.ErrorDetails = append(eb.ErrorDetails, errorDetail{
            Field:   strings.TrimSuffix(k, "-bin"), // バイナリで帰ってくる文字列は-binのprefixがつくので、クライアントが扱いやすいよう消す
            Message: v[0],                          // サーバー側では文字列としてしか入れていないが、何故かArrayで入ってくるの最初のものだけ取得する
        })
    }

    jErr := json.NewEncoder(w).Encode(eb)

    if jErr != nil {
        w.Write([]byte(fallback))
    }
}

Go書くの久しぶりすぎてこんな感じでよかったか全然自信ないけど、一応やりたいことは実現できた。