0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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' を選択しました。

  1. Ubuntu Linux 22.04 の環境
  2. Go言語の最新版インストール
  3. oapi-codegen のインストール
  4. node.js 最新版のインストール
  5. redocly のインストール

コード開発の概要

次の作業フローは、OAD 形式で書かれた api.yaml を Webサービスに組み込む流れです。cfg.yamloapi-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サーバーの起動を含むメイン)

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

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

  1. mkdir rest-api-server && cd rest-api-server
  2. main.go を作成、編集する
  3. mkdir db && cd db
  4. db.go を作成、編集する
  5. cd ..
  6. mkdir api && cd api
  7. api.yamlcfg.yaml を作成、編集する
  8. redocly lint api.yaml によって、OpenAPIの構文をチェックする
  9. oapi-codegen -config cfg.yaml api.yaml によって、petshop.gen.go を生成する
  10. impl.go に処理を、実装する
  11. go mod init labo.local/m ソースコードから必要なモジュールのリストを作成する
  12. go mod tidy モジュールをダウンロードする
  13. go run main.go でREST-APIサーバーを動かして、curlでアクセスしてテストする
  14. 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/

生成したHTMLのAPI仕様書のスクリーンショット
スクリーンショット 2025-02-17 065033.png

おわりに

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

参考資料

  1. OpenAPI v3.0
  2. フレームワーク Echo
  3. oapi-codegen
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?