Chapter 05

APIとフロント間で通信してみる

matsu7089
matsu7089
2021.01.01に更新

それではフロント側と API を連携させて、家計簿を完成させていきます!

まずは、axios というライブラリをプロジェクトに追加します。
さまざまなプロジェクトで API にアクセスする際よく利用されています。

> yarn add axios

Vuex の中で axios を使って API にアクセスします。
この図の Actions <---> Backend API の部分を実装します。

API クライアントをつくる

src の中に新しく api ディレクトリを作成し、
その中に gasApi.js を作成します。

このリクエストを送れるようにします。

{
  method: 'GET or POST or PUT or DELETE',
  authToken: '認証情報',
  params: {
    // 任意の処理の引数となるデータ
  }
}
api/gasApi.js
import axios from 'axios'

// 共通のヘッダーを設定したaxiosのインスタンス作成
const gasApi = axios.create({
  headers: { 'content-type': 'application/x-www-form-urlencoded' }
})

// response共通処理
// errorが含まれていたらrejectする
gasApi.interceptors.response.use(res => {
  if (res.data.error) {
    return Promise.reject(res.data.error)
  }
  return Promise.resolve(res)
}, err => {
  return Promise.reject(err)
})

/**
 * APIのURLを設定します
 * @param {String} url
 */
const setUrl = url => {
  gasApi.defaults.baseURL = url
}

/**
 * authTokenを設定します
 * @param {String} token
 */
let authToken = ''
const setAuthToken = token => {
  authToken = token
}

/**
 * 指定年月のデータを取得します
 * @param {String} yearMonth
 * @returns {Promise}
 */
const fetch = yearMonth => {
  return gasApi.post('', {
    method: 'GET',
    authToken,
    params: {
      yearMonth
    }
  })
}

/**
 * データを追加します
 * @param {Object} item
 * @returns {Promise}
 */
const add = item => {
  return gasApi.post('', {
    method: 'POST',
    authToken,
    params: {
      item
    }
  })
}

/**
 * 指定年月&idのデータを削除します
 * @param {String} yearMonth
 * @param {String} id
 * @returns {Promise}
 */
const $delete = (yearMonth, id) => {
  return gasApi.post('', {
    method: 'DELETE',
    authToken,
    params: {
      yearMonth,
      id
    }
  })
}

/**
 * データを更新します
 * @param {String} beforeYM
 * @param {Object} item
 * @returns {Promise}
 */
const update = (beforeYM, item) => {
  return gasApi.post('', {
    method: 'PUT',
    authToken,
    params: {
      beforeYM,
      item
    }
  })
}

export default {
  setUrl,
  setAuthToken,
  fetch,
  add,
  delete: $delete,
  update
}

最初に共通の設定をしたインスタンスを作成します。あとからデフォルト設定を上書きもできます。

// 共通のヘッダーを設定したaxiosのインスタンス作成
const gasApi = axios.create({
  headers: { 'content-type': 'application/x-www-form-urlencoded' }
})

// リクエスト先のURLを変更する
gasApi.defaults.baseURL = 'https://xxxxx.com'

インスタンスを作成すると get, post, put, delete などのメソッドが使えます。
このメソッドで各リクエストを送信できます。今回は API の仕様上すべて post を使います。

gasApi.post(url, data)

また、interceptors を利用するとリクエスト時、レスポンス時の共通処理を設定できます。
今回はレスポンスの内容に error が含まれていた場合、reject してエラーにします。

// response共通処理
// errorが含まれていたらrejectする
gasApi.interceptors.response.use(res => {
  if (res.data.error) {
    return Promise.reject(res.data.error)
  }
  return Promise.resolve(res)
}, err => {
  return Promise.reject(err)
})

API からデータを取得する

それでは、作成した API クライアントを使用して実際に通信してみます。

store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import gasApi from '../api/gasApi'

Vue.use(Vuex)

/** 
 * State
 * Vuexの状態
 */
const state = {
  /** 家計簿データ */
  abData: {},

  /** ローディング状態 */
  loading: {
    fetch: false,
    add: false,
    update: false,
    delete: false
  },

  /** エラーメッセージ */
  errorMessage: '',

  /** 設定 */
  settings: {
    /** ~ 省略 ~ */
  }
}

/**
 * Mutations
 * ActionsからStateを更新するときに呼ばれます
 */
const mutations = {
  /** ~ 省略 ~ */

  /** ローディング状態をセットします */
  setLoading (state, { type, v }) {
    state.loading[type] = v
  },

  /** エラーメッセージをセットします */
  setErrorMessage (state, { message }) {
    state.errorMessage = message
  },

  /** 設定を保存します */
  saveSettings (state, { settings }) {
    state.settings = { ...settings }
    const { appName, apiUrl, authToken } = state.settings
    document.title = appName
    gasApi.setUrl(apiUrl)
    gasApi.setAuthToken(authToken)
    // 家計簿データを初期化
    state.abData = {}

    localStorage.setItem('settings', JSON.stringify(settings))
  },

  /** 設定を読み込みます */
  loadSettings (state) {
    const settings = JSON.parse(localStorage.getItem('settings'))
    if (settings) {
      state.settings = Object.assign(state.settings, settings)
    }
    const { appName, apiUrl, authToken } = state.settings
    document.title = appName
    gasApi.setUrl(apiUrl)
    gasApi.setAuthToken(authToken)
  }
}

/**
 * Actions
 * 画面から呼ばれ、Mutationをコミットします
 */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    const type = 'fetch'
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.fetch(yearMonth)
      commit('setAbData', { yearMonth, list: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
      commit('setAbData', { yearMonth, list: [] })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}

/** ~ 省略 ~ */

import で作成したクライアントを使えるようにして、
state にローディング状態とエラーメッセージを追加します。

import gasApi from '../api/gasApi'
/** ローディング状態 */
loading: {
  fetch: false,
  add: false,
  update: false,
  delete: false
},

/** エラーメッセージ */
errorMessage: '',

saveSettings, loadSettings 内でアプリ設定の apiUrl, authToken を gasApi に反映させます。

const { appName, apiUrl, authToken } = state.settings
document.title = appName
gasApi.setUrl(apiUrl)
gasApi.setAuthToken(authToken)

Actions の中でクライアントを使ってリクエストを送信します。

/** 指定年月の家計簿データを取得します */
async fetchAbData ({ commit }, { yearMonth }) {
  const type = 'fetch'
  // 取得の前にローディングをtrueにする
  commit('setLoading', { type, v: true })
  try {
    // APIにリクエスト送信
    const res = await gasApi.fetch(yearMonth)
    // 取得できたらabDataにセットする
    commit('setAbData', { yearMonth, list: res.data })
  } catch (e) {
    // エラーが起きたらメッセージをセット
    commit('setErrorMessage', { message: e })
    // 空の配列をabDataにセット
    commit('setAbData', { yearMonth, list: [] })
  } finally {
    // 最後に成功/失敗関係なくローディングをfalseにする
    commit('setLoading', { type, v: false })
  }
}

ホーム画面で fetchAdData を呼んでいた箇所も変更が必要なので、対応させます。

Home.vue
/** ~ 省略 ~ */

export default {
  name: 'Home',

  /** ~ 省略 ~ */

  data () {
    const today = new Date()
    const year = today.getFullYear()
    const month = ('0' + (today.getMonth() + 1)).slice(-2)

    return {
      /** 月選択メニューの状態 */
      menu: false,
      /** 検索文字 */
      search: '',
      /** 選択年月 */
      yearMonth: `${year}-${month}`,
      /** テーブルに表示させるデータ */
      tableData: []
    }
  },

  computed: {
    ...mapState({
      /** 家計簿データ */
      abData: state => state.abData,
      /** ローディング状態 */
      loading: state => state.loading.fetch,
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    /** ~ 省略 ~ */

    /** 表示させるデータを更新します */
    async updateTable () {
      const yearMonth = this.yearMonth
      const list = this.abData[yearMonth]

      if (list) {
        this.tableData = list
      } else {
        await this.fetchAbData({ yearMonth })
        this.tableData = this.abData[yearMonth]
      }
    },

    /** ~ 省略 ~ */
  }
}

data の中で持っていた loading は消して、State の loading を使うようにします。

computed: {
  ...mapState({
    /** 家計簿データ */
    abData: state => state.abData,
    /** ローディング状態 */
    loading: state => state.loading.fetch,
  }),

  /** ~ 省略 ~ */
},

fetchAbData は Promise を返すようにしたので async/await に直します。

async updateTable () {
  /** ~ 省略 ~ */
  await this.fetchAbData({ yearMonth })
  /** ~ 省略 ~ */
},

このままだと通信でエラーが起きたときにメッセージが表示されないので、
App.vue にエラーメッセージを表示させるようにします。

App.vue
<template>
  <v-app>
    <!-- ~ 省略 ~ -->
    <v-main>
      <!-- ~ 省略 ~ -->
    </v-main>
    <!-- スナックバー -->
    <v-snackbar v-model="snackbar" color="error">{{ errorMessage }}</v-snackbar>
  </v-app>
</template>
<script>
import { mapState } from 'vuex'

export default {
  name: 'App',

  data () {
    return {
      snackbar: false
    }
  },

  computed: mapState({
    appName: state => state.settings.appName,
    errorMessage: state => state.errorMessage
  }),

  watch: {
    errorMessage () {
      this.snackbar = true
    }
  },

  /** ~ 省略 ~ */
}
</script>

スナックバーは画面下に表示される、通知のようなものです

watch で errorMessage を監視して、変更のあったタイミングでスナックバーを表示させます。
スナックバーは一定時間経過すると自動で消えます。

watch: {
  // errorMessageに変更があったら
  errorMessage () {
    // スナックバーを表示
    this.snackbar = true
  }
},

API との疎通確認をしてみます!

家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
※ authToken を設定してない方は空のままでOKです。

ホーム画面に戻ってスプレッドシートのデータが表示されるか確認してみてください!

API で追加/更新できるようにする

次に、ItemDialog から API を使って追加/更新できるようにします。
さきほどと同じように Actions との内容を書き換えます。

store/index.js
/** ~ 省略 ~ */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    /** ~ 省略 ~ */
  },

  /** データを追加します */
  async addAbData ({ commit }, { item }) {
    const type = 'add'
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.add(item)
      commit('addAbData', { item: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },

  /** データを更新します */
  async updateAbData ({ commit }, { beforeYM, item }) {
    const type = 'update'
    const yearMonth = item.date.slice(0, 7)
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.update(beforeYM, item)
      if (yearMonth === beforeYM) {
        commit('updateAbData', { yearMonth, item })
        return
      }
      const id = item.id
      commit('deleteAbData', { yearMonth: beforeYM, id })
      commit('addAbData', { item: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */

ItemDialog も async/await に対応させます。

ItemDialog.vue
/** ~ 省略 ~ */
import { mapActions, mapGetters, mapState } from 'vuex'

export default {
  name: 'ItemDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** 入力したデータが有効かどうか */
      valid: false,
      /** 日付選択メニューの表示状態 */
      menu: false,

      /** 操作タイプ 'add' or 'edit' */
      actionType: 'add',
      /** ~ 省略 ~ */
    }
  },

  computed: {
    /** ~ 省略 ~ */

    ...mapState({
      /** ローディング状態 */
      loading: state => state.loading.add || state.loading.update
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    /** ~ 省略 ~ */

    /** 追加/更新がクリックされたとき */
    async onClickAction () {
      const item = {
        date: this.date,
        title: this.title,
        category: this.category,
        tags: this.tags.join(','),
        memo: this.memo,
        income: null,
        outgo: null
      }
      item[this.inout] = this.amount || 0

      if (this.actionType === 'add') {
        await this.addAbData({ item })
      } else {
        item.id = this.id
        await this.updateAbData({ beforeYM: this.beforeYM, item })
      }

      this.show = false
    },
    /** ~ 省略 ~ */
  }
}

追加も編集も同じコンポーネントで行っているので、
どちらかが実行中であれば loading が true となるようにします。

...mapState({
  /** ローディング状態 */
  loading: state => state.loading.add || state.loading.update
}),

追加/編集がダイアログから実行できるか確認してみます!
どちらも実行できればOKです!スプレッドシートも確認してみてください。

API で削除できるようにする

最後に、DeleteDialog から API を使って削除できるようにします。

store/index.js
/** ~ 省略 ~ */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    /** ~ 省略 ~ */
  },

  /** データを追加します */
  async addAbData ({ commit }, { item }) {
    /** ~ 省略 ~ */
  },

  /** データを更新します */
  async updateAbData ({ commit }, { beforeYM, item }) {
    /** ~ 省略 ~ */
  },

  /** データを削除します */
  async deleteAbData ({ commit }, { item }) {
    const type = 'delete'
    const yearMonth = item.date.slice(0, 7)
    const id = item.id
    commit('setLoading', { type, v: true })
    try {
      await gasApi.delete(yearMonth, id)
      commit('deleteAbData', { yearMonth, id })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */
DeleteDialog.vue
/** ~ 省略 ~ */
import { mapActions, mapState } from 'vuex'

export default {
  name: 'DeleteDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** 受け取ったデータ */
      item: {}
    }
  },

  computed: mapState({
    /** ローディング状態 */
    loading: state => state.loading.delete
  }),

  methods: {
    /** ~ 省略 ~ */

    /** 削除がクリックされたとき */
    async onClickDelete () {
      await this.deleteAbData({ item: this.item })
      this.show = false
    }
  }
}

削除がダイアログから実行できるか確認してみます!
実行できればOKです!スプレッドシートも確認してみてください。

ハンズオンは以上になります。お疲れ様でした!🎉 🍻

ホーム画面で収支の総計を確認できるようにしたり、毎月1日に先月の収入を自動で繰り越す GAS プログラムを追加したり…。

フロントに限らず、GAS 側も自分好みにしてみてください!

ハンズオン完成時点のソースコード一覧はこちらから確認できます!