Prism + Caddyでモックサーバーを作ってみた

こんにちは。システム開発一部の吉田です。

普段バックエンドとクラウドしか触っていないのですが、たまたまVueを使ったフロントエンドを担当することになりました。

CSSをまともに勉強したことが無く、めちゃくちゃ苦手なので悪戦苦闘中です。

今回フロントエンドを開発するにあたってローカル環境でAPI叩きながらコード書いていくという手法を取りたいなと思っていて、モックサーバーが欲しくなったのでPrismとCaddyを組み合わせたモックサーバーを導入してみました。

OpenAPI定義が既に作成済みだったのもあり、導入はサクッとできました。その結果、ローカルでの検証がしやすくなり、開発効率が向上したと感じています。おすすめなので紹介させてください。

Prism とは

PrismはOpenAPI定義に基づいてAPIのモックサーバーを作成してくれるパッケージです。

meta.stoplight.io

API定義に基づいたリクエストのバリデーション、Examplesを返すレスポンス、Responseのスキーマから動的生成した値を返す...などモックサーバーとしての機能が充実しています。動的生成値については後続で説明します。

例によってDockerイメージが配布されているのでこれを使っていきましょう。

hub.docker.com

Prismを触ってみる

この記事ではOpenAPIの定義についてはサンプルを使っていきます。早速docker-compose.yamlを作って起動します。

version: '3'
services:
  prism:
    image: stoplight/prism:4
    network_mode: host
    command: >
      mock -p 4010 --host 0.0.0.0
      https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore.yaml

起動できたらlocahost:4010/petsにリクエストを送ってみると...こんな感じでschemasで定義された通りのレスポンスが返ってきます。

OpenAPIの定義からサクッとモックサーバーを作成することが出来ました。しかし、API定義によるとGET /petsはペットのリストを返すパスなので、データが1つしか返ってこないのはちょっと困ります。

そこで、Schema定義に沿って動的生成した値をレスポンスとして返すように設定します。mockのコマンドにdオプションを指定するだけ。

mock -p 4010 --host 0.0.0.0mock -d -p 4010 --host 0.0.0.0

このときのレスポンスは以下の通り。idに数値、nameとtagにはLorem ipsumっぽい文字列が入っているデータのリストが返ってきました。リクエストを送るたびにレスポンスの中身は変わります。

動的に生成される値はschemasを元に決まるので、schemasの中でformatなどを指定するとより想定に近い値が返ってきます。

先ほどのAPI定義にちょっとだけ手を加えて生成値の変化を見てみましょう。

docker-compose.yamlと同じディレクトリにpetstore.ymlとして配置します。

長いので折り畳み

openapi: "3.0.0"
info:
  version: 1.0.0
  title: Swagger Petstore
  license:
    name: MIT
servers:
  - url: http://petstore.swagger.io/v1
paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
      tags:
        - pets
      parameters:
        - name: limit
          in: query
          description: How many items to return at one time (max 100)
          required: false
          schema:
            type: integer
            maximum: 100
            format: int32
      responses:
        '200':
          description: A paged array of pets
          headers:
            x-next:
              description: A link to the next page of responses
              schema:
                type: string
          content:
            application/json:    
              schema:
                $ref: "#/components/schemas/Pets"
              examples:
                example1:
                  $ref: '#/components/examples/get-pets-1'
                example2:
                  $ref: '#/components/examples/get-pets-2'
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      summary: Create a pet
      operationId: createPets
      tags:
        - pets
      responses:
        '201':
          description: Null response
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /pets/{petId}:
    get:
      summary: Info for a specific pet
      operationId: showPetById
      tags:
        - pets
      parameters:
        - name: petId
          in: path
          required: true
          description: The id of the pet to retrieve
          schema:
            type: string
      responses:
        '200':
          description: Expected response to a valid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pet"
              examples:
                example1:
                  $ref: '#/components/examples/get-pets-petid-1'
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
components:
  schemas:
    Pet:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: integer
          format: int64
          minimum: 1
          maximum: 100
        name:
          type: string
          pattern: '^([a-z]{1,10})$'
        tag:
          type: string
          enum:
            - cat
            - dog
            - hamster
            - rabbit
        mailAddress:
          type: string
          format: email
    Pets:
      type: array
      maxItems: 100
      items:
        $ref: "#/components/schemas/Pet"
    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: integer
          format: int32
        message:
          type: string
  examples:
    get-pets-1:
      value:
        - id: 1
          name: max
          tag: dog
          email: example1@example.com
        - id: 2
          name: charlie
          tag: rabbit
          email: example2@example.com
        - id: 3
          name: bella
          tag: hamster
          email: example3@example.com
        - id: 4
          name: lily
          tag: cat
          email: example4@example.com
    get-pets-2:
      value:
        - id: 5
          name: kitty
          tag: cat
          email: example5@example.com
        - id: 6
          name: daisy
          tag: rabbit
          email: example6@example.com
        - id: 7
          name: sasha
          tag: cat
          email: example7example.com
    get-pets-petid-1:
      value:
        id: 1
        name: kitty
        tag: cat
        email: example1@example.com

変更内容

  • idに最大値と最小値を設定
  • nameに正規表現を適用
  • tagにenumを設定
  • format: emailのmailAddressのSchema追加
  • GET /petsのレスポンスにexampleを2つ追加
  • GET /pets/{petid}のレスポンスにexampleを1つ追加

docker-compose.yamlも合わせて書き替えて、ローカルの定義ファイルを読み込むようにします。

version: '3'
services:
  prism:
    image: stoplight/prism:4
    network_mode: host
    volumes:
      - ./petstore.yaml:/tmp/petstore.yaml
    command: mock -d -p 4010 --host 0.0.0.0 /tmp/petstore.yaml

これで起動して先ほどと同じパスにリクエストを送ってみると...変更前のレスポンスと違って、id, name, tagが規定された通りに返って来ているのが分かると思います。

追加したmailAddressもメールアドレスっぽいフォーマットで返って来ていますね。このように、OpenAPI定義のSchemaを詳細化するとより効果的にPrismを利用できます。

また、↑ような動的生成の値ではなく、exampleで設定した値がレスポンスとして欲しいときがあると思います。その際はdオプションを外す、あるいはリクエストヘッダーにPrefer: example=example2のような形で欲しいexample名を指定してあげます。exampleが定義済みかつdオプションを外したときは一番上に定義されたexampleがレスポンスになります。

Preferについては公式のドキュメントで言及されています。詳細は以下を参照してください。

docs.stoplight.io

補足ですが、dオプションを指定していた場合でもPreferの指定が優先される仕様のようです。(dオプション指定時にPrefer: example=example2を設定した場合、example2がレスポンスとして返ってきます。)

そのほかに、Perefer: dynamic=trueで動的生成値を返すようにしたり、Prefer: code=400でエラーの場合のレスポンスを返すように設定することもできます。便利。

とはいえ、リクエストヘッダーをいちいち意識しないといけないのは正直めんどくさいです。axiosでAPIコールをする場合、ヘッダーを追加するにはリクエスト部分の実装を書き換えないといけません。

動的生成値とexample1つを返したいのであればdocker-composeでdオプション付きとそうでないものの2つのコンテナをポートを分けて使えばいいのですが、Exampleの2つ目を返したいといった場合はやはりヘッダーを編集する必要があります。

Prismだけはどうにもならないので、Caddyというリバースプロキシを組み合わせることでこのヘッダー問題を解決することにしました。

CaddyとPrismを組みあわせる

Caddyの説明をすると長くなってしまうので本記事では省きます。超ざっくりした説明をするとWebサーバとリバースプロキシとロードバランサーの機能を持ったソフトウェアです。個人的な感想ですが、リバースプロキシとしてはSquidより使い方がわかりやすくて良いです。

Prismと同じく公式のドキュメントが充実しているので詳細はそちらを参照してください。

caddyserver.com

まずはdocker-composeから書き換えていきます。ここではproxyというコンテナを追加で立てています。ついでにポートも変更。2つのコンテナを同じdocker network上に乗せて疎通させます。

version: '3'
services:
  prism:
    image: stoplight/prism:4
    volumes:
      - ./petstore.yaml:/tmp/petstore.yaml
    command: mock -d -p 4010 --host 0.0.0.0 /tmp/petstore.yaml
    ports:
      - 8081:4010
  proxy:
    image: caddy
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
    ports:
      - '8080:80'
    depends_on:
      - prism

Caddyfileという名前のファイルでcaddyの振る舞いを定義します。docker-compose.yamlと同じディレクトリに以下で作成します。

http://localhost {
    handle_path /dynamic/* {
        request_header +Prefer dynamic=true
        reverse_proxy prism:4010
    }
    handle_path /example1/* {
        request_header +Prefer example=example1
        reverse_proxy prism:4010
    }
    handle_path /example2/* {
        uri strip_prefix /example2
        request_header +Prefer example=example2
        reverse_proxy prism:4010
    }
    handle_path /400/* {
        request_header +Prefer code=400
        reverse_proxy prism:4010
    }
}

記述内容からもなんとなくやりたいことが見えてくると思います。Caddyをリバースプロキシとして、パスごとにPreferのリクエストヘッダーを付与して振り分ける方式としました。

この時返ってくるレスポンスは以下の通り。

  • GET localhost:8080/dynamic/pets → 動的な値
  • GET localhost:8080/example1/pets → exapmle1の値
  • GET localhost:8080/example2/pets → exapmle2の値
  • GET localhost:8080/400/pets → 400エラー(今回のAPI定義だと、defaultとして定義しているレスポンス)

パスで振り分けが可能なのでAPIコール先のURLを環境変数として持てば、axiosの実装に手を加えることなく検証を進めることが可能です。とても使いやすくなりました。

さらなる応用

ここまでの状態でも実用で耐えうる形にはなっていると思います。あくまでローカルで使うためのモックですし。

しかし、私の場合、/dynamicをアクセス先としているときにログインで使っているAPIパスのレスポンスも動的な値で返って来てしまってログインの検証が上手く行かないという壁に当たりました。SPAでログイン機能を先に作ったのでSPAをローカルで動かした際にログインが出来ないとその先の検証が上手く行かない状態になってしまった...という流れです。

表現が分かりにくくてすみません。噛み砕くと、/dynamic、/400にアクセスした時でも一部のパス、一部のメソッドだけはexampleの値が返ってくるようにしないといけませんでした。そのためにCaddyfileにさらに手を加えることにしました。

今回のAPI定義におけるCaddyfileの設定例を記載します。GET /pets/{petId}をexample1のレスポンスにするという変更内容になります。

http://localhost {
    @get_pets_petid_path {
        path */pets/*
        method GET
    }
    route @get_pets_petid_path {
        rewrite * /pets/1
        request_header +Prefer example=example1
        reverse_proxy prism:4010
    }
    route * {
        handle_path /dynamic/* {
            request_header +Prefer dynamic=true
            reverse_proxy prism:4010
        }
        handle_path /example1/* {
            request_header +Prefer example=example1
            reverse_proxy prism:4010
        }
        handle_path /example2/* {
            request_header +Prefer example=example2
            reverse_proxy prism:4010
        }
        handle_path /400/* {
            request_header +Prefer code=400
            reverse_proxy prism:4010
        }
    }
}

この状態でcomposeを起動してGET localhost:8080/dynamic/pets/{petId}にリクエストを送ってみると...example1で指定した値が返ってきます。 GET localhost:8080/dynamic/petsのレスポンスは動的生成値のままです。

このようにCaddyfile内部でRouteを分けて設定することによってハンドルするパスの評価順を任意に設定することが出来ます。

(通常上からの評価になるのですが、変数で宣言しているパスマッチャーだけ上からの評価になりませんでした。)

まとめ

今回はPrismの説明とPrism単体だと使いにくい部分をCaddyでカバーする方法をご紹介しました。いかがだったでしょうか?

Prism単体の紹介記事は多いです。が、Caddyと組み合わせて紹介しているところが少なかったので取り上げてみました。

OpenAPIといった標準的なスキーマを元に開発を進める手法のことをスキーマ駆動開発というらしいですね。調べている途中に見つけました。(詳しくはスキーマ駆動開発でググってみて欲しいです。)

使いやすいモックがあれば実際にAPIを叩きつつコードを書けるので、今回のモックサーバーはスキーマ駆動開発を促進させるツールとして適切なのではないでしょうか。ぜひ使ってみてください。