OpenAPIのYAMLから、Go言語のWebサービスを作ったメモ

## はじめに 

OpenAPIのYAMLの定義から、REST-API の Web サービスを作る方法を勉強したメモです。至らない点や間違いなど、ありましたら、コメント欄にお願いします。

[OpenAPI Description (OAD)](https://spec.openapis.org/oas/latest.html#conformance) の記述フォーマットで書いた YAML から Go 言語のコードを生成して、Webサーバーのフレームワーク [Echo](https://echo.labstack.com/) を利用して、[REST API](https://www.redhat.com/ja/topics/api/what-is-a-rest-api) サーバーを作ったメモです。

OADは、「[OpenAPI イニシアチブ](https://www.openapis.org/)」 が定める　[OpenAPI Specification (OAS)](https://spec.openapis.org/oas/latest.html) の記述フォーマットです。

OpenAPI 仕様 (OAS) は、プログラミング言語に依存しない HTTP API 定義します。これにより手早くサービスの機能を理解できるようになります。OpenAPI で適切に定義されると、最小限の実装ロジックで、リモート サービスをアクセスできます。OpenAPI 仕様は、サービスを呼び出す際の推測をなくします。

本投稿記事では、次のシーケンス図の「RESTサーバー」と「DAO」部分を実装します。「RESTサーバー」は、HTTPのリクエストを[Echo](https://echo.labstack.com/)で受けて、OADのYAMLから生成されたインターフェースに従って実装されたコードで、リクエストを処理します。「DAO」は、本来はデータベースサービスにアクセスするためのコードですが、この記事ではGo言語のmap型で書いたオンメモリの Key Value Store (KVS) です。

RESTサーバーは、HTTPメソッドの GET,POST,PUT,DELETEを受けて、相応の処理をして応答を返します。

```mermaid
sequenceDiagram
participant クライアント
participant RESTサーバー
participant DAO
Note right of RESTサーバー: クライアントはcurl<br/>RESTサーバーはEcho<br/>DAOはGo言語のMap型
クライアント->>RESTサーバー: HTTPによる要求<br/>GET<br/>POST<br/>PUT<br/>DELETE
activate RESTサーバー
RESTサーバー->>DAO: キーを指定してCRUD
activate DAO
DAO-->>RESTサーバー: 結果
deactivate DAO
RESTサーバー-->>クライアント: 結果
deactivate RESTサーバー
```

## 環境のセットアップ
コードを開発してテストするために必要なソフトウェア環境と、インストールを説明するリンクを以下に作成しました。パソコン上の仮想サーバーにインストールすると手軽で便利だと思います。

OAD から go 言語のコードを生成するツールに、[`Swagger`](https://swagger.io/), [`ReDoc`](https://github.com/Redocly/redoc), [`Open API Generator`](https://github.com/OpenAPITools/openapi-generator)など、有名なツールがありますが、Go 言語のソースを生成する点、Go 言語の Web フレームワークを選択でき、OSS のスポンサー企業として Cybozu 社が参加していることから [`oapi-codegen`][] を選びました。また、HTML の API 仕様書の生成ツールは、個人的に一番好きな Web ページを生成する Redoc の 'redocly' を選択しました。

1. [Ubuntu Linux 22.04 の環境](https://kb.seeck.jp/archives/19354)
1. [Go言語の最新版インストール](https://go.dev/doc/install)
1. [oapi-codegen のインストール](https://github.com/oapi-codegen/oapi-codegen)
1. [node.js 最新版のインストール](https://nodejs.org/en/download)
1. [redocly のインストール](https://redocly.com/docs/cli/installation)


## コード開発の概要

次の作業フローは、OAD 形式で書かれた `api.yaml` を Webサービスに組み込む流れです。`cfg.yaml` は  [`oapi-codegen`][] の設定ファイルで、出力するファイル名とWebサービスのフレームワークの指定などが記述してあります。これらを[`oapi-codegen`][]のインプットにして、`petshop.gen.go`を出力します。このファイルは REST-API の PATH をアクセスした時に、コールバックされる関数のインターフェースが書かれていますので、これを手掛かりに、`impl.go` を作成します。コードの追加開発やメンテナンスを実施する際は、[`oapi-codegen`][] により `petshop.gen.go` を再生成して、`impl.go` だけを編集します。

```mermaid
flowchart LR
 api.yaml & cfg.yaml --> oapi-codegenによりGo言語ド生成 --> petshop.gen.go --> Echo用に実装
```

次は `REST API` 仕様書の生成の流れです。生成した `redoc-static.html` の ドキュメント root の下へ配置すれば、API を公開できます。

```mermaid
flowchart LR
 api.yaml --> redocly --> HTMLのAPI仕様書(redoc-static.html) --> Webサーバーのドキュメントとして配置
```

ファイルの配置は、以下のようになります。`rest-api-server` 直下の `main.go` で、Echoサーバーをインスタンス化、OpenAPIで生成させた関数の登録などを実施します。ディレクトリ api は　OADの定義、生成コード、実装コードなどを集めておきます。ディレクトリ db は、データベースサービスへアクセスするためのコードを集めておきます。

```
rest-api-server
├── README.md
├── api
│   ├── api.yaml (OpenAPIの構文で書かれた仕様書)
│   ├── cfg.yaml (oapi-codegen のためのコンフィグ)
│   ├── impl.go  (開発するコード)
│   ├── petshop.gen.go (oapi-codegenにより生成されたコード、これは編集しない)
│   └── redoc-static.html (redoclyにより生成されたHTMLのAPI仕様書)
├── db
│   └── db.go (map型のデータ、本格的ならDBのDAOに置き換えるのかな)
├── go.mod
├── go.sum
└── main.go (Echo HTTPサーバーの起動を含むメイン)
```


## 具体的なコード開発の手順

作業の手順を以下に列挙します。ファイルは、この後に、掲載しておきます。

1. `mkdir rest-api-server && cd rest-api-server`
1. `main.go` を作成、編集する
1. `mkdir db && cd db`
1. `db.go` を作成、編集する
1. `cd ..` 
1. `mkdir api && cd api`
1. `api.yaml` と `cfg.yaml` を作成、編集する
1. `redocly lint api.yaml` によって、OpenAPIの構文をチェックする 
1. `oapi-codegen -config cfg.yaml api.yaml` によって、`petshop.gen.go` を生成する
1. `impl.go` に処理を、実装する
1. `go mod init labo.local/m` ソースコードから必要なモジュールのリストを作成する
1. `go mod tidy` モジュールをダウンロードする
1. `go run main.go` でREST-APIサーバーを動かして、curlでアクセスしてテストする 
1. `redocly build-docs api.yaml` によって、`api.yaml` から REST-APIの仕様書 `redoc-static.html` を生成する


## main.go の内容
main.goの主要な文に、コメントを付与しましので、内容を理解できると思います。
「APIキーのバリデーター」は、クライアントのリクエストのHTTPヘッダーにセットされたAPIキーが正しい事を確認します。今回は、OpenAPIのYAMLファイルから、Go言語のRESTサービスを構築が主題なので、固定のAPIキーと比較するようにしました。APIキーの有効期限、無効化、ユーザーIDとの対応づけなど、動的管理管理することで、APIのセキュリティを高めることができます。main関数の中にある `e.Use(middleware.KeyAuth(apikey_validator))` の宣言によって、すべてのHTTPリクエストの APIキーをチェックするようになります。Echoフレームワークの便利な特徴といえます。

「初期データのセット」は、テスト用データを2件だけセットします。これで、データをPOSTする前に、GETメソッドの動作を確認できます。

`api.RegisterHandlers(e, server)`の宣言によって、`oapi-codegen`で生成された`petshop.gen.go`を介して、`impl.go`のHTTPメソッドに対応するGo言語の関数がコールされます。OpenAPI と Echo を繋ぐための大切な宣言と言えます。

```go
package main

import (
	"log"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"

	"labo.local/m/api" // OpenAPI定義、生成コードと実装コード
	"labo.local/m/db"  // データの保存と検索などを担うコード
)

// APIキーのバリデーター
func apikey_validator(key string, c echo.Context) (bool, error) {
	return key == "9183aa94-eb65-11ef-8d82-0fbf465959ad", nil
}

func main() {
	// 初期データのセット
	db.SetInitialData()

	// Echoサーバーの起動準備
	server := api.NewServer()
	e := echo.New()

	// リクエストのHTTPヘッダーのAPIキーの存在をチェック
	e.Use(middleware.KeyAuth(apikey_validator))

	// OpenAPIの定義から生成された実装を組み込み
	api.RegisterHandlers(e, server)

	// Webサーバーのスタート
	log.Fatal(e.Start("0.0.0.0:8080"))
}
```

## db.go の内容
これは DAO の部分で、Go 言語の MAP型を使って、Key Value Store (KVS) を作っています。メモリ上に保持するだけなので、プロセスを止めると、保存したデータは喪失します。本格的なプリケーションであれば、永続ストレージにデータを保存、または、EtcdなどKVSデータベースにデータを保存して、データを永続化します。

```go
package db

import "errors"

type Pet struct {
	Id      int64
	Species string
	Breed   string
}

var pets = make(map[int64]Pet)

func SetInitialData() {
	pets[1] = Pet{Id: 1, Species: "Dog", Breed: "Dachshund"}
	pets[2] = Pet{Id: 2, Species: "Dog", Breed: "Toy poodle"}
}

func ListData() []Pet {
	var ret []Pet
	for k, v := range pets {
		v.Id = int64(k)
		ret = append(ret, v)
	}
	return ret
}

func AppendData(id int64, pet Pet) error {
	pets[id] = pet
	return nil
}

func DeleteData(id int64) error {
	_, exists := pets[id]
	if exists {
		delete(pets, id)
		return nil
	}
	return errors.New("not found")
}

func UpdateData(id int64, pet Pet) error {
	_, exists := pets[id]
	if exists {
		pets[id] = pet
		return nil
	}
	return errors.New("not found")
}

func FindById(id int64) (Pet, error) {
	_, exists := pets[id]
	if exists {
		return pets[id], nil
	}
	return Pet{}, errors.New("not found")
}
```

## api/api.yaml と api/cfg.yaml
`api.yaml`は、[oapi-codegen][]がサポートする [OpenAPI v3.0.4](https://spec.openapis.org/oas/v3.0.4.html)を指定しています。
OpenAPI の書き方は、この[リンク](https://learn.openapis.org/specification/)参照ください。

```yaml
openapi: 3.0.4
info:
  version: 1.0.0
  title: ペットストア 
  license:
    name: MIT
    url: https://github.com/takara9/marmot/blob/main/LICENSE
servers:
  - url: /v1
security:
  - defaultApiKey: []
paths:
  /pets:
    get:
      summary: すべてのペットをリスト
      operationId: listPets
      tags:
        - petshop
      parameters:
        - name: limit
          in: query
          description: 応答の最大レコード数を制限、最大値に100件を設定
          required: false
          schema:
            type: integer
            maximum: 100
            format: int32
      responses:
        '200':
          description: 1ページに表示されるペット配列
          headers:
            x-next:
              description: 応答の次のページへのリンク
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pets'
        '400':
          description: トークンがセットされないなどの時に返すエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        default:
          description: エラーコードとメッセージを参照のこと
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      summary: ペットの登録、配列で複数のペットを登録可能
      operationId: createPets
      tags:
        - petshop
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
        required: true
      responses:
        '201':
          description: 登録に成功すると、HTTP 201, ボディは空で応答します。
        '400':
          description: トークンがセットされないなどの時に返すエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        default:
          description: エラーコードとメッセージを参照のこと
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /pets/{petId}:
    get:
      summary: ペットIDから個別にペット情報を返す
      operationId: showPetById
      tags:
        - petshop
      parameters:
        - name: petId
          in: path
          required: true
          description: ペット情報を取り出すためのID
          schema:
            type: string
      responses:
        '200':
          description: IDで指定されたペットの情報
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        '400':
          description: トークンがセットされないなどの時に返すエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        default:
          description: エラーコードとメッセージを参照のこと
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      summary: IDを指定してペット情報を削除
      operationId: deletePetById
      tags:
        - petshop
      parameters:
        - name: petId
          in: path
          required: true
          description: ペット情報を取り出すためのID
          schema:
            type: string
      responses:
        '202':
          description: 削除が成功するとボディ無しで応答
        '400':
          description: トークンがセットされないなどの時に返すエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        default:
          description: エラーコードとメッセージを参照のこと
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    put:
      summary: ペットIDで特定した情報を更新
      operationId: updatePetById
      tags:
        - petshop
      parameters:
        - name: petId
          in: path
          required: true
          description: ペット情報を特定するためのID
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
        required: true
      responses:
        '202':
          description: 更新が成功するとボディ無しで応答
        '400':
          description: トークンがセットされないなどの時に返すエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        default:
          description: ペット情報を特定するためのID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  securitySchemes:
    defaultApiKey:
      description: API キー
      type: apiKey
      name: api-key
      in: header
  schemas:
    Pet:
      type: object
      required:
        - id
        - species
        - breed
      properties:
        id:
          type: integer
          format: int64
        species:
          type: string
        breed:
          type: string
    Pets:
      type: array
      maxItems: 100
      items:
        $ref: '#/components/schemas/Pet'
    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: integer
          format: int32
        message:
          type: string
```
`cfg.yaml`は、`oapi-codegen` の動作を変えます。ここでは、出力するファイル名の指定、`echo-server` のコードを生成するように設定します。

```yaml
package: api
output: petshop.gen.go
generate:
  models: true
  echo-server: true
```

## api/impl.go
直接 `petshop.gen.go` を編集することを避け、`oapi-codegen` で生成されたコードと、`echo-server` のコードを橋渡しして、連携させるために重要なコードが `impl.go` です。このコードの中に、HTTPリクエストのメソッドに対応するコードを実装していきます。 

```go
package api

import (
	"net/http"
	"strconv"

	"github.com/labstack/echo/v4"

	"labo.local/m/db"
)

type Server struct{}

func NewServer() Server {
	return Server{}
}

// List all pets
// (GET /pets)
func (Server) ListPets(ctx echo.Context, params ListPetsParams) error {
	resp := db.ListData()
	return ctx.JSON(http.StatusOK, resp)
}

// Create a pet
// (POST /pets)
func (Server) CreatePets(ctx echo.Context) error {
	pets := new(Pets)
	err := ctx.Bind(pets)
	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    1,
				Message: err.Error(),
			},
		)
	}
	
	for _, v := range *pets {
		var pet db.Pet
		pet.Id = v.Id
		pet.Species = v.Species
		pet.Breed = v.Breed
		err = db.AppendData(pet.Id, pet)
		if err != nil {
			break
		}
	}

	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    2,
				Message: err.Error(),
			},
		)
	}

	return ctx.NoContent(http.StatusCreated)
}

// Info for a specific pet
// (GET /pets/{petId})
func (Server) ShowPetById(ctx echo.Context, petId string) error {
	id, err := strconv.ParseInt(petId, 10, 64)
	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    1,
				Message: err.Error(),
			},
		)
	}
	resp, err := db.FindById(id)
	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    2,
				Message: err.Error(),
			},
		)
	}
	return ctx.JSON(http.StatusOK, resp)
}

// Delete a specific pet
// (DELETE /pets/{petId})
func (Server) DeletePetById(ctx echo.Context, petId string) error {
	id, err := strconv.ParseInt(petId, 10, 64)
	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    1,
				Message: err.Error(),
			},
		)
	}
	err = db.DeleteData(id)
	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    2,
				Message: err.Error(),
			},
		)
	}
	return ctx.NoContent(http.StatusAccepted)
}

// Update a specific pet
// (PUT /pets/{petId})
func (Server) UpdatePetById(ctx echo.Context, petId string) error {
	id, err := strconv.ParseInt(petId, 10, 64)
	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    1,
				Message: err.Error(),
			},
		)
	}

	pet := new(Pet)
	err = ctx.Bind(pet)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	if pet.Id != id {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    3,
				Message: "not match id",
			},
		)
	}

	var p db.Pet
	p.Id = pet.Id
	p.Species = pet.Species
	p.Breed = pet.Breed
	err = db.UpdateData(id, p)
	if err != nil {
		return ctx.JSON(http.StatusInternalServerError, 
			Error{
				Code:    2,
				Message: err.Error(),
			},
		)
	}
	return ctx.NoContent(http.StatusAccepted)
}
```

## main.goの実行
ここまでで、必要なコードの準備が出来たので、`main.go`を実行して、HTTPリクエストを受けられるようにします。

```shell
$ ls
README.md  api  db  main.go

$ go mod init labo.local/m
go: creating new go.mod: module labo.local/m
go: to add module requirements and sums:
        go mod tidy

$ go mod tidy
go: finding module for package github.com/labstack/echo/v4
go: finding module for package github.com/oapi-codegen/runtime
go: finding module for package github.com/labstack/echo/v4/middleware
go: found github.com/labstack/echo/v4 in github.com/labstack/echo/v4 v4.13.3
go: found github.com/labstack/echo/v4/middleware in github.com/labstack/echo/v4 v4.13.3
go: found github.com/oapi-codegen/runtime in github.com/oapi-codegen/runtime v1.1.1

$ go run main.go 

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.13.3
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8080
```

## Curlコマンドを使ったアクセステスト

### リスト表示

トークンが無い場合は、HTTP Status 400と以下のエラーボディが返される。
```
$ curl -si  -X GET localhost:8080/pets
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Sat, 15 Feb 2025 06:36:15 GMT
Content-Length: 44

{"message":"missing key in request header"}
```

テストのための固定トークンをセットすると、データが付与された応答が返される。
```
$ APIKEY=9183aa94-eb65-11ef-8d82-0fbf465959ad
$ curl -si -H "Authorization: Bearer ${APIKEY}" -X GET localhost:8080/pets
HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 15 Feb 2025 06:37:54 GMT
Content-Length: 93

[{"Id":1,"Species":"Dog","Breed":"Dachshund"},{"Id":2,"Species":"Dog","Breed":"Toy poodle"}]
```


### データ追加
データの追加 POST

```shell
$ curl -si -X POST -H "Content-Type: application/json" -H "Authorization: Bearer ${APIKEY}" -d '[{"Id":3,"Species":"Cat","Breed":"Mike"},{"Id":4,"Species":"Cat","Breed":"American short hair"}]' 
localhost:8080/pets
HTTP/1.1 201 Created
Date: Sat, 15 Feb 2025 06:39:48 GMT
Content-Length: 0
```

データのリストを確認 GET

```shell
$ curl -si -H "Authorization: Bearer ${APIKEY}" -X GET localhost:8080/pets
HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 15 Feb 2025 06:40:50 GMT
Content-Length: 188

[{"Id":1,"Species":"Dog","Breed":"Dachshund"},{"Id":2,"Species":"Dog","Breed":"Toy poodle"},{"Id":3,"Species":"Cat","Breed":"Mike"},{"Id":4,"Species":"Cat","Breed":"American short hair"}]
```


### IDを指定してのデータの取り出し
URLにIDを付与してGET

```shell
$ curl -si -H "Authorization: Bearer ${APIKEY}" -X GET localhost:8080/pets/3
{"Id":3,"Species":"Cat","Breed":"Mike"}
```


### 更新
Id 3 の Bread が、"Mike" から "Mike-Neco" へ更新

```shell
$ curl -si -X PUT -H "Authorization: Bearer ${APIKEY}" -H "Content-Type: application/json" -d '{"Id":3,"Species":"Cat","Breed":"Mike-Neco"}' localhost:8080/pets/3
HTTP/1.1 202 Accepted
Date: Sat, 15 Feb 2025 06:42:53 GMT
Content-Length: 0

$ curl -si -H "Authorization: Bearer ${APIKEY}" -X GET localhost:8080/pets/3
{"Id":3,"Species":"Cat","Breed":"Mike-Neco"}
```


### 削除
IDを指定して削除

```shell
$ curl -si -H "Authorization: Bearer ${APIKEY}" -X DELETE localhost:8080/pets/4
HTTP/1.1 202 Accepted
Date: Sat, 15 Feb 2025 06:44:17 GMT
Content-Length: 0

$ curl -si -H "Authorization: Bearer ${APIKEY}" -X GET localhost:8080/pets
HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 15 Feb 2025 06:44:26 GMT
Content-Length: 138

[{"Id":2,"Species":"Dog","Breed":"Toy poodle"},{"Id":3,"Species":"Cat","Breed":"Mike-Neco"},{"Id":1,"Species":"Dog","Breed":"Dachshund"}]
```

## HTML仕様書の作成
OpenAPI仕様書の `api.yaml` から カッコいいHTMLページを生成する手順です。 

```shell
# ディレクトリを移動
$ cd api
$ api$ ls
api.yaml  cfg.yaml  impl.go  petshop.gen.go

# HTMLページを生成
$ redocly build-docs api.yaml
Found undefined and using theme.openapi options
Prerendering docs

🎉 bundled successfully in: redoc-static.html (83 KiB) [⏱ 8ms].

# 公開用ディレクトリにコピー
$ cp redoc-static.html /exports/homepage/
```

生成したHTMLのAPI仕様書のスクリーンショット
![スクリーンショット 2025-02-17 065033.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42829/ae090cc6-5ec4-4aa9-a35a-5aacc1ce0daf.png)


## おわりに
以上が、OpenAPI で API仕様を書いて、Go言語のソースコードを生成、実装の雛形を作成、実行テスト、最後に、HTMLの仕様書を作成するまでの流れでした。


## 参考資料
1. [OpenAPI v3.0](https://learn.openapis.org/examples/)
2. [フレームワーク Echo](https://echo.labstack.com/)
3. [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen)


[`oapi-codegen`]: https://github.com/oapi-codegen/oapi-codegen