あいつの日誌β

働きながら旅しています。

React + Redux + JWT tutorial(2)

前回でサーバーを準備したので今回はフロント側を準備します。

準備

以下を参考に webpack 関係を準備します。

https://gist.github.com/okamuuu/831b03f1dee84dea8d3893b9c8afabe3

Frontend で実装する機能

Frontend で以下の機能を実装します。

  • /api/sessionsPOST すると access_token を取得できる(通常は username, password を送信する)
  • クライアント側でローカルストレージにトークンを保存する
  • /api/hello へアクセスする
  • /api/private/hello にアクセスする

作業開始

% mkdir src www test
% mkdir src{components,containers}

webpack 関係の設定は下記を参考にしてください。

https://gist.github.com/okamuuu/831b03f1dee84dea8d3893b9c8afabe3

Creating the Redux Store

npm install --save-dev axios

create src/actions.js

export const SET_STATUS_MESSAGE = 'SET_STATUS_MESSAGE'
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'
export const CLEAR_MESSAGE = 'CLEAR_MESSAGE'

export const SET_HELLO = 'SET_HELLO'
export const SET_PRIVATE_HELLO = 'SET_PRIVATE_HELLO'

export const SET_USER = 'SET_USER'

import axios from 'axios';
const BASE_URL = 'http://localhost:3000'

export function fetchHello() {
  // cording rule: https://github.com/acdlite/redux-actions
  return (dispatch) => {
    dispatch({type: CLEAR_MESSAGE})
    dispatch({type: SET_STATUS_MESSAGE, payload: 'start fetching hello'})
    return axios.get(`${BASE_URL}/api/hello`).then((response) => {
      dispatch({type: SET_HELLO, payload: response.data })
      dispatch({type: SET_STATUS_MESSAGE, payload: 'finish fetching hello'})
    }).catch((err) => {
      console.error(err.message)
      dispatch({type: CLEAR_MESSAGE})
      dispatch({type: SET_ERROR_MESSAGE, payload: 'fail fetching hello'})
    })
  };
}

export function fetchPrivateHello() {

  let token = localStorage.getItem('id_token') || null
  let config = {};
  if (token) {
    config = { headers: {'Authorization': `Bearer ${token}` }}
  }

  return (dispatch) => {

    dispatch({type: CLEAR_MESSAGE})
    dispatch({type: SET_STATUS_MESSAGE, payload: 'start fetching private hello'})
    return axios.get(`${BASE_URL}/api/private/hello`, config).then((response) => {
      dispatch({type: SET_PRIVATE_HELLO, payload: response.data })
      dispatch({type: SET_STATUS_MESSAGE, payload: 'finish fetching private hello'})
    }).catch((err) => {
      console.error(err.message)
      dispatch({type: CLEAR_MESSAGE})
      dispatch({type: SET_ERROR_MESSAGE, payload: 'fail fetching private hello'})
    })
  };
}

export function login() {
  return (dispatch) => {
    dispatch({type: CLEAR_MESSAGE})
    dispatch({type: SET_STATUS_MESSAGE, payload: 'start login'})
    return axios.post(`${BASE_URL}/api/sessions`).then((response) => {
      localStorage.setItem('id_token', response.data.id_token)
      // hard-cording
      dispatch({type: SET_USER, payload: { userName: 'Tarou', isAuthenticated: true }})
      dispatch({type: SET_STATUS_MESSAGE, payload: 'finish login'})
    }).catch((err) => {
      console.error(err.message)
      dispatch({type: CLEAR_MESSAGE})
      dispatch({type: SET_ERROR_MESSAGE, payload: 'fail login'})
    })
  };
}

create src/reducers.js

import { combineReducers } from 'redux'
import {
  SET_STATUS_MESSAGE, SET_ERROR_MESSAGE, CLEAR_MESSAGE,
  SET_HELLO, SET_PRIVATE_HELLO, SET_USER
} from './actions'

function messageState(state = {
    statusMessage: "",
    errorMessage: ""
  }, action) {
  switch (action.type) {
    case SET_STATUS_MESSAGE:
      return Object.assign({}, state, {
        statusMessage: action.payload
      })
    case SET_ERROR_MESSAGE:
      return Object.assign({}, state, {
        errorMessage: action.payload
      })
    case CLEAR_MESSAGE:
      return Object.assign({}, state, {
        statusMessage: "",
        errorMessage: ""
      })
    default:
      return state
  }
}

function helloState(state = {
    hello: '',
  }, action) {
  switch (action.type) {
    case SET_HELLO:
      return Object.assign({}, state, {
        hello: action.payload
      })
    default:
      return state
  }
}

function privateHelloState(state = {
    privateHello: '',
  }, action) {
  switch (action.type) {
    case SET_PRIVATE_HELLO:
      return Object.assign({}, state, {
        privateHello: action.payload
      })
    default:
      return state
  }
}

function userState(state = {
    userName: '',
    isAuthenticated: ''
  }, action) {
  switch (action.type) {
    case SET_USER:
      return Object.assign({}, state, {
        userName: action.payload.userName,
        isAuthenticated: action.payload.isAuthenticated,
      })
    default:
      return state
  }
}

const helloApp = combineReducers({
  messageState,
  userState,
  helloState,
  privateHelloState,
})

export default helloApp

Testing

テストで使用するモジュールをインストールする

npm install --save-dev mocha assert nock node-localstorage

create test/test_fetch_hello.js:

import assert from 'assert'
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'

import api from '../src/middleware/api'
import { fetchHello, login, fetchPrivateHello } from '../src/actions'
import helloApp from '../src/reducers'

import nock from 'nock'
import {LocalStorage} from 'node-localstorage'

// replace localStorage for testing on Node.js
global.localStorage = new LocalStorage('./scratch')

describe('fetchHello', () => {

  const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore)
  const store = createStoreWithMiddleware(helloApp)

  beforeEach(() => {
    nock.cleanAll()
    global.localStorage.clear()
  });

  it('fetching', (done) => {
    // fetching は再現が難しいので nock
    nock('http://localhost:3000').get('/api/hello').delay(100).reply(200, 'hello, public')
    store.dispatch(fetchHello())
    setTimeout(() => {
      const { statusMessage, errorMessage } = store.getState().messageState
      const { hello } = store.getState().helloState
      assert.equal(statusMessage, 'start fetching hello')
      assert.equal(hello, '')
      done()
    }, 10)
  });

  it('success', (done) => {
    // nock('http://localhost:3000').get('/api/hello').reply(200, 'hello, public')
    store.dispatch(fetchHello())
    setTimeout(() => {
      const { statusMessage, errorMessage } = store.getState().messageState
      const { hello } = store.getState().helloState
      assert.equal(statusMessage, 'finish fetching hello')
      assert.equal(hello, 'hello, public')
      done()
    }, 50)
  });

  it('failure', (done) => {
    // failure は再現が難しいので nock
    nock('http://localhost:3000').get('/api/hello').reply(404)
    store.dispatch(fetchHello())
    setTimeout(() => {
      const { statusMessage, errorMessage } = store.getState().messageState
      const { hello } = store.getState().helloState
      assert.equal(errorMessage, 'fail fetching hello')
      done()
    }, 10)
  });
});

describe('login', () => {

  const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore)
  const store = createStoreWithMiddleware(helloApp)

  beforeEach(() => {
    nock.cleanAll()
    global.localStorage.clear()
  });

  it('success', (done) => {
    nock('http://localhost:3000').post('/api/sessions').reply(201, {id_token: 'dummy-token'})
    store.dispatch(login())
    setTimeout(() => {
      const { statusMessage, errorMessage } = store.getState().messageState
      const { userName, isAuthenticated } = store.getState().userState
      assert.equal(statusMessage, 'finish login')
      assert.ok(isAuthenticated)
      const token = localStorage.getItem('id_token')
      assert.ok(token)
      done()
    }, 10)
  })
});

describe('fetchPrivateHello', () => {

  const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore)
  const store = createStoreWithMiddleware(helloApp)

  beforeEach(() => {
    nock.cleanAll()
    global.localStorage.clear()
  });

  it('fetching', (done) => {
    // fetching は再現が難しいので nock
    nock('http://localhost:3000').get('/api/private/hello').delay(100).reply(200, 'hello, public')
    store.dispatch(fetchPrivateHello())
    setTimeout(() => {
      const { statusMessage, errorMessage } = store.getState().messageState
      const { privateHello } = store.getState().privateHelloState
      assert.equal(statusMessage, 'start fetching private hello')
      assert.equal(privateHello, '')
      done()
    }, 10)
  });

  it('failure because of no token', (done) => {
    // nock('http://localhost:3000').get('/api/private/hello').reply(401)
    store.dispatch(fetchPrivateHello())
    setTimeout(() => {
      const { statusMessage, errorMessage } = store.getState().messageState
      assert.equal(errorMessage, 'fail fetching private hello')
      done()
    }, 10)
  });

  it('success', (done) => {
    nock('http://localhost:3000').post('/api/sessions').reply(201, {id_token: 'dummy-token'})
    store.dispatch(login())

    setTimeout(() => {
      nock('http://localhost:3000').get('/api/private/hello').reply(200, 'hello, private')
      store.dispatch(fetchPrivateHello())
      setTimeout(() => {
        const { statusMessage, errorMessage } = store.getState().messageState
        const { privateHello } = store.getState().privateHelloState
        assert.equal(statusMessage, 'finish fetching private hello')
        assert.equal(privateHello, 'hello, private')
        done()
      }, 10)
    }, 10)
  });
});

前回作成した server.js を起動してテストを実行。コマンドが長いので npm run に登録する事をおすすめします。babel-polyfill は Node.js でテストをする時に必要です。

% NODE_ENV=test $(npm bin)/mocha --compilers js:babel-register --require babel-polyfill

まとめ

今回は Action と Reducers を定義しました。 次回、実際に React に組み込んで動作を確認します。