Go言語におけるOpenAPIを使ったレスポンス検証

f:id:vasilyjp:20200612192011j:plain

こんにちは、ECプラットフォーム部の権守です。普段はZOZOTOWNのリプレイスに関わるID基盤とAPI Gatewayの開発を行っています。
ID基盤やAPI Gatewayの中身についてもいずれ紹介したいと思いますが、本記事では、ID基盤のAPI開発で取り入れているGo言語におけるOpenAPIを使ったレスポンス検証について紹介します。

OpenAPIを使ったレスポンス検証

OpenAPI Specification(以下、OpenAPIと表記します)はREST APIのためのプログラミング言語に依存しない標準的なインタフェース記述言語です。OpenAPIについては以前にこちらの記事でも取り上げましたので、合わせて読んでいただければと思います。

弊社では、新規で開発するAPIについてはOpenAPIを用いて仕様書を作成しており、ID基盤もそうして社内にAPI仕様書を提供しています。
OpenAPIは非常に素晴らしいものですが、実装側が仕様書通りのレスポンスを担保できなければ台無しになってしまいます。以前、こちらの記事では同様の問題について、Rubyにおいてシリアライザを自動生成することで解決を試みた事例を紹介しました。ID基盤はGoを用いて開発しており、同じ方法が取れなかったため、実装したAPIが仕様に沿ったレスポンスを返せているかどうかのテストコードを簡単に書けるようにしました。

実装にあたり、kin-openapiというパッケージを利用しました。OpenAPIに関するパッケージは様々ありますが、OpenAPIの最新バージョンである3系に対応しており、レスポンスを検証する機能を備えているものはkin-openapi以外見つけられませんでした。

次に、kin-openapiを利用したテスト方法について例を用いながら紹介します。

kin-openapiの使用例

まず、次のような仕様のAPIを実装することを考えます。

# openapi.yaml
openapi: "3.0.3"
info:
  title: api example
  version: 1.0.0
paths:
  /users/{id}:
    get:
      parameters:
        - name: "id"
          in: "path"
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: ユーザー情報
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/user"
components:
  schemas:
    user:
      type: object
      required:
        - id
        - nickname
      properties:
        id:
          type: integer
          example: 1
        nickname:
          type: string
          example: ゾゾ
        age:
          type: integer
          example: 22

ユーザー情報を返すだけのシンプルなAPIです。次にこれを満たすAPIを実装します。ここでは説明の簡単化のためレスポンスをモックとします。

// main.go
package main

import (
    "net/http"

    "validate-response-sample/router"
)

func main() {
    http.ListenAndServe(":8080", router.Router)
}

標準のhttpパッケージを用いてWebサーバーを立ち上げます。ルーティングは次のrouter.goで定義したRouterを用います。

// router.go
package router

import (
    "net/http"

    "github.com/gorilla/mux"
)

var Router = func() *mux.Router {
    r := mux.NewRouter().StrictSlash(true)
    r.HandleFunc("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"id": 3, "nickname": "マックス", "age": 18}`))
    })
    return r
}()

httpパッケージにはパスパラメータを扱う実装は含まれていないので、ここではGorillaのルーターを使っています。

モックを返すAPIを実装できたので、次に本題のテストコードについて説明します。

// main_test.go
package main_test

import (
    "net/http"
    "testing"

    testingHelper "validate-response-sample/testing"
)

func TestUserRequest(t *testing.T) {
    req, e := http.NewRequest(http.MethodGet, "/users/3", nil)
    if e != nil {
        panic(e)
    }

    e = testingHelper.TestRequest(req)
    if e != nil {
        t.Error(e)
    }
}

ユーザーAPIへのリクエストを作成し、レスポンス検証のために用意したヘルパー関数であるTestRequestへ渡します。TestRequest関数は実際にHTTPリクエストを行い、受け取ったレスポンスが期待するレスポンス形式に沿っていないとエラーを返します。TestRequest関数の具体的な実装は以下になります。

// helpers.go
package testing

import (
    "context"
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "net/url"

    "validate-response-sample/router"
    "github.com/getkin/kin-openapi/openapi3filter"
)

func TestRequest(request *http.Request) error {
    ts := httptest.NewServer(router.Router)
    defer ts.Close()

    openAPIRouter := openapi3filter.NewRouter().WithSwaggerFromFile("openapi.yaml")
    route, pathParams, e := openAPIRouter.FindRoute(request.Method, request.URL)
    if e != nil {
        return e
    }

    u, _ := url.Parse(ts.URL)
    request.URL.Scheme = u.Scheme
    request.URL.Host = u.Host
    response, e := http.DefaultClient.Do(request)
    if e != nil {
        return e
    }
    defer response.Body.Close()

    body, e := ioutil.ReadAll(response.Body)
    if e != nil {
        return e
    }

    requestValidationInput := &openapi3filter.RequestValidationInput{
        Request:    request,
        PathParams: pathParams,
        Route:      route,
    }

    responseValidationInput := &openapi3filter.ResponseValidationInput{
        RequestValidationInput: requestValidationInput,
        Status:                 response.StatusCode,
        Header:                 response.Header,
    }
    responseValidationInput.SetBodyBytes(body)

    return openapi3filter.ValidateResponse(context.TODO(), responseValidationInput)
}

次の手順で処理を行っています。

  1. テスト用サーバーを立ち上げ
  2. openapi.yamlの読み込みとリクエストに該当する記述の探索
  3. 受け取ったリクエストのテストサーバー用のURLへの書き換え
  4. 実際のリクエストとレスポンスの受け取り
  5. 返ってきたレスポンスとopenapi.yamlに記述されたレスポンス形式が一致するかの確認

これでOpenAPIを用いて作成した仕様書と実装が食い違うことを防ぐテストを簡単に書けるようになりました。

しかし、運用しているとある問題に遭遇しました。その問題と対応した方法について紹介していきます。

OpenAPIの拡張とその対応

OpenAPIではステータスコード毎に1つのレスポンスしか記載できません。しかし、実際には同じステータスコードに対して複数のレスポンス形式があることは珍しくありません。
例えば、ユーザー情報のGETリクエストにおいて本人にしか返さない項目があると、同じ200のステータスであっても本人からのリクエストかどうかでレスポンス形式が異なります。また別例として銀行口座からお金を引き出すAPIの例を考えると、同じ403のステータスでも本人以外の処理によるエラーと残高不足によるエラーではレスポンス形式は異なると考えられます。

そこで、Responses Objectに対してx-ステータスコード-タイトルといった形式のフィールドを追加して複数のレスポンスを表現することにしました。OpenAPIではx-を接頭辞につけたフィールドを定義することで仕様を拡張することが許されています。具体的には次のように書きます。

# openapi.yaml
openapi: "3.0.3"
info:
  title: api example
  version: 1.0.0
paths:
  /users/{id}:
    get:
      parameters:
        - name: "id"
          in: "path"
          required: true
          schema:
            type: integer
      responses:
        "x-200-Self":
          description: 自分のユーザー情報
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/self_user"
        "x-200-Other":
          description: 他人のユーザー情報
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/other_user"
components:
  schemas:
    self_user:
      type: object
      required:
        - id
        - name
        - nickname
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: 像造太郎
        nickname:
          type: string
          example: ゾゾ
        birthday:
          type: string
          format: date
          example: 1998-05-21
    other_user:
      type: object
      required:
        - id
        - nickname
      properties:
        id:
          type: integer
          example: 1
        nickname:
          type: string
          example: ゾゾ
        age:
          type: integer
          example: 22

こうすることでOpenAPIの文法に準拠したまま1つのステータスコードに対して複数のレスポンス形式を記載できるようになりました。
しかし、これは独自拡張のため、このままでは導入したテストが動作しません。そこで、次のようにヘルパー関数を実装し直しました。

// helpers.go
package testing

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io/ioutil"
    "mime"
    "net/http"
    "net/http/httptest"
    "net/url"
    "regexp"
    "strconv"

    "validate-response-sample/router"
    "github.com/getkin/kin-openapi/openapi3filter"
)

func TestRequest(request *http.Request, responseKey string) error {
    ts := httptest.NewServer(router.Router)
    defer ts.Close()

    openAPIRouter := openapi3filter.NewRouter().WithSwaggerFromFile("openapi.yaml")
    route, _, e := openAPIRouter.FindRoute(request.Method, request.URL)
    if e != nil {
        return e
    }

    u, _ := url.Parse(ts.URL)
    request.URL.Scheme = u.Scheme
    request.URL.Host = u.Host
    response, e := http.DefaultClient.Do(request)
    if e != nil {
        return e
    }
    defer response.Body.Close()

    e = validateResponse(response, route, responseKey)
    if e != nil {
        e = fmt.Errorf("%v %v, %v: %v", request.Method, request.URL.Path, responseKey, e.Error())
    }
    return e
}

func validateResponse(response *http.Response, route *openapi3filter.Route, key string) error {
    // validate status
    // ...

    // find expected response
    // ...

    // validate headers
    // ...

    // validate body
    // ...
}

TestRequest関数の大きな変更点は2つあります。まず、http.Request以外にレスポンスのキーを受け取ることです。元の実装ではレスポンスのステータスコードから期待するレスポンス形式が自動的にわかりましたが、拡張したことによってステータスコードからは一意に決まらなくなりました。そのため、期待するレスポンスのキーを指定する必要があります。
次に、kin-openapiが提供するvalidate関数の代わりに独自実装したvalidate関数を呼び出すことです。validate関数の中身について少しずつ説明していきます。

まずはステータスコードの検証部分です。

   status := response.StatusCode
    var expectedStatus int
    re := regexp.MustCompile("^[0-9]{3}$")
    if re.MatchString(key) {
        i, _ := strconv.Atoi(key)
        expectedStatus = i
    } else {
        re := regexp.MustCompile("^x-([0-9]{3})-.+$")
        s := re.ReplaceAllString(key, "$1")
        if s == "" {
            return errors.New("illegal response key")
        }
        i, _ := strconv.Atoi(s)
        expectedStatus = i
    }

    if status != expectedStatus {
        return fmt.Errorf("want %v status, but got %v status", expectedStatus, status)
    }

レスポンスのキーとして従来通りのステータスコードを指定する場合と、拡張したキーを指定する場合を正規表現を用いて分岐しています。拡張したキーに関しては正規表現を用いてステータスコードの抽出も行っています。
OpenAPIではレスポンスのキーとしてdefaultや200系を示す2XXなどを設定できますが、厳密な仕様とするためにここではそれらを禁止しています。

次に、レスポンス仕様の取得部分です。

   responses := route.Operation.Responses
    responseRef := responses[key]
    if responseRef == nil {
        return errors.New("the response key is not documented")
    }
    expectedResponse := responseRef.Value
    if expectedResponse == nil {
        return errors.New("reference of response has not been resolved")
    }

引数として受け取っているrouteは、リクエスト内容から該当する仕様を探したものです。そこから、レスポンス仕様を取り出します。
OpenAPI 3では$refという表記を使うことで、componentsに記載した内容を参照できます。kin-openapiでは、この$refを扱うために参照用の構造体を挟んで、実体はValueフィールドに持っています。参照を解決できていない場合にはValueフィールドの値がnilになるため、そのチェックをしています。

次はレスポンスヘッダーの検証です。

   for k, v := range expectedResponse.Headers {
        h := response.Header.Get(k)
        expectedHeader := v.Value
        if expectedHeader == nil {
            return errors.New("reference of header has not been resolved")
        }
        if expectedHeader.Schema == nil {
            return fmt.Errorf("header schema of %v is not documented", k)
        }
        expectedHeaderSchema := expectedHeader.Schema.Value
        if expectedHeaderSchema == nil {
            return errors.New("reference of schema has not been resolved")
        }
        if e := expectedHeaderSchema.VisitJSON(h); e != nil {
            return e
        }
    }

レスポンス仕様に記載されたヘッダーが実際のレスポンスに含まれているかを検証します。各ヘッダーのSchema Objectを取得し、VisitJSONメソッドに実際の値を渡すことでOpenAPI上のスキーマに沿っているかを検証できます。実際のレスポンスヘッダーの値はキーを元に取得します。

最後にレスポンスボディの検証です。

   body, e := ioutil.ReadAll(response.Body)
    if e != nil {
        return e
    }

    content := expectedResponse.Content
    if len(content) == 0 {
        if string(body) == "" {
            return nil
        }
        return errors.New("content of the key is not documented")
    }

    mediaType, _, e := mime.ParseMediaType(response.Header.Get("Content-Type"))
    if e != nil {
        return e
    }
    mediaTypeObject := content.Get(mediaType)
    if mediaTypeObject == nil {
        return errors.New("unmatched Content-Type")
    }
    if mediaTypeObject.Schema == nil {
        return errors.New("content schema of the key is not documented")
    }
    bodySchema := mediaTypeObject.Schema.Value
    if bodySchema == nil {
        return errors.New("reference of schema has not been resolved")
    }

    var decodedBody interface{}
    if e = json.NewDecoder(bytes.NewBuffer(body)).Decode(&decodedBody); e != nil {
        return e
    }
    return bodySchema.VisitJSON(decodedBody)

まず、レスポンスのボディを全て読み込んで変数に格納します。ボディが空の場合はそれが期待通りかどうかのチェックとしてContentフィールドも空であることを確認します。ContentフィールドはMedia Type毎にレスポンスボディのスキーマを持ちますが、レスポンスボディがないAPIであればContentフィールドは空になります。この場合はこれ以上検証するものがないので処理を終了します。

次に、ボディが空でない場合はレスポンスのContent-TypeヘッダーからMedia Typeを判別します。判別にはmimeパッケージのParseMediaType関数を用いています。ParseMediaTypeはContent-Typeヘッダーに含まれるパラメータも取得できますが、ここでは使わないのでMedia Typeだけを変数に格納しています。
Media Typeがわかったので、その値を用いて該当するレスポンスボディのスキーマを取得します。ここでもヘッダーの検証で用いたVisitJSONメソッドを使いますが、ヘッダーの値がただの文字列なのに対し、レスポンスボディはJSON文字列なので事前にデコードをしています。

これらによってレスポンスのキーを拡張した仕様書を使った場合でもレスポンスの検証ができるようになりました。

まとめ

GoにおいてOpenAPIを使ったレスポンス検証を行うためにkin-openapiパッケージを使った方法を紹介しました。また、OpenAPIの表現力では足りないレスポンス表現についての拡張とそれに対応した検証方法を紹介しました。
Goで開発をしていてAPI仕様書と実装の食い違いに頭を抱えている方はぜひ試してみてください。

最後に、ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

tech.zozo.com

カテゴリー