[OpenAPI] Go言語でAPIファーストな開発をしてみた - oapi-codegen実践編
対象読者
- Go言語でのWeb API開発に興味のある方
- APIファースト開発を学びたい方
- oapi-codegenを使った具体的な開発手法を知りたい方
はじめに
APIファースト開発とは、APIの設計を起点にアプリケーション開発を進める手法です。APIを最初に定義することで、開発者以外も並行作業が可能になるなどのメリットがあります。
しかし、個人的にはAPIファースト開発の真価はそれだけではないと考えています。API設計に重点を置くことで、システムやアプリケーション間で本当に必要なインターフェースを明確化し、結果としてドメインの整理や設計にも役立つと考えています。
私自身、これまでの開発でAPI設計やドキュメント作成が後回しとなってしまい、結果設計の不備やドキュメントの陳腐化に悩まされることも少なくありませんでした。
API定義のための仕様であるOpenAPI Specification (OAS) と、そのエコシステムを活用することで、これらの問題も解決することができます!
本記事では、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の設定を記述します。
# 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も作成します。
//go:build tools
// +build tools
package main
import (
_ "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen"
)
//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を作成します。
最終的に作成したテンプレートはこちら
# 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
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
を実装します。
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にアプリエンドポイントを定義します。
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
を定義してみます。
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
に引数が不足しているようです。エラーに出力されている通り、引数を追加します。
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
}
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にあがっています。よろしければ参考にしてみてください。
APIファースト開発は、変化の激しい現代において、柔軟性と拡張性の高いシステムを構築するための強力な手法です。
ぜひ、この機会にAPIファースト開発を体験してみてください!
Happy Development!!
-
oapi-codegenは標準で多様なFWに対応しているのが、今回採用したポイントとなります。Echoを利用予定ですが、他ツールを使用する場合は独自でHandlerをWrapするなど工夫が必要になります。 ↩︎
-
OpenAPI Generatorは多言語対応かつクライアント/サーバの両コード・テスト・ドキュメントまで多様な生成ができるのが強みです。 ↩︎
-
ogenは後発ということもあり洗練された印象があります。GeneratorというよりもFWに近いように思います。OpenTelemetryにもデフォルトで対応しているようです。 ↩︎
-
OASではSchemaが公開されておりそちらを参照することで補完などが可能です。ただし今回利用するv3.0.3ではVSCodeやNeovimのLSPなどに利用されているredhat-developer/yaml-language-serverの問題でOASのスキーマが不正と見做されてしまうため自前のスキーマファイルを用意するというワークアラウンドをとっています。 ↩︎
-
oapi-codegenは現在v3.0.3まで対応しています。記事執筆現在ではv3.1以降は対応していないため注意が必要です。OpenAPI 3.1 support? · Issue #373 · oapi-codegen/oapi-codegen ↩︎
Discussion