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?

GoでAPIクライアント自動生成とモックを実装してみた

Last updated at Posted at 2024-10-26

背景・目的

以前、Swagger UIをGitHub Actionsで公開しました。その際に利用した設定ファイルを使用してコードを自動生成し、

今回は、oapi-codegenについて、特徴を整理し、動作を確認してみます。

また、uber-go/mockを使用してモックを生成して単体テストも試します。

まとめ

下記に、特徴を整理します。

特徴 説明
oapi-codegenとは OpenAPI 仕様を Go コードに変換するためのコマンドライン ツールおよびライブラリ
oapi-codegenのメリット OpenAPI 3.0に基づくサービスの作成や統合に必要な定型文を削減できる。
これにより、ビジネス ロジックの作成と、組織にとっての真の付加価値の創出に注力できる
oapi-codegenのサポート ・サーバーサイドの定型文の生成
・クライアント API 定型文の生成
・型の生成
・大規模な OpenAPI 仕様を複数のパッケージに分割する
uber-go/mockとは Go プログラミング言語用のモックフレームワーク
Go の組み込みテスト パッケージとうまく統合されているが、他のコンテキストでも使用できる
uber-go/mockの背景 Google の golang/mock リポジトリから生まれたが、Google はこのプロジェクトを保守しなくなった。
Uber 内で gomock プロジェクトが頻繁に使用されていることを考慮して、Uber では今後これをフォークして保守することになった

概要

oapi-codegen

下記のページを基に整理します。
https://github.com/oapi-codegen/oapi-codegen

oapi-codegen is a command-line tool and library to convert OpenAPI specifications to Go code, be it server-side implementations, API clients, or simply HTTP models.

  • OpenAPI 仕様を Go コードに変換するためのコマンドライン ツールおよびライブラリ
    • サーバー側の実装
    • API クライアント
    • 単純な HTTP モデルなど

Using oapi-codegen allows you to reduce the boilerplate required to create or integrate with services based on OpenAPI 3.0, and instead focus on writing your business logic, and working on the real value-add for your organisation.

  • OpenAPI 3.0に基づくサービスの作成や統合に必要な定型文を削減できる。これにより、ビジネス ロジックの作成と、組織にとっての真の付加価値の創出に注力できる

Features

At a high level, oapi-codegen supports:

  • Generating server-side boilerplate for a number of servers (docs)
  • Generating client API boilerplate (docs)
  • Generating the types (docs)
  • Splitting large OpenAPI specs across multiple packages(docs)
    • This is also known as "Import Mapping" or "external references" across our documentation / discussion in GitHub issues
  • oapi-codegenは下記をサポートしている
    • サーバーサイドの定型文の生成
    • クライアント API 定型文の生成
    • 型の生成
    • 大規模な OpenAPI 仕様を複数のパッケージに分割する

uber-go/mock

下記を基に整理します。

gomock is a mocking framework for the Go programming language. It integrates well with Go's built-in testing package, but can be used in other contexts too.

  • gomock は、Go プログラミング言語用のモック フレームワーク
  • Go の組み込みテスト パッケージとうまく統合されているが、他のコンテキストでも使用できる

This project originates from Google's golang/mock repo. Unfortunately, Google no longer maintains this project, and given the heavy usage of gomock project within Uber, we've decided to fork and maintain this going forward at Uber.

  • このプロジェクトは、Google の golang/mock リポジトリから生まれた
  • Google はこのプロジェクトを保守しなくなった
  • Uber 内で gomock プロジェクトが頻繁に使用されていることを考慮して、Uber では今後これをフォークして保守することになった

実践

前提

  • Goがインストールされていること

oapi-codegenを使用した実装

プロジェクトの作成

  1. VSCodeのワークスペース用に、ディレクトリを作成します

    % mkdir oapi-codegen-test
    % cd oapi-codegen-test
    
  2. VSCodeでフォルダを追加します

oapi-codegenのインストール

  1. oapi-codegenをインストールします
    % go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
    go: downloading github.com/oapi-codegen/oapi-codegen v1.16.3
    go: downloading github.com/oapi-codegen/oapi-codegen/v2 v2.4.1
    go: downloading github.com/getkin/kin-openapi v0.127.0
    go: downloading golang.org/x/text v0.18.0
    go: downloading golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d
    go: downloading github.com/speakeasy-api/openapi-overlay v0.9.0
    go: downloading gopkg.in/yaml.v3 v3.0.1
    go: downloading github.com/vmware-labs/yaml-jsonpath v0.3.2
    go: downloading github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
    go: downloading github.com/go-openapi/jsonpointer v0.21.0
    go: downloading github.com/invopop/yaml v0.3.1
    go: downloading github.com/perimeterx/marshmallow v1.1.5
    go: downloading github.com/go-openapi/swag v0.23.0
    go: downloading github.com/mailru/easyjson v0.7.7
    go: downloading github.com/josharian/intern v1.0.0
    go: downloading github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936
    % 
    
  2. インストールを確認します。(バージョンを確認します)
    % oapi-codegen -version 
    github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
    v2.4.1
    % 
    

クライアントコードを生成

  1. 専用のディレクトリを作成します

    % mkdir client
    
  2. githubリポジトリのコードを指定して、クライアントコードを生成します

    % oapi-codegen -generate types,client -o ./client/api_client.go -package client https://raw.githubusercontent.com/XXXXX/swagger-test/refs/heads/main/swagger-tutorial.yml
    
  3. コードが生成されました。内容を確認します

    oapi-codegen-test % ls client/api_client.go 
    client/api_client.go
        oapi-codegen-test % cat client/api_client.go 
    // Package client provides primitives to interact with the openapi HTTP API.
    //
    // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT.
    package client
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"io"
    	"net/http"
    	"net/url"
    	"strings"
    
    	"github.com/oapi-codegen/runtime"
    )
    
    const (
    	BasicAuthScopes = "BasicAuth.Scopes"
    )
    
    // GetArtistListParams defines parameters for GetArtistList.
    type GetArtistListParams struct {
    	// Limit Limits the number of items on a page
    	Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
    
    	// Offset Specifies the page number of the artists to be displayed
    	Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
    }
    
    // RequestEditorFn  is the function signature for the RequestEditor callback function
    type RequestEditorFn func(ctx context.Context, req *http.Request) error
    
    // Doer performs HTTP requests.
    //
    // The standard http.Client implements this interface.
    type HttpRequestDoer interface {
    	Do(req *http.Request) (*http.Response, error)
    }
    
    // Client which conforms to the OpenAPI3 specification for this service.
    type Client struct {
    	// The endpoint of the server conforming to this interface, with scheme,
    	// https://api.deepmap.com for example. This can contain a path relative
    	// to the server, such as https://api.deepmap.com/dev-test, and all the
    	// paths in the swagger spec will be appended to the server.
    	Server string
    
    	// Doer for performing requests, typically a *http.Client with any
    	// customized settings, such as certificate chains.
    	Client HttpRequestDoer
    
    	// A list of callbacks for modifying requests which are generated before sending over
    	// the network.
    	RequestEditors []RequestEditorFn
    }
    
    // ClientOption allows setting custom parameters during construction
    type ClientOption func(*Client) error
    
    // Creates a new Client, with reasonable defaults
    func NewClient(server string, opts ...ClientOption) (*Client, error) {
    	// create a client with sane default values
    	client := Client{
    		Server: server,
    	}
    	// mutate client and add all optional params
    	for _, o := range opts {
    		if err := o(&client); err != nil {
    			return nil, err
    		}
    	}
    	// ensure the server URL always has a trailing slash
    	if !strings.HasSuffix(client.Server, "/") {
    		client.Server += "/"
    	}
    	// create httpClient, if not already present
    	if client.Client == nil {
    		client.Client = &http.Client{}
    	}
    	return &client, nil
    }
    
    // WithHTTPClient allows overriding the default Doer, which is
    // automatically created using http.Client. This is useful for tests.
    func WithHTTPClient(doer HttpRequestDoer) ClientOption {
    	return func(c *Client) error {
    		c.Client = doer
    		return nil
    	}
    }
    
    // WithRequestEditorFn allows setting up a callback function, which will be
    // called right before sending the request. This can be used to mutate the request.
    func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
    	return func(c *Client) error {
    		c.RequestEditors = append(c.RequestEditors, fn)
    		return nil
    	}
    }
    
    // The interface specification for the client above.
    type ClientInterface interface {
    	// GetArtistList request
    	GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error)
    }
    
    func (c *Client) GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
    	req, err := NewGetArtistListRequest(c.Server, params)
    	if err != nil {
    		return nil, err
    	}
    	req = req.WithContext(ctx)
    	if err := c.applyEditors(ctx, req, reqEditors); err != nil {
    		return nil, err
    	}
    	return c.Client.Do(req)
    }
    
    // NewGetArtistListRequest generates requests for GetArtistList
    func NewGetArtistListRequest(server string, params *GetArtistListParams) (*http.Request, error) {
    	var err error
    
    	serverURL, err := url.Parse(server)
    	if err != nil {
    		return nil, err
    	}
    
    	operationPath := fmt.Sprintf("/artists")
    	if operationPath[0] == '/' {
    		operationPath = "." + operationPath
    	}
    
    	queryURL, err := serverURL.Parse(operationPath)
    	if err != nil {
    		return nil, err
    	}
    
    	if params != nil {
    		queryValues := queryURL.Query()
    
    		if params.Limit != nil {
    
    			if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil {
    				return nil, err
    			} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
    				return nil, err
    			} else {
    				for k, v := range parsed {
    					for _, v2 := range v {
    						queryValues.Add(k, v2)
    					}
    				}
    			}
    
    		}
    
    		if params.Offset != nil {
    
    			if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil {
    				return nil, err
    			} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
    				return nil, err
    			} else {
    				for k, v := range parsed {
    					for _, v2 := range v {
    						queryValues.Add(k, v2)
    					}
    				}
    			}
    
    		}
    
    		queryURL.RawQuery = queryValues.Encode()
    	}
    
    	req, err := http.NewRequest("GET", queryURL.String(), nil)
    	if err != nil {
    		return nil, err
    	}
    
    	return req, nil
    }
    
    func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error {
    	for _, r := range c.RequestEditors {
    		if err := r(ctx, req); err != nil {
    			return err
    		}
    	}
    	for _, r := range additionalEditors {
    		if err := r(ctx, req); err != nil {
    			return err
    		}
    	}
    	return nil
    }
    
    // ClientWithResponses builds on ClientInterface to offer response payloads
    type ClientWithResponses struct {
    	ClientInterface
    }
    
    // NewClientWithResponses creates a new ClientWithResponses, which wraps
    // Client with return type handling
    func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) {
    	client, err := NewClient(server, opts...)
    	if err != nil {
    		return nil, err
    	}
    	return &ClientWithResponses{client}, nil
    }
    
    // WithBaseURL overrides the baseURL.
    func WithBaseURL(baseURL string) ClientOption {
    	return func(c *Client) error {
    		newBaseURL, err := url.Parse(baseURL)
    		if err != nil {
    			return err
    		}
    		c.Server = newBaseURL.String()
    		return nil
    	}
    }
    
    // ClientWithResponsesInterface is the interface specification for the client with responses above.
    type ClientWithResponsesInterface interface {
    	// GetArtistListWithResponse request
    	GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error)
    }
    
    type GetArtistListResponse struct {
    	Body         []byte
    	HTTPResponse *http.Response
    	JSON200      *[]struct {
    		Albums      *int    `json:"albums,omitempty"`
    		ArtistGenre *string `json:"artist_genre,omitempty"`
    		ArtistName  *string `json:"artist_name,omitempty"`
    		Username    string  `json:"username"`
    	}
    	JSON400 *struct {
    		Message *string `json:"message,omitempty"`
    	}
    }
    
    // Status returns HTTPResponse.Status
    func (r GetArtistListResponse) Status() string {
    	if r.HTTPResponse != nil {
    		return r.HTTPResponse.Status
    	}
    	return http.StatusText(0)
    }
    
    // StatusCode returns HTTPResponse.StatusCode
    func (r GetArtistListResponse) StatusCode() int {
    	if r.HTTPResponse != nil {
    		return r.HTTPResponse.StatusCode
    	}
    	return 0
    }
    
    // GetArtistListWithResponse request returning *GetArtistListResponse
    func (c *ClientWithResponses) GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error) {
    	rsp, err := c.GetArtistList(ctx, params, reqEditors...)
    	if err != nil {
    		return nil, err
    	}
    	return ParseGetArtistListResponse(rsp)
    }
    
    // ParseGetArtistListResponse parses an HTTP response from a GetArtistListWithResponse call
    func ParseGetArtistListResponse(rsp *http.Response) (*GetArtistListResponse, error) {
    	bodyBytes, err := io.ReadAll(rsp.Body)
    	defer func() { _ = rsp.Body.Close() }()
    	if err != nil {
    		return nil, err
    	}
    
    	response := &GetArtistListResponse{
    		Body:         bodyBytes,
    		HTTPResponse: rsp,
    	}
    
    	switch {
    	case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
    		var dest []struct {
    			Albums      *int    `json:"albums,omitempty"`
    			ArtistGenre *string `json:"artist_genre,omitempty"`
    			ArtistName  *string `json:"artist_name,omitempty"`
    			Username    string  `json:"username"`
    		}
    		if err := json.Unmarshal(bodyBytes, &dest); err != nil {
    			return nil, err
    		}
    		response.JSON200 = &dest
    
    	case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
    		var dest struct {
    			Message *string `json:"message,omitempty"`
    		}
    		if err := json.Unmarshal(bodyBytes, &dest); err != nil {
    			return nil, err
    		}
    		response.JSON400 = &dest
    
    	}
    
    	return response, nil
    }
    
    

生成されたクライアントを使用する

  1. main.goファイルを作成します
  2. 下記のコードを実装します
    • 生成されたクライアントコードは、apiというパッケージ名で使えるようになります。
    package main
    
    import (
    	"context"
    	"log"
    	"net/http"
    
    	// APIクライアントのパッケージをインポート
    	// このパッケージはoapi-codegenで生成されたもの
    	// モジュール+パッケージ名で指定
    	"oapi-codegen-test/client"
    )
    
    func main() {
    	// クライアントを作成
    	appClient, err := client.NewClientWithResponses("https://api.example.com", client.WithHTTPClient(&http.Client{}))
    
    	// クエリパラメータの設定
    	params := &client.GetArtistListParams{
    		Limit:  intPointer(10),
    		Offset: intPointer(0),
    	}
    
    	// APIリクエストの送信
    	response, err := appClient.GetArtistListWithResponse(context.Background(), params)
    	if err != nil {
    		log.Fatalf("Error calling API: %v", err)
    	}
    
    	log.Printf("Response: %v", response.JSON200) // 成功時のレスポンスを表示
    }
    
    // intをポインタで渡すためのヘルパー関数
    func intPointer(i int) *int {
    	return &i
    }
    
    

ビルド

  1. go mod initでモジュールを初期化する

    oapi-codegen-test % go mod init oapi-codegen-test
    go: creating new go.mod: module oapi-codegen-test
    go: to add module requirements and sums:
            go mod tidy
    oapi-codegen-test % 
    
  2. 中身を確認します

    oapi-codegen-test % cat go.mod 
    module oapi-codegen-test
    
    go 1.23.2
    oapi-codegen-test % 
    
  3. ビルドします

    go build -o myapi-client
    

uber-go/mockを使用した単体テスト

テストを行うにあたり、下記のフォルダ構成とします

oapi-codegen-test/
├── client
│   ├── api_client.go          # oapi-codegenで生成されたAPIクライアントコード
│   ├── model.go               # oapi-codegenで生成されたデータモデル(構造体)定義
│   └── tests
│       └── client_test.go     # クライアントコードの単体テスト。モックを使って検証
├── e2e
│   └── main_test.go           # エンドツーエンドテスト。モッククライアントでmain.goの動作を確認
├── go.mod                     # Goモジュールの依存関係ファイル
├── go.sum                     # go.modの依存関係のチェックサム
├── interfaces
│   ├── client_interface.go    # クライアントのインターフェース定義。テスト用モック生成に使用
│   └── client_mock.go         # モックジェネレーター(uber-go/mock)で生成されたモック
├── main.go                    # アプリケーションのエントリーポイント。実行ロジックを含む
└── myapi-client               # ビルドされた実行ファイル(例)

gomockパッケージのインストール

  1. gomockをインストールします
    oapi-codegen-test % go get go.uber.org/mock/gomock
    go: downloading github.com/stretchr/testify v1.9.0
    go: added go.uber.org/mock v0.5.0
    oapi-codegen-test % 
    

openapi仕様書の修正

  1. 下記のOpenAPIをGitHubにPushしておきます
    openapi: 3.0.0
    info:
      version: 1.0.0
      title: Simple Artist API
      description: A simple API to illustrate OpenAPI concepts
    
    servers:
      - url: https://example.io/v1
    
    # Basic authentication
    components:
      securitySchemes:
        BasicAuth:
          type: http
          scheme: basic
    security:
      - BasicAuth: []
    
    paths: 
      /artists:
        get:
          description: Returns a list of artists
          operationId: getArtistList
          parameters:
            - name: limit
              in: query
              description: Limits the number of items on a page
              schema:
                type: integer
              required: false
            - name: offset
              in: query
              description: Specifies the page number of the artists to be displayed
              schema:
                type: integer
              required: false
          responses:
            '200':
              description: Successfully returend a list of artists
              content:
                application/json:
                  schema:
                    type: array
                    items:
                      type: object
                      required:
                        - username
                      properties:
                        artist_name:
                          type: string
                        artist_genre:
                          type: string
                        albums:
                          type: integer
                        username:
                          type: string
            '400':
              description: Invalid request
              content:
                application/json:
                  schema:
                    type: object
                    properties:
                      message:
                        type: string
    

mock/mockgenのインストール

  1. mock/mockgenをインストールします
    oapi-codegen-test % go install go.uber.org/mock/mockgen@latest
    go: downloading go.uber.org/mock v0.5.0
    go: downloading golang.org/x/mod v0.18.0
    go: downloading golang.org/x/tools v0.22.0
    % 
    
  2. 正常にインストールされたか確認します
    oapi-codegen-test % mockgen -version
    v0.5.0
    oapi-codegen-test % 
    

プロジェクトの初期セットアップ

  1. フォルダを作成します
    oapi-codegen-test % mkdir -p client/tests
    oapi-codegen-test % mkdir interfaces
    oapi-codegen-test % mkdir e2e
    oapi-codegen-test % tree
    .
    ├── client
    │   ├── api_client.go
    │   └── tests
    ├── e2e
    ├── go.mod
    ├── go.sum
    ├── interfaces
    ├── main.go
    └── myapi-client
    
    5 directories, 5 files
    oapi-codegen-test % 
    

OpenAPI仕様からクライアントコードとモデルの生成

クライアントコードの生成

  1. クライアントコードを生成します
    oapi-codegen-test % oapi-codegen -generate client -o ./client/api_client.go -package client https://raw.githubusercontent.com/xxxxx/swagger-test/refs/heads/main/swagger-tutorial.yml
    % 
    
  2. api_client.goを確認します
    // Package client provides primitives to interact with the openapi HTTP API.
    //
    // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT.
    package client
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"io"
    	"net/http"
    	"net/url"
    	"strings"
    
    	"github.com/oapi-codegen/runtime"
    )
    
    // RequestEditorFn  is the function signature for the RequestEditor callback function
    type RequestEditorFn func(ctx context.Context, req *http.Request) error
    
    // Doer performs HTTP requests.
    //
    // The standard http.Client implements this interface.
    type HttpRequestDoer interface {
    	Do(req *http.Request) (*http.Response, error)
    }
    
    // Client which conforms to the OpenAPI3 specification for this service.
    type Client struct {
    	// The endpoint of the server conforming to this interface, with scheme,
    	// https://api.deepmap.com for example. This can contain a path relative
    	// to the server, such as https://api.deepmap.com/dev-test, and all the
    	// paths in the swagger spec will be appended to the server.
    	Server string
    
    	// Doer for performing requests, typically a *http.Client with any
    	// customized settings, such as certificate chains.
    	Client HttpRequestDoer
    
    	// A list of callbacks for modifying requests which are generated before sending over
    	// the network.
    	RequestEditors []RequestEditorFn
    }
    
    // ClientOption allows setting custom parameters during construction
    type ClientOption func(*Client) error
    
    // Creates a new Client, with reasonable defaults
    func NewClient(server string, opts ...ClientOption) (*Client, error) {
    	// create a client with sane default values
    	client := Client{
    		Server: server,
    	}
    	// mutate client and add all optional params
    	for _, o := range opts {
    		if err := o(&client); err != nil {
    			return nil, err
    		}
    	}
    	// ensure the server URL always has a trailing slash
    	if !strings.HasSuffix(client.Server, "/") {
    		client.Server += "/"
    	}
    	// create httpClient, if not already present
    	if client.Client == nil {
    		client.Client = &http.Client{}
    	}
    	return &client, nil
    }
    
    // WithHTTPClient allows overriding the default Doer, which is
    // automatically created using http.Client. This is useful for tests.
    func WithHTTPClient(doer HttpRequestDoer) ClientOption {
    	return func(c *Client) error {
    		c.Client = doer
    		return nil
    	}
    }
    
    // WithRequestEditorFn allows setting up a callback function, which will be
    // called right before sending the request. This can be used to mutate the request.
    func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
    	return func(c *Client) error {
    		c.RequestEditors = append(c.RequestEditors, fn)
    		return nil
    	}
    }
    
    // The interface specification for the client above.
    type ClientInterface interface {
    	// GetArtistList request
    	GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error)
    }
    
    func (c *Client) GetArtistList(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
    	req, err := NewGetArtistListRequest(c.Server, params)
    	if err != nil {
    		return nil, err
    	}
    	req = req.WithContext(ctx)
    	if err := c.applyEditors(ctx, req, reqEditors); err != nil {
    		return nil, err
    	}
    	return c.Client.Do(req)
    }
    
    // NewGetArtistListRequest generates requests for GetArtistList
    func NewGetArtistListRequest(server string, params *GetArtistListParams) (*http.Request, error) {
    	var err error
    
    	serverURL, err := url.Parse(server)
    	if err != nil {
    		return nil, err
    	}
    
    	operationPath := fmt.Sprintf("/artists")
    	if operationPath[0] == '/' {
    		operationPath = "." + operationPath
    	}
    
    	queryURL, err := serverURL.Parse(operationPath)
    	if err != nil {
    		return nil, err
    	}
    
    	if params != nil {
    		queryValues := queryURL.Query()
    
    		if params.Limit != nil {
    
    			if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil {
    				return nil, err
    			} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
    				return nil, err
    			} else {
    				for k, v := range parsed {
    					for _, v2 := range v {
    						queryValues.Add(k, v2)
    					}
    				}
    			}
    
    		}
    
    		if params.Offset != nil {
    
    			if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil {
    				return nil, err
    			} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
    				return nil, err
    			} else {
    				for k, v := range parsed {
    					for _, v2 := range v {
    						queryValues.Add(k, v2)
    					}
    				}
    			}
    
    		}
    
    		queryURL.RawQuery = queryValues.Encode()
    	}
    
    	req, err := http.NewRequest("GET", queryURL.String(), nil)
    	if err != nil {
    		return nil, err
    	}
    
    	return req, nil
    }
    
    func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error {
    	for _, r := range c.RequestEditors {
    		if err := r(ctx, req); err != nil {
    			return err
    		}
    	}
    	for _, r := range additionalEditors {
    		if err := r(ctx, req); err != nil {
    			return err
    		}
    	}
    	return nil
    }
    
    // ClientWithResponses builds on ClientInterface to offer response payloads
    type ClientWithResponses struct {
    	ClientInterface
    }
    
    // NewClientWithResponses creates a new ClientWithResponses, which wraps
    // Client with return type handling
    func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) {
    	client, err := NewClient(server, opts...)
    	if err != nil {
    		return nil, err
    	}
    	return &ClientWithResponses{client}, nil
    }
    
    // WithBaseURL overrides the baseURL.
    func WithBaseURL(baseURL string) ClientOption {
    	return func(c *Client) error {
    		newBaseURL, err := url.Parse(baseURL)
    		if err != nil {
    			return err
    		}
    		c.Server = newBaseURL.String()
    		return nil
    	}
    }
    
    // ClientWithResponsesInterface is the interface specification for the client with responses above.
    type ClientWithResponsesInterface interface {
    	// GetArtistListWithResponse request
    	GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error)
    }
    
    type GetArtistListResponse struct {
    	Body         []byte
    	HTTPResponse *http.Response
    	JSON200      *[]Artist
    	JSON400      *struct {
    		Message *string `json:"message,omitempty"`
    	}
    }
    
    // Status returns HTTPResponse.Status
    func (r GetArtistListResponse) Status() string {
    	if r.HTTPResponse != nil {
    		return r.HTTPResponse.Status
    	}
    	return http.StatusText(0)
    }
    
    // StatusCode returns HTTPResponse.StatusCode
    func (r GetArtistListResponse) StatusCode() int {
    	if r.HTTPResponse != nil {
    		return r.HTTPResponse.StatusCode
    	}
    	return 0
    }
    
    // GetArtistListWithResponse request returning *GetArtistListResponse
    func (c *ClientWithResponses) GetArtistListWithResponse(ctx context.Context, params *GetArtistListParams, reqEditors ...RequestEditorFn) (*GetArtistListResponse, error) {
    	rsp, err := c.GetArtistList(ctx, params, reqEditors...)
    	if err != nil {
    		return nil, err
    	}
    	return ParseGetArtistListResponse(rsp)
    }
    
    // ParseGetArtistListResponse parses an HTTP response from a GetArtistListWithResponse call
    func ParseGetArtistListResponse(rsp *http.Response) (*GetArtistListResponse, error) {
    	bodyBytes, err := io.ReadAll(rsp.Body)
    	defer func() { _ = rsp.Body.Close() }()
    	if err != nil {
    		return nil, err
    	}
    
    	response := &GetArtistListResponse{
    		Body:         bodyBytes,
    		HTTPResponse: rsp,
    	}
    
    	switch {
    	case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
    		var dest []Artist
    		if err := json.Unmarshal(bodyBytes, &dest); err != nil {
    			return nil, err
    		}
    		response.JSON200 = &dest
    
    	case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
    		var dest struct {
    			Message *string `json:"message,omitempty"`
    		}
    		if err := json.Unmarshal(bodyBytes, &dest); err != nil {
    			return nil, err
    		}
    		response.JSON400 = &dest
    
    	}
    
    	return response, nil
    }
    

モデルコードの生成

  1. モデルコードを生成します
    oapi-codegen-test % oapi-codegen -generate types -o ./client/model.go -package client https://raw.githubusercontent.com/xxxxx/swagger-test/refs/heads/main/swagger-tutorial.yml
    % 
    
  2. model.goを確認します
    // Package client provides primitives to interact with the openapi HTTP API.
    //
    // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT.
    package client
    
    const (
    	BasicAuthScopes = "BasicAuth.Scopes"
    )
    
    // Artist defines model for Artist.
    type Artist struct {
    	Albums      *int    `json:"albums,omitempty"`
    	ArtistGenre *string `json:"artist_genre,omitempty"`
    	ArtistName  *string `json:"artist_name,omitempty"`
    	Username    string  `json:"username"`
    }
    
    // GetArtistListParams defines parameters for GetArtistList.
    type GetArtistListParams struct {
    	// Limit Limits the number of items on a page
    	Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
    
    	// Offset Specifies the page number of the artists to be displayed
    	Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
    }
    

インターフェースとモックの作成

インターフェースの作成

  1. interfaces/ディレクトリにclient_interface.goを作成します
    oapi-codegen-test % touch interfaces/client_interface.go
    oapi-codegen-test % ls interfaces/client_interface.go   
    interfaces/client_interface.go
    oapi-codegen-test % 
    
  2. client_interface.goを実装します
    // interfaces/client_interface.go
    package interfaces
    
    import (
    	"context"
    	"net/http"
    	"oapi-codegen-test/client"
    )
    
    // ClientInterface defines the methods for the API client.
    type ClientInterface interface {
    	GetArtistList(ctx context.Context, params *client.GetArtistListParams) (*http.Response, error)
    }
    

モックの生成

  1. モックを生成します
    oapi-codegen-test % mockgen -source=interfaces/client_interface.go -destination=interfaces/client_mock.go -package=interfaces
    oapi-codegen-test % 
    
  2. 生成したコードを確認します
    // Code generated by MockGen. DO NOT EDIT.
    // Source: interfaces/client_interface.go
    //
    // Generated by this command:
    //
    //	mockgen -source=interfaces/client_interface.go -destination=interfaces/client_mock.go -package=interfaces
    //
    
    // Package interfaces is a generated GoMock package.
    package interfaces
    
    import (
    	context "context"
    	http "net/http"
    	client "oapi-codegen-test/client"
    	reflect "reflect"
    
    	gomock "go.uber.org/mock/gomock"
    )
    
    // MockClientInterface is a mock of ClientInterface interface.
    type MockClientInterface struct {
    	ctrl     *gomock.Controller
    	recorder *MockClientInterfaceMockRecorder
    	isgomock struct{}
    }
    
    // MockClientInterfaceMockRecorder is the mock recorder for MockClientInterface.
    type MockClientInterfaceMockRecorder struct {
    	mock *MockClientInterface
    }
    
    // NewMockClientInterface creates a new mock instance.
    func NewMockClientInterface(ctrl *gomock.Controller) *MockClientInterface {
    	mock := &MockClientInterface{ctrl: ctrl}
    	mock.recorder = &MockClientInterfaceMockRecorder{mock}
    	return mock
    }
    
    // EXPECT returns an object that allows the caller to indicate expected use.
    func (m *MockClientInterface) EXPECT() *MockClientInterfaceMockRecorder {
    	return m.recorder
    }
    
    // GetArtistList mocks base method.
    func (m *MockClientInterface) GetArtistList(ctx context.Context, params *client.GetArtistListParams) (*http.Response, error) {
    	m.ctrl.T.Helper()
    	ret := m.ctrl.Call(m, "GetArtistList", ctx, params)
    	ret0, _ := ret[0].(*http.Response)
    	ret1, _ := ret[1].(error)
    	return ret0, ret1
    }
    
    // GetArtistList indicates an expected call of GetArtistList.
    func (mr *MockClientInterfaceMockRecorder) GetArtistList(ctx, params any) *gomock.Call {
    	mr.mock.ctrl.T.Helper()
    	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetArtistList", reflect.TypeOf((*MockClientInterface)(nil).GetArtistList), ctx, params)
    }
    

テスト用ヘルパー関数の実装

テストデータの再利用

  1. client_test.goを作成します
    oapi-codegen-test % touch client/tests/client_test.go
    oapi-codegen-test % ls client/tests/client_test.go 
    client/tests/client_test.go
    oapi-codegen-test % 
    
  2. 実装します
    // client_test.go
    package client_test
    
    import (
    	"oapi-codegen-test/client"
    	"testing"
    )
    
    // NewTestArtist creates a default test artist object for testing.
    func NewTestArtist() *client.Artist {
    	return &client.Artist{
    		ArtistName:  stringPointer("Test Artist"),
    		ArtistGenre: stringPointer("Pop"),
    		Albums:      intPointer(3),
    		Username:    "test_artist",
    	}
    }
    
    func stringPointer(s string) *string {
    	return &s
    }
    
    func intPointer(i int) *int {
    	return &i
    }
    
    func TestGetArtistList(t *testing.T) {
    	testArtist := NewTestArtist()
    
    	// テスト処理でtestArtistを使用して検証
    	if *testArtist.ArtistName != "Test Artist" {
    		t.Errorf("Expected artist name to be 'Test Artist'")
    	}
    }
    

テストの実行

  1. テストを実行します
    oapi-codegen-test % go test ./client/tests
    ok      oapi-codegen-test/client/tests  0.365s
    oapi-codegen-test % 
    

main.go用のエンドツーエンドテストの実装

  1. main_testの作成
    oapi-codegen-test % touch e2e/main_test.go
    oapi-codegen-test % ls e2e/main_test.go 
    e2e/main_test.go
    oapi-codegen-test % 
    
  2. 作成します
    // e2e/main_test.go
    package e2e
    
    import (
    	"context"
    	"io/ioutil"
    	"net/http"
    	"oapi-codegen-test/client" // clientパッケージのインポート
    	"strings"
    	"testing"
    	// interfacesパッケージのインポート
    )
    
    // テスト用モッククライアントの実装
    type mockClient struct{}
    
    // GetArtistListのモック実装
    func (m *mockClient) GetArtistList(ctx context.Context, params *client.GetArtistListParams) (*http.Response, error) {
    	return &http.Response{
    		StatusCode: 200,
    		Body:       ioutil.NopCloser(strings.NewReader(`[{"artist_name": "E2E Artist", "artist_genre": "Jazz", "albums": 1, "username": "e2e_artist"}]`)),
    	}, nil
    }
    
    func TestMainFunction(t *testing.T) {
    	// モッククライアントのインスタンスを作成
    	mockClient := &mockClient{}
    
    	// テスト対象関数を実行し、モッククライアントを使用
    	response, err := mockClient.GetArtistList(context.Background(), &client.GetArtistListParams{})
    
    	// エラーチェック
    	if err != nil {
    		t.Fatalf("Expected no error, got %v", err)
    	}
    
    	// ステータスコードとレスポンス内容の検証
    	if response.StatusCode != http.StatusOK {
    		t.Errorf("Expected status code 200, got %v", response.StatusCode)
    	}
    
    	body, _ := ioutil.ReadAll(response.Body)
    	if !strings.Contains(string(body), "E2E Artist") {
    		t.Errorf("Expected response to contain 'E2E Artist'")
    	}
    }
    
  3. テストを実行します
    oapi-codegen-test % go test ./e2e
    ok      oapi-codegen-test/e2e   0.402s
    oapi-codegen-test % 
    

考察

今回、oapi-codegenによってAPIクライアントとデータモデルを自動生成し、uber-go/mockを活用してテスト用モックを作成することで、エンドツーエンドテストと単体テストを実装しました。oapi-codegenの自動生成を利用することで、API仕様が変更された際にも手動でクライアントコードを修正する手間を削減できるのは良いと感じました。

また、uber-go/mockによるモック化によって、サーバー側の依存がなくてもAPIの動作を再現できるのは便利でした。効率的にかいはつできそうで

参考

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?