goaでPayloadの文字列がログに出ないようにする

iOS/Androidアプリで使うAPIをgoaで作っていて、Payloadの中身をログに出したくない、という案件が出てきたので色々調べた。

## 参考になった情報
* [続・GoでパスワードなどをPrintfで出力させたくない - kawaken&#39;s blog](http://kawaken.hateblo.jp/entry/2016/11/16/192841)  
stringのエイリアスを定義した上で、`fmt.Stringer`, `fmt.GoStringer`インターフェースを実装することでフォーマットされないようにする。  
基本的にはこれで良い。  
ただ、goaが自動で出力するログは、一度JSONをパースして構造体に値を入れた後に`json.Marshal`で文字列にしている模様。  
https://github.com/goadesign/goa/blob/90bd33edff1a4f17fab06d1f9e14e659075d1328/middleware/log_request.go#L92
* [Keep passwords and secrets out of your logs with Go](https://hackernoon.com/keep-passwords-and-secrets-out-of-your-logs-with-go-a2294a9546ce)  
タイトルはやりたいことそのまま。  
対象の構造体で`json.Marshaler`を実装し、`json.Marshal`の結果を置き換える、というもの。  
Payload用の構造体はgoagenで自動生成されるので、そのまま使うことはできないが、↑と組み合わせれば良い感じになりそう。

## 作ったやつ
https://github.com/75py/secretstr

## やったこと
参考URLの2つを組み合わせた。
1. `string`のエイリアス`SecretString`を定義する
2. `fmt.Stringer`, `fmt.GoStringer`インターフェースを実装する
3. `json.Marshaler`インターフェースを実装する
4. `string`に戻すための関数を定義する

※最後のやつは必須ではない。単に`string(foo)`で戻せるが、「生の文字列を使っているのがどこか？」を簡単に把握できるようにするためには必要かなと。

最小限のコードは以下の通り。

```go
type SecretString string

func (ss SecretString) String() string {
	return "[FILTERED]"
}

func (ss SecretString) GoString() string {
	return ss.String()
}

func (ss SecretString) MarshalJSON() ([]byte, error) {
	return json.Marshal(ss.String())
}

func (ss SecretString) RawString() string {
	return string(ss)
}
```

GitHubに置いたコードは、もう少し色気を出して色々書いている。
[secret_string.go](https://github.com/75py/secretstr/blob/master/secret_string.go)

## 使い方
基本的には、`string`で定義するところを`secretstr.SecretString`に置き換えるだけで使える。READMEのサンプルコードを参照。
これにより、
`fmt.Println(パスワードをメンバに含む構造体)`
のようなデバッグコードが仮に残っていたとしても、ログに生のパスワードが出力されることはなくなる。

ただ、goagenで自動生成されるコードについては一工夫必要。

## goagenで自動生成されるPayloadでの使い方

例えば以下のようにリクエストを投げた場合。

```bash
curl -X POST http://localhost:8080/auth/login -d '{"id":"raw_id","password":"raw_password"}' -H "Content-Type: application/json"
```

### Before

通常の定義だと、型はただの`string`なので、当然ログにID/PWが出る。

```go:design/design.go
Payload(func() {
	Param("id", String, "Login ID")
	Param("password", String, "Login password")
})
```
```
2019/05/05 21:30:13 [INFO] started req_id=tBkbeBBZ60-1 POST=/auth/login from=::1 ctrl=LoginController action=login
2019/05/05 21:30:13 [INFO] headers req_id=tBkbeBBZ60-1 Accept=*/* Content-Length=41 Content-Type=application/json User-Agent=curl/7.54.0
2019/05/05 21:30:13 [INFO] payload req_id=tBkbeBBZ60-1 raw={"id":"raw_id","password":"raw_password"}
2019/05/05 21:30:13 [INFO] completed req_id=tBkbeBBZ60-1 status=0 bytes=0 time=143.578µs ctrl=LoginController action=login
```

### After
ドキュメントを眺めてみると、`Metadata`を定義すれば型を変更できるとのこと。
https://godoc.org/github.com/goadesign/goa/design/apidsl#Metadata

```go:design/design.go
Payload(func() {
	Param("id", func() {
		Description("Login ID")
		Metadata("struct:field:type", "secretstr.SecretString", "github.com/75py/secretstr")
	})
	Param("password", func() {
		Description("Login password")
		Metadata("struct:field:type", "secretstr.SecretString", "github.com/75py/secretstr")
	})
})
```
```go:app/contexts.go（自動生成）
// LoginLoginPayload is the login login action payload.
type LoginLoginPayload struct {
	// Login ID
	ID *secretstr.SecretString `form:"id,omitempty" json:"id,omitempty" yaml:"id,omitempty" xml:"id,omitempty"`
	// Login password
	Password *secretstr.SecretString `form:"password,omitempty" json:"password,omitempty" yaml:"password,omitempty" xml:"password,omitempty"`
}
```

ログは以下のようになる。

```
2019/05/05 21:31:15 [INFO] started req_id=tBkbeBBZ60-2 POST=/auth/login/secretstr from=::1 ctrl=LoginController action=login_secretstr
2019/05/05 21:31:15 [INFO] headers req_id=tBkbeBBZ60-2 Accept=*/* Content-Length=41 Content-Type=application/json User-Agent=curl/7.54.0
2019/05/05 21:31:15 [INFO] payload req_id=tBkbeBBZ60-2 raw={"id":"[FILTERED]","password":"[FILTERED]"}
2019/05/05 21:31:15 [INFO] completed req_id=tBkbeBBZ60-2 status=0 bytes=0 time=80.848µs ctrl=LoginController action=login_secretstr
```

### 少し困っているところ

この方法だと、MaxLengthなどのバリデーションが使えなくなる。

```go:app/contexts.go（自動生成）
	if utf8.RuneCountInString(payload.ID) > 5 { // cannot use *payload.ID (type secretstr.SecretString) as type string in argument to utf8.RuneCountInString
		err = goa.MergeErrors(err, goa.InvalidLengthError(`raw.id`, payload.ID, utf8.RuneCountInString(payload.ID), 5, false))
	}
```

このくらいなら自分で実装すれば良いので大した手間ではないけど、もっと良い方法があればぜひ教えてください。