🗂

[OpenAPI] Go言語でAPIファーストな開発をしてみた - oapi-codegen実践編

2024/08/20に公開

対象読者

  • Go言語でのWeb API開発に興味のある方
  • APIファースト開発を学びたい方
  • oapi-codegenを使った具体的な開発手法を知りたい方

はじめに

APIファースト開発とは、APIの設計を起点にアプリケーション開発を進める手法です。APIを最初に定義することで、開発者以外も並行作業が可能になるなどのメリットがあります。
しかし、個人的にはAPIファースト開発の真価はそれだけではないと考えています。API設計に重点を置くことで、システムやアプリケーション間で本当に必要なインターフェースを明確化し、結果としてドメインの整理や設計にも役立つと考えています。

私自身、これまでの開発でAPI設計やドキュメント作成が後回しとなってしまい、結果設計の不備やドキュメントの陳腐化に悩まされることも少なくありませんでした。
API定義のための仕様であるOpenAPI Specification (OAS) と、そのエコシステムを活用することで、これらの問題も解決することができます!

https://www.openapis.org/what-is-openapi

本記事では、OASとGo言語を用いてTODOアプリのWeb APIサーバーを実装し、APIファースト開発のメリットと効率的な開発手法を具体的に紹介します。

この記事が、API設計の重要性と、それを支えるツールの有効性を理解する一助となれば幸いです。

Code Generator: oapi-codegen の紹介

OpenAPI SpecificationからGoのコードを生成するツールはいくつかありますが、今回はoapi-codegen[1]というツールを用いて開発していきます。
他にもOpenAPI Generator[2]ogen[3]といったツールもあります。ぜひ実際に確かめてみてご自身に合ったものを選んでみてください。

早速試してみましょう!

プロジェクトの雛形作成

まずは、最終的なプロジェクト構成を紹介します。

.
├── api
│   ├── config.yml              # oapi-codegen config file
│   ├── generate.go             # oapi-codegen generate script
│   └── openapi.yml             # OpenAPI 3.0.2 spec
├── cmd
│   └── api
│       └── main.go             # API server entry point
├── internal
│   └── api
│       └── server.go           # API server
│   └── service
│       └── todo_service.go     # TODO app buisiness logics
├── pkg
│   └── api.gen.go              # oapi-codegen generated file
└── tools
    └── tools.go                # go:generate directive for oapi-codegen

この構成は、公式で推奨されているtools.goにoapi-codegenを定義し、go generateでコード生成できるように設定されています。
またWeb API用のFWとしてEchoを利用します。

oapi-codegenの導入

まずはapi/config.ymlにoapi-codegenの設定を記述します。

config.yml
# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/main/configuration-schema.json
package: api
generate:
  echo-server: true       # Echo用のコードを生成
  models: true
  embedded-spec: true     # 生成するコードにOpen API specを埋め込むか(後述)
output: ../pkg/api.gen.go # 出力先

あわせてtools/tools.goおよびapi/generate.goも作成します。

tools.go
//go:build tools
// +build tools

package main

import (
	_ "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen"
)
generate.go
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yml ./openapi.yml
package api

APIの設計

準備ができたら早速開発に入りましょう!

APIファースト開発では、まずAPIの設計から始めます。OpenAPI Specificationにそってopenapi.ymlを作成します。

  • スキーマを指定することでテンプレートの補完が可能です。[4]
  • OpenAPIバージョンは 3.0.3 を指定します。[5]
最終的に作成したテンプレートはこちら
openapi.yml
# yaml-language-server: $schema=../openapi-v3.0-draft7-schema.json
# Work around: redhat-developer/yaml-language-serverでOpenAPI v3のSchemaがエラーとなってしまう問題の回避
# Refs: https://github.com/redhat-developer/vscode-yaml/issues/532
openapi: 3.0.3
info:
  title: TODO List API
  description: A simple API to manage TODO items
  version: 1.0.0

servers:
  - url: https://api.example.com/v1

paths:
  /todos:
    get:
      summary: List all TODO items
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Todo'
    post:
      summary: Create a new TODO item
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TodoInput'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'

  /todos/{todoId}:
    get:
      summary: Get a specific TODO item
      parameters:
        - name: todoId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
        '404':
          description: TODO item not found

    put:
      summary: Update a TODO item
      parameters:
        - name: todoId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TodoInput'
      responses:
        '200':
          description: Successful update
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
        '404':
          description: TODO item not found

    delete:
      summary: Delete a TODO item
      parameters:
        - name: todoId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '204':
          description: Successful deletion
        '404':
          description: TODO item not found

components:
  schemas:
    Todo:
      allOf:
        - $ref: '#/components/schemas/TodoInput'
        - type: object
          properties:
            id:
              type: integer
            createdAt:
              type: string
              format: date-time
          required:
            - id
            - createdAt

    TodoInput:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        completed:
          type: boolean
      required:
        - title

今回は1つのEntityのCRUDのみのシンプルなサービスですが、実際のシステムではもっと複雑なドメインを扱うことになると思います。その際、まずAPIにフォーカスすることでシステム・アプリ間で必要なものの整理にきっと役立つはずです。

oapi-codegenの実行

go mod tidy でoapi-codegenを追加し go generate ./... を実行します。うまくいけばpkg/api.gen.goが生成されるはずです。

APIサーバーの実装

api.gen.goが生成されたら、いよいよAPIサーバーの実装です。

internal/service/todo_service.goにTODOアプリの実装を行なっていきます。

todo_service.go
// TODO Service

type TodoService struct {
	todos []api.Todo
}

// Get all TODO items
func (h *TodoService) GetTodos() []api.Todo {
	//...
}

// Create new TODO item
func (h *TodoService) CreateTodo(todo *api.TodoInput) {
//...
}

続いてinternal/api/server.goでoapi-codegenによって定義された ServerInterface を実装します。

server.go
type Server struct {
// Serviceに処理を委譲
	service *service.TodoService
}
// インターフェースが実装されているかコンパイル時に検知する
var _ api.ServerInterface = &Server{}

// GetTodos implements api.ServerInterface.
func (s *Server) GetTodos(ctx echo.Context) error {
//...
}

// PostTodos implements api.ServerInterface.
func (s *Server) PostTodos(ctx echo.Context) error {
//...
}

そして最後にcmd/api/main.goにアプリエンドポイントを定義します。

main.go
package main

import (
	//...
	"github.com/kama-meshi/api-first-example-go/internal/api"
	pkg "github.com/kama-meshi/api-first-example-go/pkg"
	"github.com/labstack/echo/v4"
	echomiddleware "github.com/labstack/echo/v4/middleware"
	middleware "github.com/oapi-codegen/echo-middleware"
)

func main() {
	// OASテンプレートの読み込み
	swagger, err := pkg.GetSwagger()
	if err != nil {
		log.Fatalf("Error loading OAS template: %s", err)
	}
	// ホスト名での検証は行わない
	swagger.Servers = nil

	server := api.NewServer()

	e := echo.New()
	e.Use(echomiddleware.Logger())
	// OASテンプレートで指定したスキーマによる検証を行う
	e.Use(middleware.OapiRequestValidator(swagger))
	pkg.RegisterHandlers(e, server)
	e.Logger.Fatal(e.Start(net.JoinHostPort("0.0.0.0", "8080")))
}

これで実装も完了です!Web APIサーバを実行してみましょう!

$ go run cmd/api/main.go
$ curl -H 'Content-Type: application/json' http://localhost:8080/todos -d '{"title": "Cooking", "completed": false}'
$ curl 'http://localhost:8080/todos' | jq .
[
  {
    "completed": false,
    "createdAt": "2024-08-17T14:43:07.77163+09:00",
    "id": 1,
    "title": "Cooking"
  }
]

APIの更新

ここでAPIに更新が出たケースを確かめてみましょう。TODOの一覧取得時に取得件数を指定できるようにします。

まずはAPIの設計を行います。openapi.ymlを更新し、クエリパラメータ limit を定義してみます。

openapi.yml
paths:
  /todos:
    get:
      # --- 以下を追加 ---
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100

APIの定義を行ったら、go generateで再びコードを生成します。するとinternal/api/server.goでビルドエラーが生じるはずです。

$ go generate
$ go build ./...
# github.com/kama-meshi/api-first-example-go/internal/api
internal/api/server.go:13:29: cannot use &Server{} (value of type *Server) as "github.com/kama-meshi/api-first-example-go/pkg".ServerInterface value in variable declaration: *Server does not implement "github.com/kama-meshi/api-first-example-go/pkg".ServerInterface (wrong type for method GetTodos)
                have GetTodos(echo.Context) error
                want GetTodos(echo.Context, "github.com/kama-meshi/api-first-example-go/pkg".GetTodosParams) error

GetTodos に引数が不足しているようです。エラーに出力されている通り、引数を追加します。

server.go
func (s *Server) GetTodos(ctx echo.Context, params api.GetTodosParams) error {
	options := make([]service.Option, 0)
	if params.Limit != nil {
		options = append(options, service.WithLimit(uint(*params.Limit)))
	}
	todos := s.service.GetTodos(options...)
	ctx.JSON(200, todos)
	return nil
}
todo_service.go
type Option func([]api.Todo) []api.Todo

func WithLimit(size uint) Option {
	return func(todos []api.Todo) []api.Todo {
		if size == 0 {
			return make([]api.Todo, 0)
		}
		if len(todos) < int(size) {
			return todos
		}
		return todos[:size]
	}
}

// Get all TODO items
func (h *TodoService) GetTodos(options ...Option) []api.Todo {
	todos := h.todos
	for _, opt := range options {
		todos = opt(todos)
	}
	return todos
}

これで無事実装できました!OASテンプレートによるバリデーションも効いているので閾値外の範囲を指定するとエラーレスポンスが返されます。

$ curl -H 'Content-Type: application/json' http://localhost:8080/todos -d '{"title": "Runnig", "completed": false}'
$ curl 'http://localhost:8080/todos&limit=1' | jq .
[
  {
    "completed": false,
    "createdAt": "2024-08-17T14:43:07.77163+09:00",
    "id": 1,
    "title": "Cooking"
  }
]
$ curl 'http://localhost:8080/todos?limit=0' | jq .
{
  "message": "parameter \"limit\" in query has an error: number must be at least 1"
}

おわりに

今回はGo言語とoapi-codegenを用いたAPIファースト開発の一連の流れを、TODOアプリのWeb APIサーバー実装を通して紹介しました。
今回開発したコードはGitHubにあがっています。よろしければ参考にしてみてください。

https://github.com/kama-meshi/api-first-example-go

APIファースト開発は、変化の激しい現代において、柔軟性と拡張性の高いシステムを構築するための強力な手法です。

ぜひ、この機会にAPIファースト開発を体験してみてください!

Happy Development!!

脚注
  1. oapi-codegenは標準で多様なFWに対応しているのが、今回採用したポイントとなります。Echoを利用予定ですが、他ツールを使用する場合は独自でHandlerをWrapするなど工夫が必要になります。 ↩︎

  2. OpenAPI Generatorは多言語対応かつクライアント/サーバの両コード・テスト・ドキュメントまで多様な生成ができるのが強みです。 ↩︎

  3. ogenは後発ということもあり洗練された印象があります。GeneratorというよりもFWに近いように思います。OpenTelemetryにもデフォルトで対応しているようです。 ↩︎

  4. OASではSchemaが公開されておりそちらを参照することで補完などが可能です。ただし今回利用するv3.0.3ではVSCodeやNeovimのLSPなどに利用されているredhat-developer/yaml-language-serverの問題でOASのスキーマが不正と見做されてしまうため自前のスキーマファイルを用意するというワークアラウンドをとっています。 ↩︎

  5. oapi-codegenは現在v3.0.3まで対応しています。記事執筆現在ではv3.1以降は対応していないため注意が必要です。OpenAPI 3.1 support? · Issue #373 · oapi-codegen/oapi-codegen ↩︎

Discussion