GoでTSVからエラーレスポンス用のコードを自動生成する

この記事は、[Wano Group Advent Calendar 2024](https://qiita.com/advent-calendar/2024/wano-group)の2日目の記事です。
1日目は、@_tachi_さんの、[[Git] gitconfig 活用しよう](https://qiita.com/_tachi_/items/d5e0357ef2eb5e603339)
グループ会社のEDOCODEも[Advent Calendar](https://adventar.org/calendars/10618)をやっていますので、そちらもどうぞ。ちなみに、そっちにも[実際のアクセスに近づけたAPIの負荷テストを行う](https://qiita.com/ktat/items/88be65a00fc10864f173)という記事を本日書いています。

## エラーレスポンス用のコードについて考える

500 エラーとか、400エラーとか、そういうのを返すコードです。基本的には、メッセージを変えるだけでコード自体は同じものになりがちで、コピペしまくる...ということになりがちですね。

また、どういうときに、500エラーがでて、どんなメッセージがでるのか、とか、もしかしたら資料として求められるかもしれません。

であれば、最初から資料として使えるファイル(TSV)をもとにコードを作れば良いんじゃない？と思いました。
で、後で変えたくなったときにも、TSVを変えるだけでコードも変更されるなら、コードとドキュメントに乖離もでないし良いんじゃない？ということでやってみました。

## TSVを用意する

例えばこんな感じです。

```
name    status_code     take_error      take_arg        code    message
Internal        500     1       0       5000    Internal Server Error
BadRequest      400     1       0       4000    Bad Request
InvalidApiKey   401     0       1       4000    Invalid API Key
UnauthorizeddApiKey     401     0       1       4001    Unauthorized API Key
MissingApiKey   400     0       0       4002    X-API-Key is missing in header
```

| 項目 | 意味 |
|-----|-----|
| name        | Errorの名前 |
| status_code | HTTPレスポンスのステータスコード |
| take_error  | errorを引数に取るか |
| take_arg    | その他の引数を取るか |
| code        | エラーレスポンスに含めるコード |
| message     | エラーレスポンスに含めるメッセージ |

## 生成するコード

TSVファイルを読み込んでコードを自動生成するためのコードです。

```go
package main

import (
        "bytes"
        "encoding/csv"
        "log"
        "os"
        "regexp"
        "text/template"

        "github.com/jszwec/csvutil"

)

type Def struct {
        Name          string `csv:"name"`
        TakeError     int    `csv:"take_error"`
        TakeStringArg int    `csv:"take_arg"`
        StatusCode    int    `csv:"status_code"`
        Code          string `csv:"code"`
        Message       string `csv:"message"`
}

func main() {
        tsvPath := os.Args[1]
        goPath := os.Args[2]
        defs, err := parseTsv[Def](tsvPath)
        if err != nil {
                log.Fatalf(err.Error())
        }
        data, err := os.ReadFile(goPath)
        if err != nil {
                log.Fatalf(err.Error())
        }
        content := string(data)

        generated := ""

        for _, def := range *defs {
                templ := `const {{.Name}}StatusCode = {{.StatusCode}}
const {{.Name}}ErrorCode = "{{.Code}}"

func (c *CustomContext) Err{{.Name}}({{if .TakeStringArg}}message string{{end}}{{if .TakeError}}{{if .TakeStringArg}}, {{end}}e error{{end}}) error {
        {{- if .TakeError}}
    warnCalled(e)
        {{- end -}}
        {{- if or .TakeError .TakeStringArg -}}
                {{if and .TakeError .TakeStringArg }}
                        {{- if and (gt .StatusCode 499) (lt .StatusCode 600) }}
        // 適当に変えてください
        if os.Getenv("APP_ENV") != "production" {
                message = message + ", " + e.Error()
        }
                        {{- end -}}
                {{else if .TakeError}}
        message := ": "+e.Error()
        if config.NewAppEnv().IsProduction() {
                message = ""
        }
                {{- else if .TakeStringArg }}
        message = ": "+message
                {{- end}}
                {{- if and (gt .StatusCode 499) (lt .StatusCode 600) }}
                   log.Warn(message)
                {{- end }}
        return c.Status({{.Name}}StatusCode).JSON(NewErrorResponse({{.Name}}ErrorCode, "{{.Message}}"+message))
        {{- else}}
        return c.Status({{.Name}}StatusCode).JSON(NewErrorResponse({{.Name}}ErrorCode, "{{.Message}}"))
        {{- end}}
}

`
                t, err := template.New("goCode").Parse(templ)
                if err != nil {
                        log.Panic(err)
                }
                initialBytes := []byte("")
                buf := bytes.NewBuffer(initialBytes)

                err = t.Execute(buf, def)
                if err != nil {
                        log.Panic(err)
                }
                generated += buf.String()
        }

        re := regexp.MustCompile("(?s)(\n//go:generate go run .+?\n)(.*)$")
        newContent := re.ReplaceAllString(content, "$1"+"\n"+generated+"\n")

        err = os.WriteFile(goPath, []byte(newContent), 0644)
        if err != nil {
                log.Panicf("failed writing file: %s", err)
        }
}

// このコードはutilsというpackageに入っていたもので、これ専用ではありません
func parseTsv[T any](path string) (*[]T, error) {
        f, err := os.Open(path)
        if err != nil {
                return nil, err
        }
        defer f.Close()

        csvReader := csv.NewReader(f)
        csvReader.Comma = '\t'

        dec, err := csvutil.NewDecoder(csvReader)
        if err != nil {
                log.Fatal(err)
        }

        defs := &[]T{}
        if err := dec.Decode(&defs); err != nil {
                return nil, err
        }
        return defs, nil
}
```

## 生成されるコードを含めるファイル

以下のファイル内に、下記を記載しています。
```
//go:generate go run ../../codegen/context-errors/main.go ../../docs/context-errors/errors.tsv ./generated_err.go
```
これで、`go generate`で生成できます。

```go
package context

import (
        "encoding/json"
        "regexp"
        "runtime"

        "github.com/gofiber/fiber/v2/log"
)

type ErrorResponse struct {
        Code    string `json:"code"`
        Message string `json:"message"`
}

// Implement MarshalJSON for ErrorResponse
func (e ErrorResponse) MarshalJSON() ([]byte, error) {
        type Alias ErrorResponse
        return json.Marshal(&struct {
                Alias
        }{
                Alias: (Alias)(e),
        })
}

func NewErrorResponse(code string, message string) ErrorResponse {
        return ErrorResponse{
                Code:    code,
                Message: message,
        }
}

func warnCalled(e error) {
        isErrOutputed := false
        for i := range []int{2, 3, 4, 5} {
                pc, file, line, ok := runtime.Caller(i)
                if ok {
                        // `your-path`は適当に変えてください
                        r := regexp.MustCompile("^.+?your-path/")
                        file = r.ReplaceAllString(file, "your-path/")
                        fn := runtime.FuncForPC(pc)
                        if fn != nil {
                                if i == 2 {
                                        isErrOutputed = true
                                        log.Warnf("%s called from %s in %s at %d", e.Error(), fn.Name(), file, line)
                                } else {
                                        log.Warnf(" called from %s in %s at %d", fn.Name(), file, line)
                                }
                        }
                }
        }
        if !isErrOutputed {
                log.Warn(e.Error())
        }
}

// DON"T EDIT THE FLLOWING LINES

//go:generate go run ../../codegen/context-errors/main.go ../../docs/context-errors/errors.tsv ./generated_err.go
```

## 生成されたコード

以下のようなコードが生成されます。

```go
const InternalStatusCode = 500
const InternalErrorCode = "5000"

func (c *CustomContext) ErrInternal(e error) error {
        warnCalled(e)
        message := ": " + e.Error()
        if config.NewAppEnv().IsProduction() {
                message = ""
        }
        log.Warn(message)
        return c.Status(InternalStatusCode).JSON(NewErrorResponse(InternalErrorCode, "Internal Server Error"+message))
}
```
## 嬉しいところ

最初に書いた理由以外にも、いくつかあります。

### テストに使える

例えば、テストを書いていたとして、(ないと思いますが)`ErrInternal`のHTTPのStatus Codeを変えたくなったとします。

そんな場合、テストコード上に、`500` と書いて比較するのではなく、生成されたコードのconstの`InternalStatusCode`を使えば、定義を変えてもテストは壊れません。

### 決めなきゃいけないことを後回しにできる

こういうコードとかは、後で変えてもそんなに問題がないけど、一旦決めとかないと開発進めにくいというものでもあるかなと思います。決めるものは少ないほうが良いので、一旦適当に決めておいて、後から真面目に考えて変更しても、破綻しないというのも、まぁ、良いんじゃないかなと思っています。

### 自動生成コードを変更すれば一気に変えられる

当たり前ですが、コピペだと面倒ですね。例えば、今回だと、以下のような変更をあと付けで行いました。

1. productionならエラーメッセージをレスポンスに含めない
1. 呼び出し元をログにだす

このような機能を一括で追加したいときにも便利かもしれません(関数化したら良いやんという話もあるかもしれません)。

## 終わり

以上、TSVからエラーレスポンス用のコードを自動生成する方法でした。ただ、一回適当に決めてしまうと、特に変えるモチベーションもわかず、そのまま使っているというオチ...だったりもします。

明日も...自分になってしまいましたが、「[フルスクラッチでプロジェクトを始めるときにやっていること](https://qiita.com/ktat/items/d858346600110244ba69)」になります。