🦇

swaggoを用いてハンドラ駆動でREST APIの仕様を表現する

2024/02/28に公開

はじめに

この度、受託開発においてGo + REST APIを採用しました。
フロントエンドとのコミュニケーションコストを減らす + APIとしての設計書作成のためにOpenAPIを利用しようと考えたのですが、Goのコード上でそれを実現できないか?と調べたところ、swaggoというライブラリに行きつきました。

そのため、今回はハンドラ駆動でOpenAPIを定義する手法を紹介したいと思います。
今回書いたコードはこちらになります。

OpenAPIとは?

ChatGPTが3行で説明してくれました。

OpenAPIは、RESTful APIの仕様を記述するための言語非依存のインターフェースファイルです。
この仕様によって、人間とコンピュータのどちらもがAPIの機能を理解しやすくなります。
APIのエンドポイント、操作、入力・出力の形式などを定義し、APIの開発、テスト、ドキュメント生成を自動化するのに役立ちます。

環境構築

goのプロジェクトを立ち上げます。

mkdir go-swag && go-swag
go mod init go-swag
touch main.go

swaggoのCLIをインストールします。

go install github.com/swaggo/swag/cmd/swag@latest

サードパッケージをインポートします(今回はmuxをルーティングで使います)。

go get -u github.com/swaggo/http-swagger
go get -u "github.com/gorilla/mux"

ドメインモデルの定義

Goのアプリケーション内で定義するドメインモデルを定義します。
後述しますが、こちらの構造体をそのままOpenAPIの中で表現することができます。
modelパッケージとして、model/blog.goに定義します。

model/blog.go
package model

type BlogPost struct {
	ID      int    `json:"id"`
	Title   string `json:"title"`
	Content string `json:"content"`
}

ハンドラの定義

次にハンドラを定義します。
関数の上にアノテーションを書くことで、APIの定義を表現することができます。
またリクエストボティやレスポンスのデータ型として、上記のドメインモデルを参照することができるようになっています(model.BlogPost)。
今回はmain.goの中に書きますが、別パッケージでも同様の定義を行うことができます。

  • @Summary: APIの要約を記述します。
  • @Description: APIの詳細な説明を記述します。
  • @Tags: APIをカテゴリー分けするためのタグを指定します。
  • @Accept: APIが受け入れるデータのタイプを指定します。
  • @Produce: APIが出力するデータのタイプを指定します。
  • @Success: APIの成功時のレスポンスを定義します。また、返却するJSONの元データを指定することができます(model.BlogPostのことです)。
  • @Router: APIのルーティング情報を記述します。

GetBlogPost

main.go
// @Summary ブログ記事リストを取得
// @Description 全てのブログ記事のリストを取得します。
// @Tags blog
// @Accept json
// @Produce json
// @Success 200 {array} model.BlogPost
// @Router /api/v1/blog/posts [get]
func GetBlogPosts(w http.ResponseWriter, r *http.Request) {
	// ダミーデータ
	posts := []model.BlogPost{
		{ID: 1, Title: "ブログ記事1", Content: "ブログ記事の内容1"},
		{ID: 2, Title: "ブログ記事2", Content: "ブログ記事の内容2"},
		// 他のブログ記事
	}
	json.NewEncoder(w).Encode(posts)
}
curl http://localhost:8080/api/v1/blog/posts | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   150  100   150    0     0  19936      0 --:--:-- --:--:-- --:--:-- 75000
[
  {
    "id": 1,
    "title": "ブログ記事1",
    "content": "ブログ記事の内容1"
  },
  {
    "id": 2,
    "title": "ブログ記事2",
    "content": "ブログ記事の内容2"
  }
]

GetBlogPost

main.go
// @Summary 特定のブログ記事を取得
// @Description 指定されたIDのブログ記事を取得します。
// @Tags blog
// @Accept json
// @Produce json
// @Param id path int true "記事ID"
// @Success 200 {object} model.BlogPost
// @Router /api/v1/blog/posts/{id} [get]
func GetBlogPost(w http.ResponseWriter, r *http.Request) {
	// ダミーのブログ記事データ
	post := model.BlogPost{ID: 1, Title: "サンプルブログ記事", Content: "これはサンプルのブログ記事の内容です。"}

	// URLからIDを取得
	vars := mux.Vars(r)
	id, err := strconv.Atoi(vars["id"])
	if err != nil {
		http.Error(w, "不正な記事ID", http.StatusBadRequest)
		return
	}

	// 実際のアプリケーションでは、IDに基づいてデータベースから記事を取得します。
	// ここではダミーデータを返すだけです。
	post.ID = id

	json.NewEncoder(w).Encode(post)
}
curl http://localhost:8080/api/v1/blog/posts/1 | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   117  100   117    0     0   6875      0 --:--:-- --:--:-- --:--:-- 10636
{
  "id": 1,
  "title": "サンプルブログ記事",
  "content": "これはサンプルのブログ記事の内容です。"
}

CreateBlogPost

main.go
// @Summary 新しいブログ記事を作成
// @Description 新しいブログ記事を作成します。
// @Tags blog
// @Accept json
// @Produce json
// @Param blogPost body model.BlogPost true "ブログ記事"
// @Success 201 {object} model.BlogPost
// @Router /api/v1/blog/posts [post]
func CreateBlogPost(w http.ResponseWriter, r *http.Request) {
	var newPost model.BlogPost

	// リクエストボディからブログ記事を読み込む
	err := json.NewDecoder(r.Body).Decode(&newPost)
	if err != nil {
		http.Error(w, "リクエストの解析に失敗しました", http.StatusBadRequest)
		return
	}

	// 実際のアプリケーションでは、ここでデータベースに記事を保存します。
	// ここでは、送信された記事をそのまま返しています。
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(newPost)
}
curl -X POST http://localhost:8080/api/v1/blog/posts \
     -H "Content-Type: application/json" \
     -d '{"id": 123, "title": "Sample Blog Post", "content": "This is a sample blog post content."}'

{"id":123,"title":"Sample Blog Post","content":"This is a sample blog post content."}

main関数の定義

main関数の上に定義しているアノテーションは、APIのベースとなる定義になります。
関数内では特に変わったことはしていませんが、SwaggerUIを見るためのエンドポイントが用意されています。
今回の例だと、http://localhost:8080/swagger/で確認することができます。

@title: APIのタイトルを指定します。ここでは「Blog API」というタイトルが設定されています。
@version: APIのバージョンを指定します。この場合は「1.0」となっています。
@description: APIの詳細な説明を記述します。「This is a blog API.」という説明がここでは与えられています。
@host: APIをホストしているサーバーのアドレスを指定します。ここではローカルホストのポート8080(localhost:8080)が指定されています。
@BasePath: APIのベースとなるパスを指定します。このコードでは/api/v1がベースパスとして設定されており、このパス以下に具体的なエンドポイントが配置されます。

main.go
// @title Blog API
// @version 1.0
// @description This is a blog API.
// @host localhost:8080
// @BasePath /api/v1
func main() {
	r := mux.NewRouter()
	// Swaggerドキュメントの設定
	r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler)

	api := r.PathPrefix("/api/v1").Subrouter()
	// ブログ関連のエンドポイント
	api.HandleFunc("/blog/posts", GetBlogPosts).Methods("GET")
	api.HandleFunc("/blog/posts/{id}", GetBlogPost).Methods("GET")
	api.HandleFunc("/blog/posts", CreateBlogPost).Methods("POST")

	log.Println("server started at port 8080")
	log.Fatal(http.ListenAndServe(":8080", api))
}

OpenAPI定義書の実行

あとはswag initでyaml or JSONファイルを自動生成して終了、となりますが2点注意があります。

独自の構造体を扱う場合は、オプション引数をつける

以下のようなオプション引数をつける必要があります。

swag init --parseDependency --parseInternal

これにより、modelパッケージで定義したGo構造体を認識して、SwaggerUI上で見ることができるようになります。
自動生成した定義は、デフォルトの場合docsディレクトリ内にあります。

docsパッケージをインポート

先述したlocalhost:8080/swagger/にアクセスすると、not foundエラーが出ることがあります。
この時、main.goでdocsパッケージをインポートしているか確認してみてください。

main.go
package main

import (
	"encoding/json"
	"go-swag/model"
	"log"
	"net/http"
	"strconv"

	_ "go-swag/docs"

	"github.com/gorilla/mux"
	httpSwagger "github.com/swaggo/http-swagger"
)

実行できると、以下のようなOpenAPIのyamlファイルが生成されます。

basePath: /api/v1
definitions:
  model.BlogPost:
    properties:
      content:
        type: string
      id:
        type: integer
      title:
        type: string
    type: object
host: localhost:8080
info:
  contact: {}
  description: This is a blog API.
  title: Blog API
  version: "1.0"
paths:
  /api/v1/blog/posts:
    get:
      consumes:
      - application/json
      description: 全てのブログ記事のリストを取得します。
      produces:
      - application/json
      responses:
        "200":
          description: OK
          schema:
            items:
              $ref: '#/definitions/model.BlogPost'
            type: array
      summary: ブログ記事リストを取得
      tags:
      - blog
    post:
      consumes:
      - application/json
      description: 新しいブログ記事を作成します。
      parameters:
      - description: ブログ記事
        in: body
        name: blogPost
        required: true
        schema:
          $ref: '#/definitions/model.BlogPost'
      produces:
      - application/json
      responses:
        "201":
          description: Created
          schema:
            $ref: '#/definitions/model.BlogPost'
      summary: 新しいブログ記事を作成
      tags:
      - blog
  /api/v1/blog/posts/{id}:
    get:
      consumes:
      - application/json
      description: 指定されたIDのブログ記事を取得します。
      parameters:
      - description: 記事ID
        in: path
        name: id
        required: true
        type: integer
      produces:
      - application/json
      responses:
        "200":
          description: OK
          schema:
            $ref: '#/definitions/model.BlogPost'
      summary: 特定のブログ記事を取得
      tags:
      - blog
swagger: "2.0"

無事実行できたら、ローカルサーバーを立ち上げて、SwaggerUIへアクセスしてみましょう。
以下のような画面が出てきたら成功です!
ドメインモデルも、問題なく表現できていることが確認できましたね。

終わりに

GraphQLでのスキーマ駆動での開発に慣れつつあり、似たような手法をRESTでも取ることができないか?を考えた矢先にswaggoを見つけました。

日々開発業務に追われていると、どうしても既存のやり方やツールに行ってしまう傾向は誰しもあるかと思いますが、一旦余白を設けて、良い最適解がないか探してみる習慣を持つのもエンジニアとして大事な素養なのかな、と思いました。

最後に、toraco株式会社ではエンジニアを積極採用中です。
フロントエンドエンジニア、バックエンドエンジニア、クラウドインフラエンジニアなど職種問わず、様々な技術領域にチャレンジできます。また、PM(プロジェクトマネージャー) や EM(エンジニアリングマネージャー)のキャリアパスも用意しています。
興味のある方は Wantedly の募集をぜひ読んでください。
https://www.wantedly.com/companies/company_5649245

toraco株式会社のテックブログ

Discussion