フロント開発で使うAPIモック – stubby

フロント開発を行う際に API 呼び出しをする機会があると思います。フロントを開発するのに集中したいのに API の環境を準備するなどの時間を使いたくはないですね。

そこで今回は API のモックサーバを stubby を利用して準備する手順を書いていきます。

今回の手順はすべて Mac 上での手順となります。Winodws, Ubuntuでの動作確認はしておりませんのでご注意を。

プロジェクトの準備

Node.js のインストール

個人的には nodebrew での Node.js のバージョン管理がおすすめです。

インストール方法は上記のリンクかこちらのQiitaの記事を参考にしてください。

Yarn のインストール (npm でもいいよ)

Node.js をインストールすれば npm が一緒にインストールされるのでそれでも問題ないですが、個人的には Yarn の方が色々と早い気がするのでこちらを使います。

インストール方法は公式ドキュメントを参照してください。

プロジェクトの作成

今回は React を利用したプロジェクトを例にして、API モックサーバを利用していきます。

ではプロジェクトを作成していきましよう。

yarn global add create-react-app
create-react-app front-mock --typescript
cd front-mock
yarn start

これで自動的に React アプリのベースが作られ、自動的にブラウザが起動します。起動したら React ロゴが回転する画面が表示されます。

stubby の準備

さて、stubby をインストールをして利用する準備を行いましよう。

yarn start して起動中だと思うので、Ctrl + C で止めて、stubby のインストールです。

yarn add stubby

stubby の定義を書く

stubby を利用するには API の定義を書く必要があります。yml ファイルに記述していきます。

mkdir -p mock/stubby
cd mock/stubby/
vi api.yml

api.yml の中はこちらを参照しつつ、記述してください。サンプルに1つの API を記述します。

- request:
    method: GET
    url: ^/api/books$
  response:
    status: 200
    latency: 1000
    headers:
      content-type: application/json
    body: '[
             {
               "id": "eb10484d-698f-43a3-b269-02ba810aa936",
               "name": "7つの習慣",
               "locations": [
                 "会社",
                 "Aさんの家",
                 "Bさんの家"
               ]
             },
             {
               "id": "3ad46400-4757-4f6b-98df-0bebd597c1e4",
               "name": "ティール組織",
               "locations": [
                 "会社"
               ]
             },
             {
               "id": "e68df6eb-8e7c-47fc-9abd-174b201f3407",
               "name": "Amazon Web Services実践入門",
               "locations": [
                 "会社"
               ]
             },
             {
               "id": "052aa34c-5bae-484b-8194-e4df23295787",
               "name": "改訂2版 みんなのGo言語",
               "locations": [
                 "未達"
               ]
             }
           ]'

stubby を起動するコマンドを記述

これまでで API 定義は完了です。次に stubby を起動させるためのコマンド(?)を記述します。

package.jsonscripts に新しくコマンドを追加します。

"mock": "./node_modules/stubby/bin/stubby -d ./mock/stubby/api.yml"

コマンドを追加したので、起動してみましょう!

$ yarn mock
yarn run v1.16.0
$ ./node_modules/stubby/bin/stubby -d ./mock/stubby/api.yml
Loaded: GET ^/api/books$

Quit: ctrl-c

Stubs portal running at https://0.0.0.0:7443
Stubs portal running at http://0.0.0.0:8882
Admin portal running at http://0.0.0.0:8889

色々と出力されますがポイントは2つ。

1つ目は Loaded: GET ^/api/books$。正しく API 定義がされていれば有効になっている API のメソッドとパスが表示されます。

2つ目は Stubs portal running at http://0.0.0.0:8882。このポートにアクセスすれば、今回定義した API を呼ぶことができます。

試しに http://0.0.0.0:8882/api/books にアクセスしましょう。ブラウザでアクセスしても、curl でコールでもどちらでも OK です。

定義した body が表示されると思います。

定義をもっとカスタマイズ

先ほど、レスポンスの json を直書きしました。このまま API の数が増えるとこの定義ファイルが見にくくなります。なのでレスポンスの json は別ファイルに書きましょう。

$ ls -l
total 8
-rw-r--r--  1 yanou  staff  1108  7 21 15:52 api.yml
$ mkdir response
$ cd response
$ vi get_books.json

get_books.json には先程の定義ファイルの body に書いた内容をコピーします。その後、定義ファイルを修正します。

- request:
    method: GET
    url: ^/api/books$
  response:
    status: 200
    latency: 1000
    headers:
      content-type: application/json
    file: response/get_books.json

これで再度 yarn mock をしてして、http://0.0.0.0:8882/api/booksにアクセスしてみましょう。結果は変わらないと思います。

モックの API を呼ぶ

モックの準備ができたところでアプリケーションから API を呼び出して見ましよう。API 呼び出しには axios (アクシオス) を利用します。

yarn add axios

App.tsx で API 呼び出しをしましょう。

編集後の App.tsx はこちら。

import React, { useEffect, useState } from 'react';
import axios, { AxiosResponse } from 'axios';
import './App.css';

class Book {
  id: string;
  name: string;
  locations: string[];

  constructor(id: string, name: string, locations: string[]) {
    this.id = id;
    this.name = name;
    this.locations = locations;
  }
}

const App: React.FC = () => {
  const [books, setBooks] = useState<Book[]>([]);

  useEffect(() => {
    axios.get('http://localhost:8882/api/books')
      .then((res: AxiosResponse<Book[]>) => setBooks(res.data))
      .catch(console.error); // TODO エラー処理
  }, []);

  return (
    <div className="app">
      {books.map((book: Book) => (
        <div key={book.id} className='book-item'>
          <div className='book-id'>{book.id}</div>
          <div className='book-name'>{book.name}</div>
          <div className='book-locations'>{book.locations.join(', ')}</div>
        </div>
      ))}
    </div>
  );
};

export default App;

編集後、yarn start で起動するのですが、その際にモックを起動してほしいので、start コマンドを修正します。

"start": "yarn mock & react-scripts start",

これで yarn start を実行。

CORS な感じがいやな人はこちらも

このままだと http://localhost:3000 から http://localhost:8882 と違うホストへのリクエストとなります。開発時には問題にならないかもしれないですが、ステージングや本番環境で向き先を変える必要があります。環境変数でホストを指定する方法もありますが、もっとシンプルにできるようにしましょう!

まずは package.json を修正。

"proxy": "http://localhost:8882"

この行を追加。

次に App.tsx を修正。

  useEffect(() => {
    axios.get('/api/books')
      .then((res: AxiosResponse<Book[]>) => setBooks(res.data))
      .catch(console.error); // TODO エラー処理
  }, []);

これで OK。自身のホストの /api/books にリクエストを出します。あとは自動的にプロキシをしてくれます。

POST もやってみよう!

POST のリクエストの定義もやってみよう!

stubby/api.yml に追加

- requset:
    method: POST
    url: ^/api/books$
  response:
    status: 201
    latency: 1000
    headers:
      content-type: application/json
    body: '{"id":"2970ce4c-f331-4262-a2ec-3bc8e63ad6a8"}'

App.tsx も編集

import React, { useEffect, useState } from 'react';
import axios, { AxiosResponse } from 'axios';
import './App.css';

class Book {
  id: string;
  name: string;
  locations: string[];

  constructor(id: string, name: string, locations: string[]) {
    this.id = id;
    this.name = name;
    this.locations = locations;
  }
}

const App: React.FC = () => {
  const [books, setBooks] = useState<Book[]>([]);
  const [name, setName] = useState('');
  const [locations, setLocations] = useState('');

  useEffect(() => {
    axios.get('/api/books')
      .then((res: AxiosResponse<Book[]>) => setBooks(res.data))
      .catch(console.error); // TODO エラー処理
  }, []);

  const onClickAdd = () => {
    // TODO 入力チェックなど
    axios.post('/api/books', { name, locations })
      .then((res: AxiosResponse<{ id: string }>) => {
        books.push(new Book(res.data.id, name, locations.split(',')));

        setName('');
        setLocations('');
        setBooks(books);
      })
      .catch(console.error); // TODO エラー処理
  };

  return (
    <div className="app">
      <div>
        {books.map((book: Book) => (
          <div key={book.id} className='book-item'>
            <div className='book-id'>{book.id}</div>
            <div className='book-name'>{book.name}</div>
            <div className='book-locations'>{book.locations.join(', ')}</div>
          </div>
        ))}
      </div>
      <div className='book-form'>
        <div>名前</div>
        <div><input name='name' value={name} onChange={e => setName(e.target.value)}/></div>
        <div>ロケーション (複数の場合はカンマ区切りで)</div>
        <div><input name='locations' value={locations} onChange={e => setLocations(e.target.value)}/></div>
        <div>
          <button onClick={onClickAdd}>追加</button>
        </div>
      </div>
    </div>
  );
};

export default App;

POST の結果が常に同じ値を返すので一部で Warning が出るかもしれないですが、柔軟に対応してください。

PUT もやってみよう!

PUT のリクエストの定義もやってみよう!

stubby/api.yml に追加

- request:
    method: PUT
    url: ^/api/books/(?!.*/).*$
  response:
    status: 200
    latency: 1000
    headers:
      content-type: application/json
    body: '{}'

App.tsx も編集

import React, { useEffect, useState } from 'react';
import axios, { AxiosResponse } from 'axios';
import './App.css';

class Book {
  id: string;
  name: string;
  locations: string[];

  constructor(id: string, name: string, locations: string[]) {
    this.id = id;
    this.name = name;
    this.locations = locations;
  }
}

const App: React.FC = () => {
  const [books, setBooks] = useState<Book[]>([]);
  const [name, setName] = useState('');
  const [locations, setLocations] = useState('');
  const [editBookId, setEditBookId] = useState('');
  const [editBookIndex, setEditBookIndex] = useState(-1);

  useEffect(() => {
    axios.get('/api/books')
      .then((res: AxiosResponse<Book[]>) => setBooks(res.data))
      .catch(console.error); // TODO エラー処理
  }, []);

  const onClickAdd = () => {
    // TODO 入力チェックなど
    axios.post('/api/books', { name, locations })
      .then((res: AxiosResponse<{ id: string }>) => {
        books.push(new Book(res.data.id, name, locations.split(',')));

        setName('');
        setLocations('');
        setBooks(books);
      })
      .catch(console.error); // TODO エラー処理
  };

  const onDoubleClickBookRow = (book: Book, index: number) => {
    setEditBookIndex(index);
    setEditBookId(book.id);
    setName(book.name);
    setLocations(book.locations.join(','));
  };

  const onClickCancelEdit = () => {
    setEditBookIndex(-1);
    setEditBookId('');
    setName('');
    setLocations('');
  };

  const onClickSaveEdit = () => {
    // TODO 入力チェックなど
    axios.put(`/api/books/${editBookId}`, { name, locations })
      .then(() => {
        books[editBookIndex].name = name;
        books[editBookIndex].locations = locations.split(',');

        setEditBookIndex(-1);
        setEditBookId('');
        setName('');
        setLocations('');
        setBooks(books);
      })
      .catch(console.error); // TODO エラー処理
  };

  return (
    <div className="app">
      <div>
        {books.map((book: Book, index: number) => (
          <div key={book.id} className='book-item' onDoubleClick={() => onDoubleClickBookRow(book, index)}>
            <div className='book-id'>
              {editBookIndex === index ? (
                <div>
                  <button onClick={onClickCancelEdit}>キャンセル</button>
                  <button onClick={onClickSaveEdit}>保存</button>
                </div>
              ) : (
                <span>{book.id}</span>
              )}
            </div>
            <div className='book-name'>
              {editBookIndex === index ? (
                <input name='name' value={name} onChange={e => setName(e.target.value)}/>
              ) : (
                <span>{book.name}</span>
              )}
            </div>
            <div className='book-locations'>
              {editBookIndex === index ? (
                <input name='locations' value={locations} onChange={e => setLocations(e.target.value)}/>
              ) : (
                <span>{book.locations.join(', ')}</span>
              )}
            </div>
          </div>
        ))}
      </div>
      {editBookIndex === -1 && (
        <div className='book-form'>
          <div>名前</div>
          <div><input name='name' value={name} onChange={e => setName(e.target.value)}/></div>
          <div>ロケーション (複数の場合はカンマ区切りで)</div>
          <div><input name='locations' value={locations} onChange={e => setLocations(e.target.value)}/></div>
          <div>
            <button onClick={onClickAdd}>追加</button>
          </div>
        </div>
      )}
    </div>
  );
};

export default App;

たまに stubyy が正常終了していない

たまーーに stubby のプロセスが終了していないときがあります。その際は API 呼び出しが失敗します。またコンソールに下記のようなメッセージも出ています。

Port 7443 is already in use! Exiting...
Error: listen EADDRINUSE: address already in use 0.0.0.0:7443
    at Server.setupListenHandle [as _listen2] (net.js:1228:14)
    at listenInCluster (net.js:1276:12)
    at doListen (net.js:1415:7)
    at processTicksAndRejections (internal/process/task_queues.js:77:11) {
  code: 'EADDRINUSE',
  errno: 'EADDRINUSE',
  syscall: 'listen',
  address: '0.0.0.0',
  port: 7443
}

このときは kill コマンドでプロセスを終了してから、再度実行してください。

$ ps aux | grep stubby
xxxxx   56036   0.0  0.0  4276968    836 s000  S+   4:31PM   0:00.00 grep stubby
xxxxx   55943   0.0  0.4  4521068  30276 s000  S    4:26PM   0:00.28 /Users/xxxxx/.nodebrew/node/v12.6.0/bin/node ./node_modules/stubby/bin/stubby -d ./mock/stubby/api.yml
$ kill 55943

最後に

あとは stubby/api.yml の定義を増やしていくことで色々な API を再現できます。

特定のリクエストが来た場合にエラーを返すなどもできるので、頑張ってカスタイマイズしてください。

わからないことがあれば、質問箱から質問をください!