はじめに
OpenAPIのYAMLの定義から、REST-API の Web サービスを作る方法を勉強したメモです。至らない点や間違いなど、ありましたら、コメント欄にお願いします。
OpenAPI Description (OAD) の記述フォーマットで書いた YAML から Go 言語のコードを生成して、Webサーバーのフレームワーク Echo を利用して、REST API サーバーを作ったメモです。
OADは、「OpenAPI イニシアチブ」 が定める OpenAPI Specification (OAS) の記述フォーマットです。
OpenAPI 仕様 (OAS) は、プログラミング言語に依存しない HTTP API 定義します。これにより手早くサービスの機能を理解できるようになります。OpenAPI で適切に定義されると、最小限の実装ロジックで、リモート サービスをアクセスできます。OpenAPI 仕様は、サービスを呼び出す際の推測をなくします。
本投稿記事では、次のシーケンス図の「RESTサーバー」と「DAO」部分を実装します。「RESTサーバー」は、HTTPのリクエストをEchoで受けて、OADのYAMLから生成されたインターフェースに従って実装されたコードで、リクエストを処理します。「DAO」は、本来はデータベースサービスにアクセスするためのコードですが、この記事ではGo言語のmap型で書いたオンメモリの Key Value Store (KVS) です。
RESTサーバーは、HTTPメソッドの GET,POST,PUT,DELETEを受けて、相応の処理をして応答を返します。
環境のセットアップ
コードを開発してテストするために必要なソフトウェア環境と、インストールを説明するリンクを以下に作成しました。パソコン上の仮想サーバーにインストールすると手軽で便利だと思います。
OAD から go 言語のコードを生成するツールに、Swagger
, ReDoc
, Open API Generator
など、有名なツールがありますが、Go 言語のソースを生成する点、Go 言語の Web フレームワークを選択でき、OSS のスポンサー企業として Cybozu 社が参加していることから oapi-codegen
を選びました。また、HTML の API 仕様書の生成ツールは、個人的に一番好きな Web ページを生成する Redoc の 'redocly' を選択しました。
コード開発の概要
次の作業フローは、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
だけを編集します。
次は REST API
仕様書の生成の流れです。生成した redoc-static.html
の ドキュメント root の下へ配置すれば、API を公開できます。
ファイルの配置は、以下のようになります。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サーバーの起動を含むメイン)
具体的なコード開発の手順
作業の手順を以下に列挙します。ファイルは、この後に、掲載しておきます。
mkdir rest-api-server && cd rest-api-server
-
main.go
を作成、編集する mkdir db && cd db
-
db.go
を作成、編集する cd ..
mkdir api && cd api
-
api.yaml
とcfg.yaml
を作成、編集する -
redocly lint api.yaml
によって、OpenAPIの構文をチェックする -
oapi-codegen -config cfg.yaml api.yaml
によって、petshop.gen.go
を生成する -
impl.go
に処理を、実装する -
go mod init labo.local/m
ソースコードから必要なモジュールのリストを作成する -
go mod tidy
モジュールをダウンロードする -
go run main.go
でREST-APIサーバーを動かして、curlでアクセスしてテストする -
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 を繋ぐための大切な宣言と言えます。
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データベースにデータを保存して、データを永続化します。
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を指定しています。
OpenAPI の書き方は、このリンク参照ください。
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
のコードを生成するように設定します。
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リクエストのメソッドに対応するコードを実装していきます。
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リクエストを受けられるようにします。
$ 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
$ 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
$ 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
$ curl -si -H "Authorization: Bearer ${APIKEY}" -X GET localhost:8080/pets/3
{"Id":3,"Species":"Cat","Breed":"Mike"}
更新
Id 3 の Bread が、"Mike" から "Mike-Neco" へ更新
$ 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を指定して削除
$ 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ページを生成する手順です。
# ディレクトリを移動
$ 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/
おわりに
以上が、OpenAPI で API仕様を書いて、Go言語のソースコードを生成、実装の雛形を作成、実行テスト、最後に、HTMLの仕様書を作成するまでの流れでした。