20190704のvue.jsに関する記事は11件です。

Vue.jsでQiita簡易クライアントを作成

こんにちは
普段はバックエンドがメインなのですが、フロントエンドの勉強がてらVue.jsをやってみます

開発環境

画面遷移しないのでVue Routerは使用しません

成果物

Qiita APIを利用してタグ検索できます

ソースコード

src/main.js
import Vue from "vue";
import App from "./App.vue";
import store from "./store";

import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";

Vue.use(BootstrapVue);

Vue.config.productionTip = false;

new Vue({
  store,
  render: h => h(App)
}).$mount("#app");
src/App.vue
<template>
  <div id="app">
    <qiita-form />
    <qiita-content />
  </div>
</template>

<script>
import Form from "./components/Form.vue";
import Content from "./components/Content.vue";

export default {
  name: "app",

  components: {
    "qiita-form": Form,
    "qiita-content": Content
  }
};
</script>

ストア

src/store.js
import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

const fetchItmes = async value => {
  const response = await fetch(
    `https://qiita.com/api/v2/tags/${value}/items?page=1&per_page=20`,
    { mode: "cors" }
  );
  return response.json();
};

export default new Vuex.Store({
  state: {
    selected: null,
    items: []
  },

  getters: {
    selected: state => state.selected,
    items: state => state.items
  },

  mutations: {
    selected(state, selected) {
      state.selected = selected;
    },
    items(state, items) {
      state.items = items;
    }
  },

  actions: {
    onChange({ commit }, value) {
      commit("selected", value);
    },
    async search({ commit, state }) {
      commit("items", []);
      const items = await fetchItmes(state.selected);
      commit("items", items);
    }
  }
});

コンポーネント

src/components/Form.vue
<template>
  <div class="sticky-top">
    <b-card>
      <b-form-group
        label="Qiitaくらいあんと: タグを付けた日時の降順で20件取得します"
      >
        <b-form-select :options="options" v-model="selected">
          <template slot="first">
            <option :value="null" disabled>-- 選択してください --</option>
          </template>
        </b-form-select>
      </b-form-group>
      <b-button @click="search" :disabled="isDisabled" variant="info" block
        >検索</b-button
      >
    </b-card>
  </div>
</template>

<script>
export default {
  name: "Form",

  computed: {
    selected: {
      get() {
        return this.$store.getters.selected;
      },
      set(value) {
        this.$store.dispatch("onChange", value);
      }
    },
    isDisabled() {
      return this.$store.getters.selected === null;
    },
    options: () => [
      { value: "javascript", text: "JavaScript" },
      { value: "typescript", text: "TypeScript" },
      { value: "elm", text: "Elm" }
    ]
  },

  methods: {
    search() {
      this.$store.dispatch("search");
    }
  }
};
</script>
src/components/Content.vue
<template>
  <div>
    <b-list-group v-for="item in items" :key="item.id">
      <qiita-item :item="item" />
    </b-list-group>
  </div>
</template>

<script>
import Item from "./Item.vue";

export default {
  name: "Content",

  components: {
    "qiita-item": Item
  },

  computed: {
    items() {
      return this.$store.getters.items.filter(item => !item.private);
    }
  }
};
</script>
src/components/Item.vue
<template>
  <b-list-group-item>
    <div>
      <b-link :href="item.url" target="_blank">{{ item.title }}</b-link>
      <small>{{ updateAt }}</small>
    </div>
    <div>
      <span v-for="tag in item.tags" :key="tag.name">
        <b-badge variant="info" pill>{{ tag.name }}</b-badge>
      </span>
    </div>
    <div>
      <small :id="userName">{{ `by ${userName}` }}</small>
    </div>
  </b-list-group-item>
</template>

<script>
export default {
  name: "Item",

  props: {
    item: {
      url: String,
      title: String,
      updated_at: String,
      tags: Array,
      user: {
        name: String,
        id: String
      }
    }
  },

  computed: {
    updateAt() {
      const date = new Date(this.item.updated_at);
      return ` (最終更新日: ${date.toLocaleDateString()})`;
    },
    userName() {
      return this.item.user.name || `@${this.item.user.id}`;
    }
  }
};
</script>

ソースコード全体
https://github.com/akthrms/qiita-client

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Vue+TypeScript] Vue.extend で Vue らしさを保ちつつ TypeScript で書くときの型宣言についてまとめた

はじめに

Vue + TypeScript の組み合わせでVueを書くときに、vue-property-decorator を利用して書いていくことが多いと思います。
ただ vue-property-decorator を利用すると、どうしてもVueらしさがなくなるというか、よりTypeScriptにらしい書き方になると感じています。
せっかくJavaScriptでVue書けるようになったのに、全然書き方が違うじゃないか…と挫折しかけることもあるんじゃないでしょうか?
ちなみに私は vue-property-decorator で書くほうが慣れているので好きですが、Vue入門者には厳しいところがあると思うので、 Vue.extend ベースでTypeScriptを書いていくという方法を紹介するのと、その際の型宣言についてもまとめていこうと思います。

VueをTypeScriptで書きたいけど、 vue-property-decorator は使いたくない…って人の参考になればいいなーと思います。

Vue.extend ??

TypeScript内でVueモジュールをimport/extendして書く方法です。
JavaaScriptでのVueコンポーネントの記述に近い書き方でTypeScriptを書くことができます。

<script lang="ts">
  import Vue from "vue"

  export default Vue.extend({
    name: "component",
    data() {
      return {
        value: "hoge"
      }
    }
  })
</script>

環境構築

VueCLIで簡単に構築することができます。
TypeScriptを選択して、class-styleコンポーネントシンタックスを利用しないと選択すればいいです。

? Check the features needed for your project: (Press <space> to select, <a> to toggle all, <i> to invert selection)
 ◯ Babel
❯◉ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◯ Vuex
 ◯ CSS Pre-processors
 ◯ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing
? Use class-style component syntax? (Y/n) n

これで Vue.extend の環境が構築されます。簡単ですね。

Vue.extend における型

Vue.extend ベースでTypeScriptで書いていく際の型宣言を紹介していこうと思います。

props

Propについてはネイティブコンストラクターを付与することで、内部で型推論されます。
またArrayやObjectの詳細な型宣言については PropType を利用することで宣言することができます。

<script lang="ts">
  import Vue, { PropType } from "vue"

  export type PropObjType = {
    id: string
    index: number
  }

  export default Vue.extend({
    props: {
      val: String,
      obj: Object as PropType<PropObjType>
    }
  })
</script>

ここで注意が必要なのが、ビルド時のコンパイルエラーを得ることができないという点です。ただ実行時のエラーを得ることはできます。
また用意されているネイティブコンストラクターは以下の通りです。

  • String
  • Number
  • Boolean
  • Array
  • Object
  • Date
  • Function
  • Symbol

data

data()関数に向けて型を定義し、型アノテーションを付与します。

<script lang="ts">
  import Vue from "vue"

  export type DataType = {
    value: string
    enable: boolean
    count: number
  }

  export default Vue.extend({
    data(): DataType {
      return {
        value: "hoge",
        enable: true,
        count: 0
      }
    }
  })
</script>

lifecycle hooks

lifecycle hooksで着火するイベントは基本的に void 型の関数

<script lang="ts">
  import Vue from "vue"

  export default Vue.extend({
    created(): void {
      console.log("Created!!!")
    }
  })
</script>

computed

computedで呼び出される関数が返す値に対する型を宣言する

<script lang="ts">
  import Vue from "vue"

  export type DataType = {
    value: string
    enable: boolean
    count: number
  }

  export default Vue.extend({
    data(): DataType {
      return {
        value: "hoge",
        enable: true,
        count: 0
      }
    },

    computed: {
      isEnabled(): boolean {
        return this.enable
      },
      getCount(): number {
        return this.count
      }
    },
  })
</script>

methods

methodsで呼び出される関数が返す値に対する型を宣言する

<script lang="ts">
  import Vue from "vue"

  export type DataType = {
    value: string
    enable: boolean
    count: number
  }

  export default Vue.extend({
    data(): DataType {
      return {
        value: "hoge",
        enable: true,
        count: 0
      }
    },

    methods: {
      countUp(): void {
        this.count += 1
      },
      getValue(): string {
        return this.value
      }
    }
  })
</script>

まとめ

  • Vueらしさを保ちつつTypeScriptで書きたいって人 → Vue.extend
  • VueらしさよりTypeScriptらしく書きたいって人 → vue-property-decorator

という感じかなって思います。自分に合った方法でより楽しくVueを書いていきましょう!
ではまた!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

v-forを使う時、配列に初期値があると初めから画面に表示されてしまう

vueでtodoリストを作る際に、v-forを使い配列の中身を順に表示させようとしたのですが、ボタン要素を作る為にhtmlに<button>を記述したところ、初期の状態からボタン要素が見えてしまっている不備があった為、少し躓いたので解決策を記載します。

html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <link rel="stylesheet" href="style.css">   
</head>
<body>
    <div id="app">
        <h1>ToDoList</h1>
        <table>
            <thead>
              <tr>
                <th>コメント</th>
                <th>状態</th>
              </tr>
            </thead>
            <tbody>
              <tr v-for="todo in todos" :key="todo.value">
                <td>{{ todo.item }}</td>
                <td><button @click="">{{ todo.state }}</button></td>
            </tr>
            </tbody>
          </table>
          <input type="text" v-model="newItem">
          <button @click="addItem">追加</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="script.js"></script>
</body>
</html>

ここは問題なしです。

javascript(vue) 不備があったコード。

let vm = new Vue({
     el: '#app',
     data: {
          newItem: '',
          todos: [{
               item: this.newItem,
               state: ''
          }]
     },
     methods: {
          //タスクを追加する関数
          addItem: function () {
               this.todos.push({item: this.newItem, state: '作業中'})
          }
     }
});

このコードだと、htmlに初めからボタン要素が見えてしまいます。
じゃあhtmlの<button>を消して、javascriptの関数内でボタン要素を作り、それを配列todosにpushしよう!
と思ったのですが、vueには生のjavascriptでお世話になったdocument.createElementに相当するものは無いようなので、少し躓いてしまいました。
(誤解を招く表現でしたので訂正します。わざわざ使う必要がない、という認識です。)

難しく考えていましたが解決策は非常にシンプルでした。

解決したコード

let vm = new Vue({
     el: '#app',
     data: {
          newItem: '',
          todos: []
     },
     methods: {
          //タスクを追加する関数
          addItem: function () {
               this.todos.push({item: this.newItem, state: '作業中'})
          }
     }
});

【解決策】
配列todosを空にするだけです。

【原因】
htmlに初めからボタン要素が表示されていた原因は、配列todosのボタン要素になるプロパティstateに初期値を与えてしまったせいです。
ですので配列todosを空にし、関数内でpushする時にプロパティを作ってあげれば解決です。
(プロパティitemは今回のボタン要素の表示には関係ありませんが、同じ関数内で一緒にpushしてあげた方がスマートで保守性と可読性も上がると思います。)


以上です。
よく考えれば当たり前なのに理解不足もあり少し時間を取られたので記載しておきました。
補足や訂正などありましたら、ぜひご教授いただければ嬉しいです。
最後まで見ていただきありがとうございます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Amplifyでサーバーレスなログイン機能をスマートに実装

はじめに

英文の記事もあります!

AWS Amplify、使ったことありますか?フロントエンドの人でも楽しく使えるAWS、それがAmplifyです。AWS自体、最近勉強し始めたのですが、難しいことせずに素早くAWSサービス群が使えちゃうのでこれからもっと使っていきたいです。

本記事はサインアップ/サインインの認証機能を一から作っていくチュートリアルです。GitHubはこちらから

目次

  • AWS Amplifyの私の理解度
  • サインアップなどのフォームとVue Routerの実装
  • AWS Amplifyを設定
  • AWS Amplifyの機能を実装

NOTE: 本記事ではAWS Amplifyチームが提供しているVue.jsのUIコンポーネントについては説明しません。

AWS Amplifyの私の理解度

AWS Amplifyでググってみたらいいんですが、大体以下のサービスの集まりだと認識するとググりやすいかもしれません。

  • Amplify CLI: 開発時にお世話になるCLI。
  • Amplify.js: JSコンポーネント。今回はWebの話しかしませんが、Naitive Mobile Appにも同等のものが提供されています。
  • Amplify Console: AWS Consoleからアクセスできるクラウドサービス。デプロイ時のCIを提供。まだ触ったことはありません。。

サインアップなどのフォームとVue Routerの実装

ここで作るもの:

フォーム

  • サインアップ
  • メールアドレス確認
  • サインイン

キレイにスタイリングした後のスクリーンショットはこちらのページにアップロードしています。

ルータ

サインインした時にはサインアップフォーム等を表示させたくないですよね。同時に、サインインしていない時にはサインアウトボタンのあるページを表示させたくないので、ルータを使ってアクセス制御します。

コーディングタイム!

インストール

まずはVue CLIでパッケージをインストールします。ここではVuetifyとVue Routerをインストールします。

$ # Install Vue CLI
$ npm install @vue/cli -g
$ # Create the project
$ vue create my-app
? Please pick a preset: default (babel, eslint)
$ cd $_
# Install Vuetify
$ vue add vuetify
? Choose a preset: Default (recommended)
# Install Vue Router
$ vue add router
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes

サインアップフォーム

サインアップフォームをsrc/views/signUp.vueに作ります。注意したいのは、メールアドレスの項目にusernameを使用しています。これは、AWS Amplifyではusernameが必須入力項目になっているためです。ここで変数名をusernameにする必要はないのですが便宜的に。

ここでやっていることはVuetifyを使ったことのある方だったら流し読み程度で大丈夫です。フォームを設置し、メールアドレスとパスワードのバリデーションを設定しています。バリデーションを全部パスしたら「Submit」ボタンが押せるようになり、Console.logに入力した値が表示されることを確認してください。

// src/views/SignUp.vue
<template>
  <div class="sign-up">
    <h1>Sign Up</h1>
    <v-form v-model="valid" ref="form" lazy-validation>
      <v-text-field v-model="username" :rules="emailRules" label="Email Address" required/>
      <v-text-field
        v-model="password"
        :append-icon="passwordVisible ? 'visibility' : 'visibility_off'"
        :rules="[passwordRules.required, passwordRules.min]"
        :type="passwordVisible ? 'text' : 'password'"
        name="password"
        label="Password"
        hint="At least 8 characters"
        counter
        @click:append="passwordVisible = !passwordVisible"
        required/>
      <v-btn :disabled="!valid" @click="submit">Submit</v-btn>
    </v-form>
  </div>
</template>

<script>
export default {
  name: "SignUp",
  data() {
    return {
      valid: false,
      username: '',
      password: '',
      passwordVisible: false,
    }
  },
  computed: {
    emailRules() {
      return [
        v => !!v || 'E-mail is required',
        v => /.+@.+/.test(v) || 'E-mail must be valid'
      ]
    },
    passwordRules() {
      return {
        required: value => !!value || 'Required.',
        min: v => v.length >= 8 || 'Min 8 characters',
        emailMatch: () => ('The email and password you entered don\'t match'),
      }
    },
  },
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN UP username: ${this.username}, password: ${this.password}, email: ${this.username}`);
      }
    },
  },
}
</script>

サインアップ確認フォーム

サインアップが完了したら、AWSが対象メールアドレス宛にメールを送ってくれます。そのメールの中には確認コードがあるので、メールアドレスと確認コードを入力することで登録されたメールアドレスが正しいかを確認します。

サインアップフォームとやっていることは同じです。今の段階ではサインアップと同様にconsole.logの出力しか行いません。src/views/SignUpConfirm.vueに作ります。

// src/views/SignUpConfirm.vue
<template>
  <div class="confirm">
    <h1>Confirm</h1>
    <v-form v-model="valid" ref="form" lazy-validation>
      <v-text-field v-model="username" :rules="emailRules" label="Email Address" required/>
      <v-text-field v-model="code" :rules="codeRules" label="Code" required/>
      <v-btn :disabled="!valid" @click="submit">Submit</v-btn>
    </v-form>
    <v-btn @click="resend">Resend Code</v-btn>
  </div>
</template>

<script>
export default {
  name: "SignUpConfirm",
  data() {
    return {
      valid: false,
      username: '',
      code: '',
    }
  },
  computed: {
    emailRules() {
      return [
        v => !!v || 'E-mail is required',
        v => /.+@.+/.test(v) || 'E-mail must be valid'
      ]
    },
    codeRules() {
      return [
        v => !!v || 'Code is required',
        v => (v && v.length === 6) || 'Code must be 6 digits'
      ]
    },
  },
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`CONFIRM username: ${this.username}, code: ${this.code}`);
      }
    },
    resend() {
      console.log(`RESEND username: ${this.username}`);
    }
  },
}
</script>

サインインフォーム

通常のサインインフォームです。サインアップフォームととても似ているので説明は特にしません。src/views/SignIn.vueに作ります。

// src/views/SignIn.vue
<template>
  <div class="sign-in">
    <h1>Sign In</h1>
    <v-form v-model="valid" ref="form" lazy-validation>
      <v-text-field v-model="username" :rules="emailRules" label="Email Address" required/>
      <v-text-field
        v-model="password"
        :append-icon="passwordVisible ? 'visibility' : 'visibility_off'"
        :rules="[passwordRules.required, passwordRules.min]"
        :type="passwordVisible ? 'text' : 'password'"
        name="password"
        label="Password"
        hint="At least 8 characters"
        counter
        @click:append="passwordVisible = !passwordVisible"
        required/>
      <v-btn :disabled="!valid" @click="submit">Submit</v-btn>
    </v-form>
  </div>
</template>

<script>
export default {
  name: "SignIn",
  data() {
    return {
      valid: false,
      username: '',
      password: '',
      passwordVisible: false,
    }
  },
  computed: {
    emailRules() {
      return [
        v => !!v || 'E-mail is required',
        v => /.+@.+/.test(v) || 'E-mail must be valid'
      ]
    },
    passwordRules() {
      return {
        required: value => !!value || 'Required.',
        min: v => v.length >= 8 || 'Min 8 characters',
        emailMatch: () => ('The email and password you entered don\'t match'),
      }
    },
  },
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN IN username: ${this.username}, password: ${this.password}`);
      }
    },
  },
}
</script>

サインイン後のページ

ここでサインインした後のページを作ってもいいのですが、面倒なので、Vue Routerをインストールした時に生成されたsrc/views/Home.vueを使い回しましょう。

Vue Routerにフォームページを追加していく

Vue Routerをインストールした時、src/App.vueがアップデートされたことにお気づきかと思います。

// src/App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view/>
  </div>
</template>

これを参考に、フォームページを追加していきます。

// src/App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/signUp">Sign Up</router-link> |
      <router-link to="/signUpConfirm">Confirm</router-link> |
      <router-link to="/signIn">Sign In</router-link>
    </div>
    <router-view/>
  </div>
</template>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

同様に、Vue Routerをインストールした時にsrc/router.jsというファイルが新しく追加されています。フォームページを追加していきましょう。

// src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    },
    {
      path: '/signUp',
      name: 'signUp',
      component: () => import(/* webpackChunkName: "signup" */ './views/SignUp.vue')
    },
    {
      path: '/signUpConfirm',
      name: 'signUpConfirm',
      component: () => import(/* webpackChunkName: "confirm" */ './views/SignUpConfirm.vue')
    },
    {
      path: '/signIn',
      name: 'signIn',
      component: () => import(/* webpackChunkName: "signin" */ './views/SignIn.vue')
    },
  ]
})

この段階で、ブラウザ上にナビゲーションアイテムが追加されているのが確認できるはずです。それぞれをクリックして、対応したフォームが表示されているか確認してみましょう。

AWS Amplifyを設定

フロントエンドの方にとってなんだか敷居の高いAWSをこれから使っていきます。Get Startedのページに従って、AWSのアカウント作成、Amplify CLIのインストールを実行してみてください。インストールが終わったら早速CLIを使っていきます。

$ amplify configure

ざっくり言うとこのコマンドはお使いのコンピューターからAWSにアクセスすることを知らせます。いくつか選択肢があったりしますので、読み進めて任意に設定してください。私が選択した結果は以下の通りです。参考までに。

these steps to set up access to your AWS account:

Sign in to your AWS administrator account:
https://console.aws.amazon.com/
Press Enter to continue

Specify the AWS Region
? region:  us-west-2
Specify the username of the new IAM user:
? user name:  amplify-cognito-vuejs-example
Complete the user creation using the AWS console
https://console.aws.amazon.com/iam/home?region=undefined#/users$new?step=final&accessKey&userNames=amplify-cognito-vuejs-example&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAdministratorAccess
Press Enter to continue

Enter the access key of the newly created user:
? accessKeyId:  AKIA2BSHMB**********
? secretAccessKey:  4IgyKbOh9EJiufb4prtd********************
This would update/create the AWS Profile in your local machine
? Profile Name:  default

Successfully set up the new user.

次に、同じCLIを使ってプロジェクトを初期化します。

$ amplify init

ここも任意設定です。ご自身の環境に合わせて設定してください。私が選択した結果は以下の通りです。ほぼデフォルトですね。

Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project my-app
? Enter a name for the environment dev
? Choose your default editor: Vim (via Terminal, Mac OS only)
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using vue
? Source Directory Path:  src
? Distribution Directory Path: dist
? Build Command:  npm run-script build
? Start Command: npm run-script serve

そして、認証機能であるauthを追加します。以下のコマンドだけで追加できちゃいます。

amplify add auth

ここでも選択肢をいくつか選択します。以下、私の選択した結果です。

Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito.

 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections.
 How do you want users to be able to sign in when using your Cognito User Pool? Email
 Warning: you will not be able to edit these selections.
 What attributes are required for signing up?
Successfully added resource cognitoexample77f073c1 locally

何をやっているのかさっぱりかと思いますが、ここではAWS Cognitoという認証機能の設定をしています。AWS Amplifyを通して、Cognitoというサービスを使っているんですね。

最後に、設定ファイルをアップロードします。この設定ファイルは今まで選択してきた結果をもとに勝手に作成されています。語彙が足りませんが、すごいです。

$ amplify push
Current Environment: dev

| Category | Resource name          | Operation | Provider plugin   |
| -------- | ---------------------- | --------- | ----------------- |
| Auth     | cognitoexampled26e7f7d | Create    | awscloudformation |
? Are you sure you want to continue? Yes

AWS Amplifyの機能を実装

コーディングタイム!

Amplifyの機能をこれまでに作ったアプリに実装していきます。

インストール

$ npm install aws-amplify aws-amplify-vue

aws-amplify-vueというのをインストールしたのはAmplifyEventBusというものを使うためだけです。イベントを登録して他のところで実行するだけのためのものなのでReact.jsユーザーの方は代替するものがあるはずです。

main.js

src/main.jsにて、amplifyコマンドによって生成された設定ファイルsrc/aws-exports.jsをインポートします。

// src/main.js
import Amplify from 'aws-amplify'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)

Vue.use(Auth)

auth.js

AWS Amplifyの提供されている機能を実際に使うモジュールを作ってみましょう。Vue.jsのファイル内に直接書いてもいいのですが、個人的に*.vueファイルは描画に関連したこと以外は書きたくないので、こちらのファイルを作成しています。

// src/utils/auth.js
import { Auth } from 'aws-amplify'
import { AmplifyEventBus } from 'aws-amplify-vue'

function getUser() {
  return Auth.currentAuthenticatedUser().then((user) => {
    if (user && user.signInUserSession) {
      return user
    } else {
      return null
    }
  }).catch(err => {
    console.log(err);
    return null;
  });
}

function signUp(username, password) {
  return Auth.signUp({
    username,
    password,
    attributes: {
      email: username,
    },
  })
    .then(data => {
      AmplifyEventBus.$emit('localUser', data.user);
      if (data.userConfirmed === false) {
        AmplifyEventBus.$emit('authState', 'confirmSignUp');
      } else {
        AmplifyEventBus.$emit('authState', 'signIn');
      }
      return data;
    })
    .catch(err => {
      console.log(err);
    });
}

function confirmSignUp(username, code) {
  return Auth.confirmSignUp(username, code).then(data => {
    AmplifyEventBus.$emit('authState', 'signIn')
    return data // 'SUCCESS'
  })
    .catch(err => {
      console.log(err);
      throw err;
    });
}

function resendSignUp(username) {
  return Auth.resendSignUp(username).then(() => {
    return 'SUCCESS';
  }).catch(err => {
    console.log(err);
    return err;
  });
}

async function signIn(username, password) {
  try {
    const user = await Auth.signIn(username, password);
    if (user) {
      AmplifyEventBus.$emit('authState', 'signedIn');
    }
  } catch (err) {
    if (err.code === 'UserNotConfirmedException') {
      // The error happens if the user didn't finish the confirmation step when signing up
      // In this case you need to resend the code and confirm the user
      // About how to resend the code and confirm the user, please check the signUp part
    } else if (err.code === 'PasswordResetRequiredException') {
      // The error happens when the password is reset in the Cognito console
      // In this case you need to call forgotPassword to reset the password
      // Please check the Forgot Password part.
    } else if (err.code === 'NotAuthorizedException') {
      // The error happens when the incorrect password is provided
    } else if (err.code === 'UserNotFoundException') {
      // The error happens when the supplied username/email does not exist in the Cognito user pool
    } else {
      console.log(err);
    }
  }
}

function signOut() {
  return Auth.signOut()
    .then(data => {
      AmplifyEventBus.$emit('authState', 'signedOut');
      return data;
    })
    .catch(err => {
      console.log(err);
      return err;
    });
}

export {getUser, signUp, confirmSignUp, resendSignUp, signIn, signOut};

auth.jsを使っていきましょう

これまでに作成したページでauth.jsをimportしていきましょう。

// src/views/SignUp.vue
<script>
import {signUp} from '@/utils/auth.js' // Adding this line
export default {
  name: "SignUp",
...
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN UP username: ${this.username}, password: ${this.password}, email: ${this.username}`);
        signUp(this.username, this.password); // Adding this line as well
      }
    },
  },
}
</script>
// src/views/SignUpConfirm.vue
<script>
import {confirmSignUp, resendSignUp} from '@/utils/auth.js'  // Adding this line
export default {
  name: "SignUpConfirm",
...
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`CONFIRM username: ${this.username}, code: ${this.code}`);
        confirmSignUp(this.username, this.code);  // Adding this line as well
      }
    },
    resend() {
      console.log(`RESEND username: ${this.username}`);
      resendSignUp(this.username);  // Adding this line as well
    }
  },
}
</script>
// src/views/SignIn.vue
<script>
import {signIn} from '@/utils/auth.js'  // Adding this line
export default {
  name: "SignIn",
...
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN IN username: ${this.username}, password: ${this.password}`);
        signIn(this.username, this.password);  // Adding this line as well
      }
    },
  },
}
</script>
// src/views/Home.vue
<template>
  <div class="home">
    <v-btn @click="signOut">Sign Out</v-btn>
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
import {signOut} from '@/utils/auth.js'
export default {
  name: 'home',
  components: {
    HelloWorld
  },
  methods: {
    signOut() {
      signOut().then((data) => console.log('DONE', data)).catch((err) => console.log('SIGN OUT ERR', err));
    }
  }
}
</script>

router.js

先ほど少し書きましたが、ユーザーのログイン状態に応じてページ遷移を制御したいです。http://localhost:8080/signUpをログイン済みのユーザーには表示させたくありませんし、ログインしていないユーザーにはHome.vueの内容を表示させたくはありませんよね。

// src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import { AmplifyEventBus } from 'aws-amplify-vue'
import {getUser} from '@/utils/auth.js'

Vue.use(Router)

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home,
      meta: { requiresAuth: true },
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    },
    {
      path: '/signUp',
      name: 'signUp',
      component: () => import(/* webpackChunkName: "signup" */ './views/SignUp.vue'),
      meta: { requiresAuth: false },
    },
    {
      path: '/signUpConfirm',
      name: 'signUpConfirm',
      component: () => import(/* webpackChunkName: "confirm" */ './views/SignUpConfirm.vue'),
      meta: { requiresAuth: false },
    },
    {
      path: '/signIn',
      name: 'signIn',
      component: () => import(/* webpackChunkName: "signin" */ './views/SignIn.vue'),
      meta: { requiresAuth: false },
    },
  ]
})

getUser().then((user) => {
  if (user) {
    router.push({path: '/'})
  }
})

AmplifyEventBus.$on('authState', async (state) => {
  const pushPathes = {
    signedOut: () => {
      router.push({path: '/signIn'})
    },
    signUp: () => {
      router.push({path: '/signUp'})
    },
    confirmSignUp: () => {
      router.push({path: '/signUpConfirm'})
    },
    signIn: () => {
      router.push({path: '/signIn'})
    },
    signedIn: () => {
      router.push({path: '/'})
    }
  }
  if (typeof pushPathes[state] === 'function') {
    pushPathes[state]()
  }
})

router.beforeResolve(async (to, from, next) => {
  const user = await getUser()
  if (!user) {
    if (to.matched.some((record) => record.meta.requiresAuth)) {
      return next({
        path: '/signIn',
      })
    }
  } else {
    if (to.matched.some((record) => typeof(record.meta.requiresAuth) === "boolean" && !record.meta.requiresAuth)) {
      return next({
        path: '/',
      })
    }
  }
  return next()
})

export default router

以上です!これだけで動くのか眉唾モノですね。

動かしてみましょう!

http://localhost:8080にアクセスしてみてください。もしhttp://localhost:8080/signInに勝手に遷移されたら、router.jsへの変更が無事反映されていますので安心してください。
"Sign Up"メニューから、ご自身のメールアドレスを使ってサインインしてみてください。確認メールが届くはずです。
メールアドレスと確認メールのコードを"Confirm"ページに入力後、ログインフォームにメールアドレスとパスワードを入力するとhttp://localhost:8080に遷移できるはずです。

NOTE: 2019年7月1日現在、以下のエラーがコンソールに出てくるかもしれません。

No credentials, applicationId or region

これはレポート済みのエラーです。とりあえずアプリは動くはずですので無視してください。

おわりに

AWS Consoleをあまり使うことなく、簡単にログイン機能を作ることができました。Amplifyすごい。
作成されたユーザーはAWS Console > Cognito > Manage User Pools > Users and groupsで無効にしたり削除したりできるので、ユーザー作成に失敗したらAWS Consoleで削除してください。

Firebaseを使ったことある方はそちらの方が簡単かもしれませんが、AWS Amplifyでも簡単にサーバーレスアプリが作れそうです。これから当分AWS Amplifyで遊んでいきたいと思っています。

お読み頂きありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vuex内でAPIを叩きVue.jsのcreated内でstateを取得する

はじめに

初めての投稿です。

今回Vuex内でaxiosを使いAPIからデータを取得します。
サイトにアクセスした際にVue.jsのコンポーネントへデータを渡し画面にデータを表示させます。

準備

バージョン 必須
Laravel 5.8.26 no
Vue.js 2.5.17 yes
Vuex 3.1.1 yes
axios 0.18 yes

自分はLaravelを使いAPIもそちらで用意しました。
APIさえ使えればどのような状態でも大丈夫です。

処理の流れ

①Vue.jsのcreated内でVuexのactionを呼び出す
②actionがAPIを叩く
③actionがAPIから取得したデータを引数としてmutationを呼び出す
④mutationでstateを更新する
⑤その後getterでstateを取得する

Vuexのコード

article.js
export default {
    namespaced: true,

    state: {
        articles: [],
    },

    getters: {
        getAll: state => {
            return state.articles;
        },

        getOne: state => id => {
            return state.articles.find(list => list.id === id);
        },
    },

    mutations: {
        setArticles: (state, payload) => {
            state.articles = payload.data;
        },
    },

    actions: {
        async setArticles({ commit }) {
            const payload = {
                data: '',
            };
            await axios.get('/api/articles')
            .then(response => {
                payload.data = response.data['data'];
                commit('setArticles', payload);
            })
            .catch(error => {
                console.log(error);
            });
        },
    },
}

APIから送られてくるデータはjsonです。

{
    "data": [
        {
            "id": 1,
            "title": "non",
            "body": "Sed non accusantium rem ad totam necessitatibus."
        },
        {
            "id": 2,
            "title": "totam",
            "body": "Ab et veritatis veniam et expedita voluptatem ipsam."
        },
        {
            "id": 3,
            "title": "magnam",
            "body": "Error enim laboriosam saepe delectus est."
        },
        {
            "id": 4,
            "title": "assumenda",
            "body": "Delectus tempore omnis occaecati quibusdam nisi."
        },
        {
            "id": 5,
            "title": "error",
            "body": "Quia exercitationem delectus vitae nulla corrupti eos."
        }
    ]
}

Vue.jsのコード

Article.vue
<template>
    <div>
        <p>{{ article_list }}</p>
    </div>
</template>

<script>
import { mapActions, mapGetters } from 'vuex';
export default {

    data() {
        return {
            article_list: [],
        }
    },

    created() {
        //this.setArticles;     非同期で処理されるためか遅れて完了する
        //this.article_list = this.getAll;      stateが更新されるよりも先に実行される
        this.setArticles().then( () => this.article_list = this.getAll );       //この書き方でうまく動いた
    },

    computed: {
        ...mapGetters('articles', [
            'getAll',
        ]),
    },

    methods: {
        ...mapActions('articles', [
            'setArticles',
        ]),
    },

}
</script>

コメントアウトしている箇所で苦しみました。

出力結果

vue-article.png
きちんと出力されました。

最後に

JavaScriptを勉強したことがないためこのような書き方でいいのか正直わかっていません。
コメントのほうでご教授してもらえたら幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

VueでTwitter認証するときにCROSエラーが発生したときの対処法

vueでいつものようにaxiosを使ってサーバーと通信していました。
が、Twitterと連携させるときに以下のようなエラーが発生。

Cross-Origin Read Blocking (CORB) blocked cross-origin response https://api.twitter.com/oauth/authenticate?oauth_token=xxxxxxxxxxxxxx with MIME type text/html. See https://www.chromestatus.com/feature/5629709824032768 for more details.

どうやらCORB(Cross Origin Request Blocking)によってブロックされてしまったらしい。
正直意味がわからなかったので色々調べてみたらSome-Origin-Policyが関わっていることが分かりました。

そもそもSome-Origin-Policyとは何なのか

Some-Origin-Policy(同一オリジンポリシー)とは、同じドメイン同士でしかやり取りできませんよ!というウェブセキュリティにおけるルールのことです。

あるオリジン(スキーム + ホスト + ポート)にアクセスして、そのリソースから異なるオリジンにAjax通信できないよう制限する仕組みで、スキーム、ホスト、ポート等が一つでも違えばアクセスできません。

サーバーが二つあるとしたらこんな感じです。

無題のプレゼンテーション (5).png

  1. クライアントがAサーバーにアクセス
  2. Aサーバーはクライアントにレスポンスを返す
  3. クライアントがBサーバーにAjax通信を送る
  4. Bサーバーから違うオリジンからアクセスするな!と怒られる。

https:://example.comからhttps::/test.com にAjax通信はできません。
なぜならホスト名が違うから。(ポートやスキームが違ってもだめ)

では今回のTwitter認証のエラーに当てはめてみましょう。
無題のプレゼンテーション (6).png

  1. localhostにアクセス
  2. localhostはレスポンスを返す
  3. localhostからTwitterAPIにAjax通信を送る
  4. 違うオリジン(URL)からアクセスするな!と怒られる。

Twitter側のAPIサーバーで、私はこのURLからのアクセスは許可しないよ!と拒否されてたということですね。

そこで、今回のようにsome-origin-policyに弾かれずに異なるドメイン同士で安全にリソースを共有するための仕組みCORS(Cross Origin Resource Sharing)というもの。

アクセスするには、通信される側のサーバーで特定のドメインからのアクセスを許可してあげる必要があります。

詳しくは調べていただければと思うのですが、
例えばサーバー側で、以下のようにヘッダーで許可するドメインを設定しなければなりません。

Access-Control-Allow-Origin: 'https://example.com'

もちろん僕たちがTwitterのAPIサーバーをいじることはできないので、諦めることに。

解決法 Ajax通信をやめる

長々と調べごとをしてましたが、そもそもAjax通信する必要性がないことに気づいたので、XMLHttpRequestではなく、単純にHTTPRequestを送るように変更することで解決。

axios.get("users/auth/twitter")

document.location.pathname = "users/auth/twitter"

フロントとバックエンドを分けると認証周りでつまづくことも多いですが、何かセキュリティ的に問題等ありましたらコメントいただけると幸いです。

参考: https://yukimonkey.com/fix-error/cros1/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

今更ながらWebアプリを作ってFirebaseで公開してみた話

はじめに

制作物をアウトプットするのは重要である.ということで,僕も挑戦してみる.
この記事は僕が現実逃避の暇つぶしで勉強がてら制作したWebアプリをFirebaseで公開してみたという内容です.

制作したWebアプリ:Vue-Lottery

メンバーリストから指定した人数をランダムに抽選するアプリです.
Vue.js + TypeScriptで制作しました.

選ばれやすさの重み付け機能がついてます.
複数人を抽選する機能でグループ分けもできます.

スクリーンショット 2019-07-03 18.58.24.png

Firebaseでホスティング

アカウント作る部分は割愛して,プロジェクトのセットアップから始めていきます.

Firebaseのセットアップ

Firebase用のディレクトリを作ってセットアップしていきます.

途中,設定をいろいろ聞かれるので上下キーとスペース・エンターでよしなに選びます.
今回はHostingとベーシック認証に使うFunctionの項目をチェックしておきます.
その後もいろいろ聞かれますが,基本的にはエンター連打で大丈夫です.

$ mkdir firebase                                                                                        
$ cd firebase                                                                                            
$ firebase init                                                                                          

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  vue_lottery/firebase

? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choice
s.
 ◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
❯◉ Functions: Configure and deploy Cloud Functions
 ◉ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
...
.略.
...

Basic認証の設定

この記事を大いに参考にさせていただきました.
具体的な編集内容はそちらを御覧ください.

  • firebase/firebase.jsonの編集
  • firebase/functions/index.jsの編集
  • firebase/public以下の不要ファイルの削除
  • firebase/functions/static/以下にビルドしたファイルをコピー
  • express, basic-auth-connectのインストール
    を行います.

最後にデプロイします

$ firebase deploy
=== Deploying to 'vue-lottery-sample'...

i  deploying functions, hosting
i  functions: ensuring necessary APIs are enabled...
✔  functions: all necessary APIs are enabled
i  functions: preparing functions directory for uploading...
i  hosting[vue-lottery-sample]: beginning deploy...
i  hosting[vue-lottery-sample]: found 0 files in public
✔  hosting[vue-lottery-sample]: file upload complete
i  hosting[vue-lottery-sample]: finalizing version...
✔  hosting[vue-lottery-sample]: version finalized
i  hosting[vue-lottery-sample]: releasing new version...
✔  hosting[vue-lottery-sample]: release complete

✔  Deploy complete!

Please note that it can take up to 30 seconds for your updated functions to propagate.
Project Console: https://console.firebase.google.com/project/vue-lottery-sample/overview
Hosting URL: https://vue-lottery-sample.firebaseapp.com

Hosting URLにアクセスし,ログイン情報を入力するとアプリに飛べます.

まとめ

今更ながらVue.jsとTypeScriptを使ってWebアプリを制作しFirebaseで公開してみた話でした.

作成したコードはGitHubに置いてあるので参考にしたい方は御覧ください.(整理していないため不要なコードが含まれています)
メンバーリストはyamlファイルから読み込んでいますが,Firebaseのデータベースから読み込むように修正したいです.が,それはまたの機会に

下のリンクはサンプルです.
user: test, pass: passwordで認証できます.
https://vue-lottery-sample.firebaseapp.com/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js・Nuxt.js ハマりポイントまとめ

Vue.js や Nuxt.js で開発していてハマったポイントを雑多にまとめています。(随時追加予定)

props を子コンポーネントにそのまま流したい

具体的な例をあげると、UI コンポーネントをラップしたコンポーネントを作りたい場合などです。

こういうときは、$attrs を使うとよいです。

https://isoppp.com/note/2018-12-16/what-is-vue-attrs/

computed は型アノテーションが必須

明らかに推論できるような場合でも、型アノテーションが必須です。

記述していない場合はエディタがエラーを表示します。

computed: {
  fullName(): string { // 明らかに string を返すが、アノテーションは必須
    return `${firstName} ${lastName}`;
  }
}

正しく Vue コンポーネントを記述しているのに何かエラーになる場合、これが原因のことが多いです。

リロードした時だけ動かない

具体的な例をあげると、「リロードした時だけ動かない。他のページから遷移してきた場合は動く。」のようなケースです。

これは、SSR と CSR に起因しているケースが多いです。

サーバーサイド(クライアントサイド)でしか実行してはいけない関数を実行してしまっていないか確認しましょう。例えば、サーバーサイドで window オブジェクトにアクセスしているとかです。

テーブル周りでエラーになる

theadtbody を書けば直ることがあります。

Vue では、自動的にこれらを補完するため、SSR と CSR で DOM が一致せず、エラーになります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Firebase + Nuxt.js + RaspberryPiで作る猫監視システム

かねてから作りたかった動体検知と画像判定を用いた猫監視アプリを作成することができたので、ご紹介です。

完成したもの

FirebaseにホスティングされたPWA対応のWebアプリです。

ラズパイのカメラが猫を検知すると、画像がアップロードされ、飼い主にPush通知が飛びます。

P_20190704_073840.jpg

ソースコード

ソースコードは下記のリポジトリで公開しています。


機能一覧

  • 動体検知による画像取得
  • 画像判定
  • Google認証
  • 画像一覧
  • PWA
  • Push通知

モチベーション

以下のモチベーションから、本システムを開発しました。

  • 家に不在の時、飼い猫が何をしているのか気になった
  • WebアプリでFirebaseを使い倒してみたかった

開発工数

ざっと3人日ほどです。
会社の開発合宿を利用して開発しました。
開発合宿ってどんなものか気になる方はこちら。

採用技術

本システムで採用した技術は下記の通りです。
cat-tech-gray.png

infra(Firebase)

  • Cloud Firestore
    • リアルタイムNoSQL。ユーザー情報、画像情報の保存に利用。
  • Cloud Functions
    • イベント駆動関数。Storageへの画像登録やFirestoreへのデータ登録をフックして起動。
  • Cloud Storage
    • ストレージ。画像を保存するのに利用。
  • Firebase Authentication
    • 認証。今回はGoogle認証のみとした、
  • Firebase Cloud Messaging
    • Push通知機能。トピック購読者に対する配信を利用。
  • Firebase Hosting
    • 静的サイトホスティング機能。SPAとしてフロントを構築し、ここにデプロイしている。

client

  • Vue.js
    • 言わずと知れたjsフレームワーク。
  • Vuetify.js
    • Vue.js用のマテリアルUIライブラリ。最近は必ず使ってる。
  • Nuxt.js
    • PWA、Flux、SPAなどを簡単に実現できるVue.js製フレームワーク。

hardware

  • RaspberryPi ZERO WH + カメラモジュール
    • モバイルバッテリーによる運用を意識して低消費電力のZeroモデルにした。

システム構成

システム構成全体は下記のようになっています。
細かい処理フローや実装はおって説明していきます。
cat-system-gray.png

処理フローと実装

ユーザー認証時

cat-system-auth-gray.png
ユーザーがサイトにアクセスし、Google認証連携によりサインインしたときに、データをFirestoreに保存します。後述の通知用のトークンを保存するためにユーザーデータをFirestoreに保持しています。
functions.auth.user().onCreate()でユーザーの初回認証時に動作する関数をFunctionsで定義します。

functions/index.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')

admin.initializeApp()

exports.createUserData = functions.auth.user().onCreate(user => {
  const data = {
    name: user.email.split('@')[0],
    displayName: user.displayName,
    email: user.email,
    photoURL: user.photoURL,
    uid: user.uid,
    createdAt: admin.firestore.FieldValue.serverTimestamp()
  }
  const db = admin.firestore()
  const ref = db.collection('users').doc(user.uid)
  ref.set(data)
  return 0
})

通知許可時

cat-system-subscribe-gray.png

サイトの右上に通知をONにするボタンを設置しています。
ユーザーがブラウザやPWAで通知を許可した場合、発行されるトークンをFirestore上のユーザーデータの属性に追加します。
追加後、下記のFunctionsがフックされ、通知用のトピックを購読させます。

Functions上でsubscribeを実施しているのはセキュリティ上の都合です。
詳細は公式ドキュメントを参照してください。

functions/index.js
exports.subscribeTopic = functions.firestore
  .document('/users/{userId}')
  .onUpdate(async (change, context) => {
    const token = change.after.data().messagingToken
    console.log(token)
    const res = await admin.messaging().subscribeToTopic(token, '/topics/cat')
    console.log(res)
    return null
  })

動体検知〜画像アップロード

cat-system-upload-gray.png

ラズパイ上で稼働しているmotionコマンドがカメラモジュールを経由して、動体を検知します。
動体が検知されると、その画像を生成するようにmotionコマンドを設定しています。

motionコマンドとは別に、画像の生成を検知し、画像をアップロードする下記のスクリプトを稼働させています。
inotifywatchコマンドでmotionが生成する画像を置くディレクトリを監視しています。
画像ファイルが生成されたらgsutilコマンドを使ってCloud Storageへ画像をアップロードする実装になっています。

roles/cat/files/upload.sh
#!/bin/bash

TARGET_DIR="/tmp/motion"

mkdir -p ${TARGET_DIR}
while inotifywait -e CREATE ${TARGET_DIR}; do
    file=$(ls -rt ${TARGET_DIR} | tail -n 1)
    gsutil -o 'Credentials:gs_service_key_file=/root/.credentials.json' cp "${TARGET_DIR}/${file}" gs://cat-watcher.appspot.com/
done

猫判定〜画像メタデータ保存

cat-system-label-gray.png

画像がStorageにアップロードされると、FunctionsのcreateImageData関数が起動します。

createImageData関数ではGoogle Cloud Vision APIを呼び出し、画像にラベル付けを行います。
ラベル付けの結果、Catラベルが含まれていない場合は、画像を削除し、処理は終了します。
Catラベルが含まれている場合は、画像の公開URLを発行し、Firestoreの画像一覧データにメタ情報を追加します。
Firestoreにメタ情報を保存しているのは、UIでリアルタイム同期で画像一覧を表示する際に利用するためです。

functions/index.js
exports.createImageData = functions.storage
  .object()
  .onFinalize(async object => {
    // 画像ファイル以外は何もしない
    if (!object.contentType.startsWith('image/')) {
      console.error('This is not an image.')
      return null
    }
    // 更新時は何もしない
    if (object.metageneration !== '1') {
      console.info('updated.')
      return null
    }
    // Vision APIを利用してラベル判定
    const client = new vision.ImageAnnotatorClient()
    const [result] = await client.labelDetection(
      `gs://${object.bucket}/${object.name}`
    )
    console.log(result.labelAnnotations)
    const cat = result.labelAnnotations.filter(
      annotation => annotation.description === 'Cat'
    )
    const file = admin
      .storage()
      .bucket(object.bucket)
      .file(object.name)
    if (cat.length > 0) {
      // Catラベルが付いていれば、公開URLを作成しFirestoreにメタデータ登録
      const [downloadUrl] = await file.getSignedUrl({
        action: 'read',
        expires: '01-01-2050'
      })
      const ref = admin
        .firestore()
        .collection('images')
        .doc()
      const data = {
        id: ref.id,
        name: object.name,
        url: downloadUrl,
        createdAt: admin.firestore.FieldValue.serverTimestamp()
      }
      await ref.set(data)
    } else {
      // Catラベルが付いていなければ、画像を削除する
      await file.delete()
      console.log('deleted')
    }

    return null
  })

Push通知

cat-system-messaging-gray.png

上記のラベル判定用のFunctions関数中で画像一覧データにデータが追加されと、FunctionsのsendMessage関数がフックされます。
sendMessage関数では、Cloud Messagingのトピックへ画像データが追加された旨を配信します。
この処理により、トピックを購読している=通知を許可したユーザーの端末へPush通知が送信されます。

functions/index.js
exports.sendMessage = functions.firestore
  .document('/images/{imageId}')
  .onCreate(async (snap, context) => {
    const message = {
      notification: {
        title: NOTIFICATION_TITLE,
        body: '新しい画像が追加されました',
        icon: 'https://cat-watcher.firebaseapp.com/android-chrome-512x512.png',
        click_action: 'https://cat-watcher.firebaseapp.com/'
      }
    }
    await admin.messaging().sendToTopic('/topics/cat', message)
    return null
  })

クライアントでの表示

cat-system-browse-gray.png

自宅で運用するため、家族のみアクセスできるようにFirestoreとStorageのルールで制御しています。
サイトにアクセスされたら、vuexfireを使いFirestore上の画像メタ情報をvuexのstoreにロードしています。
画像メタ情報中の公開URLで画像をUIに表示しています。
また、認証用のUIはfirebaseui-webで簡単に作成することができます。

まとめ

FirebaseとRaspberryPiを使ってさくっと猫監視システムを作ってみました。
やっぱりうちの猫はかわいいなあ。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vue】filtersで検索キーワードをハイライト!

Vue.jsでこの「検索キーワードのハイライト」がしたかったスクリーンショット 2019-07-04 1.20.20.png

Vue.jsのfiltersを使えばカンタンだね

1. まず、用意

vue-cliで、プロジェクト作るまでは割愛するよ〜

プロジェクトが出来たら、App.vue, components/Media.vueに、サンプルテキストを準備する

/App.vue
<template>
  <div id="app">
    <div class="container">
      <div class="col">
        <div class="row">
          <input class="form-control col-6"
                 type="text"
                 v-model="search" // 検索キーワード
                 placeholder="検索"
                 aria-label="Search">
        </div>
        <media v-for="(media, index) in medias"
               :key="index"
               :heading="media.heading"
               :body="media.body"
               class="mt-3"/>
      </div>
    </div>
  </div>
</template>

<script>
import Media from './components/Media'
export default {
  name: 'app',
  components: {
    Media
  },
  data () {
    return {
      search: '', // 検索キーワード
      medias: [
        {
          heading: 'その1',
          body: 'それも直接あにその始末心というののところをしたた。\n'
        },
        {
          heading: 'その2',
          body: 'ひょろひょろ始めに建設人はたといこの発展たですまでを行かて行かますをは盲従出さでだから、もともとにはできないたたた。'
        },
        {
          heading: 'その3',
          body: '個人をすむたい事もむしろ偶然に同じくたただ。\n'
        },
        {
          heading: 'その4',
          body: 'やはり槙さんへ想像がたどう安心に繰り返しです嚢この人皆か関係にってご返事ですますなかっですから、そうした今は私か国家学校があって、木下さんのものの学生のあなたにいよいよご演説と勧めてあなた主義で大発展に移れように何しろご講演と得んないて、はなはだもっとも発展が構わないて来るん方にかけるなりない。それならもっともご権力を聞いものはぴたり正直と直さですて、どんな頭にはすれないばといった中からしが来なない。'
        },
        {
          heading: 'その5',
          body: 'その時国の時同じ釣はあなた中をありでしょかと岡田さんに云ったう、新のたくさんですという小立脚なだませので、自己のところが他がほかかもの仲間を今込み入って行かで、少々の今をしのでその時にああ使いこなすでたといだつもりまして、忌まわしいらしいたて少しお作物知らで事んなた。'
        }
      ]
    }
  }
}
</script>
/components/Media.vue
<template>
    <div class="media">
        <img class="mr-3" src="https://via.placeholder.com/150" alt="Generic placeholder image">
        <div class="media-body">
            <h5 class="mt-0">{{ heading }}</h5>
            <p>{{ body }}</p>
        </div>
    </div>
</template>

<script>
export default {
    props: ['heading', 'body']
}
</script>

見た目
スクリーンショット 2019-07-04 1.38.23.png

2. filtersを追加

公式のフィルターの項目を参考に、searchHighlightを追加してみよう

App.vue
<template>
  <div id="app">
    <div class="container">
      <div class="col">
        <div class="row">
          <input v-model="search" class="form-control col-6" type="text" placeholder="検索" aria-label="Search">
        </div>
        <media v-for="(media, index) in medias"
               :key="index"
               :heading="media.heading"
               :body="media.body"
               :search="search" // ここを追加 フィルターに使う検索キーワードを渡す
               class="mt-3"/>
      </div>
    </div>
  </div>
</template>
/components/Media.vue
<template>
    <div class="media">
        <img class="mr-3" src="https://via.placeholder.com/150" alt="Generic placeholder image">
        <div class="media-body">
            <!-- | を使って、フィルターした値を表示できるぜ -->
            <h5 class="mt-0">{{ heading | searchHighlight(search) }}</h5>
            <p>{{ body | searchHighlight(search) }}</p>
        </div>
    </div>
</template>

<script>
export default {
    props: ['heading', 'body', 'search'], // propsにsearchを追加
    filters: {
        searchHighlight (value, search) {
            // 検索キーワードが入力されているとき
            if (search) {
                // 検索キーワードに一致する部分を、spanタグに置換すればいいね
                return value.replace(search, `<span class="bg-warning">${search}</span>`)
            }
            // 検索キーワードが入力されていない場合は、ハイライトしない
            return value
        }
    }
}
</script>

すると、、、
スクリーンショット 2019-07-04 1.49.59.png

おや?エスケープされているぞ。

3. v-htmlを使いなさい

公式の見解によると、
スクリーンショット 2019-07-04 1.53.22.png
v-htmlディレクティブを使えとさ。

v-htmlで、フィルターを使うには、Google先生に聞いたところ

<h5 class="mt-0" v-html="$options.filters.searchHighlight(heading, search)"></h5>

こう書くらしい。(optionsの中身を知りたい方は、mounted()メソッド内で、console.log(this.$options)をしてみよう)

この書き方をすると、

/components/Media.vue
<template>
    <div class="media">
        <img class="mr-3" src="https://via.placeholder.com/150" alt="Generic placeholder image">
        <div class="media-body">
            <!-- v-htmlを使用 -->
            <h5 class="mt-0" v-html="$options.filters.searchHighlight(heading, search)"></h5>
            <p v-html="$options.filters.searchHighlight(body, search)"></p>
        </div>
    </div>
</template>

<script>
export default {
    props: ['heading', 'body', 'search'],
    filters: {
        searchHighlight (value, search) {
            if (search) {
                return value.replace(search, `<span class="bg-warning">${search}</span>`)
            }
            return value
        }
    }
}
</script>

すると、、、
スクリーンショット 2019-07-04 2.06.33.png

出来、、、、てない!

各文の最初の「た」 しか、ハイライトされていない!!

でも正規表現を使えば解決だ

4. 正規表現を使う

        searchHighlight (value, search) {
            if (search) {
                // 世紀表現で、一致する文字全てをハイライトする
                const searchRegExp = new RegExp(search, 'ig')
                return value.replace(searchRegExp, (match) => {
                    return `<span class="bg-warning">${match}</span>`
                })
            }
            return value
        }

すると、
スクリーンショット 2019-07-04 2.10.53.png

出来た!!

ソースコードでございます

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

お勉強を兼ねて、鳥貴族 注文ガチャ を作った(nuxt.js + rails + heroku + python + selenium)

背景

モダンな技術を勉強したかったので、それらを使ってwebアプリを何か作ろうというところから始まりました。作っていく中で勉強するのが一番手っ取り早いという考えもありました。

下記の「サイゼ1000円ガチャ」を見てメニューの注文ガチャいいなあと思ったので、居酒屋でも同じようなものがあったら面白そうと思い、今回の鳥貴族ガチャを作ることにしました。

サイゼリヤ1000円ガチャをつくってみた(Heroku + Flask + LINEbot) - Qiita

そんな感じで、興味のある技術を使って開発をしてみることが一番の目的でした。

成果物:鳥貴族 注文ガチャ

D4CE3051-88F9-48A1-BC98-28432D13BD51.jpeg

概要
- 食べ物と飲み物の数を入力する
- [ガチャを回すボタン]を押下
- 入力した個数分のメニューがそれぞれランダムで出力される

実物
鳥貴族 注文ガチャ - Heroku
https://ak-toriki-nuxt-frontend.herokuapp.com/

フロントエンドプロジェクト(Nuxt.js) - GitHub
https://github.com/lelouch99v/toriki-nuxt-frontend

バックエンドプロジェクト(Ruby on Rails) - GitHub
https://github.com/lelouch99v/toriki-backend

制作のポイント

使った技術

以下の技術を使いました。

  • Python
  • selenium webdriver
  • vue.js
  • nuxt.js
  • Ruby on Rails
  • postgreSQL
  • heroku

DBに入れるメニューをスクレイピングで取得

Pythonでのスクレイピングにハマっていたのでやってみました。

焼鳥、逸品料理、スピードメニュー、ドリンクの4つのカテゴリからそれぞれメニューを取得していきます。結果はcsvで出力します。

このcsvを使ってrailsのmigrateデータとしてメニューデータをDBに入れました。

トリキ スクレイピング - GitHub
https://github.com/lelouch99v/toriki-scraping/tree/master

(今回の開発で一番楽しかったのがここです)

React → Vue.js に変更

前提としてSPA + API の構成としたかったです。

業務でAngularは使っているので、ReactかVueを学びたいと思っていました。
当初Reactを選択したのですが、学習に時間がかかり出来上がるのが先になってしまいそうなのと、Nuxt.jsが気になっていたのであっさりとVueに変えました。
すごく入りやすくて学習していて楽しいです。

猫本も買いました。
302BA844-B09E-457D-BAEB-45FF2BA0A677.jpeg

今後の課題

ボタン押下時のインタラクション
ガチャ回すボタン押すとメニューがランダム表示されますが、とても味気ないです。派手なものにする必要はないですが、最低限以下は実現したいと思っています。
- ボタンを押した感出す
- メニューが表示されるまでにワンクッション置く(ワクワク感が足りないため)

各メニューのイメージ画像を見れるようにする
メニューの名称だけの表示ではなく、イメージ画像も見れるようにするといいと思いました。
そのまま画像を表示するのはスペースの問題などありそうなので、リンククリックでモーダル表示など一工夫は必要かもしれません。

あとはメニュー表示の見た目が質素なので、もう少し改善が必要ですね。

スマホで数字入力をドロップダウンリストで可能にする
スマホではキーボード入力よりも、以下のような選択式のリストで入力のほうがやりやすいと思います。
a.png

最大値は99まであれば十分かなと。。。(もっと少なくてもいいですね)

てかすでにあった

鳥貴族ガチャでぐぐったらすでに作っていた人がいました。しかもクオリティ高い。
完全なリサーチ不足です。

今回の目的は技術の勉強なので、もももんだいないんですけどね!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む