著者:しょっさん
ソフトウエアを正しく作るために、エンジニアたちはどんなことを知らなければならないでしょうか。実際のコードを使って、より良くしていくためのステップを考えてみましょう。第7回は、JavaScriptフレームワーク「Vue.js」でアプリを作成し、テストとリリースの方法を紹介します。
シェルスクリプトマガジン Vol.67は以下のリンク先でご購入できます。
図2 Vue.js および各ライブラリを利用するための定義(simple.htmlから抜粋)
1 2 3 4 |
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script> <script src="https://unpkg.com/vue-router@3.3.2/dist/vue-router.js"></script> <script src="https://cdn.jsdelivr.net/npm/axios@0.19.2/dist/axios.min.js"></script> <script src="./jwt-decode.min.js"></script> |
図3 Vue RouterのHTMLファイル側の定義
1 2 3 4 5 6 7 8 |
<div id="app"> <h1>経費精算アプリケーション(Vue.js)</h1> <router-link to="/expense">経費登録</router-link> <router-link to="/payment">経費精算</router-link> <router-link to="/login">ログイン</router-link> <router-link to="/logout">ログアウト</router-link> <router-view /> </div> |
図4 Vue RouterのJavaScript側の定義
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
const router = new VueRouter({ routes: [ // 精算処理 { path: "/expense", }, // 支払処理 { path: "/payment", }, // 認証 - ログイン { path: "/login", }, // 認証 - ログアウト { path: "/logout", }, // どのURLにも該当しなかった場合 { path: "*", redirect: "/expense", }, ], }); |
図5 認証部分のVueコンポーネントとVue Router定義
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
const Login = { template: "#login", data: function () { return { user: localStorage.user || "", password: localStorage.password || "", remember: localStorage.remember || false, error: false, }; }, methods: { login: function () { axios .post(`${baseUrl}/api/auth`, { user: this.user, password: this.password, }) .then((response) => { if (response.status === 200) { // ログインが成功した場合は、ローカルストレージにトークンを保管する(ログインが成功した状態とする) this.error = false; localStorage.token = response.data.token; // "remember me" チェックボックスが付いていたら、各々の入力項目を保管する if (this.remember) { localStorage.user = this.user; localStorage.password = this.password; localStorage.remember = true; } else { // 逆にオフであれば入力項目の内容を破棄する delete localStorage.user; delete localStorage.password; delete localStorage.remember; } // ログイン成功したら /expense へ切り替える this.$router.replace("/expense"); } else { this.error = true; } }) .catch((response) => { this.error = true; this.remember = false; console.log(response); }); }, }, }; const router = new VueRouter({ routes: [ (略) // ログイン画面 { path: "/login", component: Login, }, // ログアウト処理 { path: "/logout", beforeEnter: (to, from, next) => { delete localStorage.token; next("/login"); }, }, ], }); |
図6 認証部分のHTMLテンプレート
1 2 3 4 5 6 7 8 9 10 |
<script type="text/x-template" id="login"> <form @submit.prevent="login"> <input v-model="user" type="email" placeholder="Your Email" autofocus="" /> <input v-model="password" type="password" placeholder="Your Password" /> <button type="submit">ログイン</button> </form> <div v-show="error"> <p>ログイン失敗</p> </div> </script> |
図7 請求処理ののVueコンポーネントとVue Router定義
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
const Expense = { template: "#expense", data: function () { let decoded = {}; if (localStorage.token) { // トークン内のユーザー情報を基に変数へ配置 decoded = jwt_decode(localStorage.token); } return { user: decoded.email || "", id: decoded.id || "", user_name: decoded.user_name || "", date: "", type: "", amount: "", description: "", error: false, }; }, // 経費を登録するメソッド methods: { expense: function () { axios .post( `${baseUrl}/api/expense`, { user: this.user, user_id: this.id, user_name: this.user_name, date: this.date, type: this.type, amount: this.amount, description: this.description, }, { headers: { Authorization: `Bearer ${localStorage.token}`, }, } ) .then((response) => { if (response.status === 200) { // 正常に登録できた場合は、変更が伴うフィールドをクリアーして、再度入力可能な状態にする this.error = false; console.log(response); this.date = ""; this.type = ""; this.amount = ""; this.description = ""; } }) .catch((response) => { this.error = true; console.log(response); }); }, }, }; const router = new VueRouter({ routes: [ // 経費登録 { path: "/expense", component: Expense, beforeEnter: (to, from, next) => { // 認証前の場合は /login ページへ遷移する if (!localStorage.token) { next({ path: "/login", query: { redirect: to.fullPath }, }); } else { next(); } }, }, (略) }); |
図8 請求処理のHTMLテンプレート
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<script type="text/x-template" id="expense"> <div> <form @submit.prevent="expense"> <input v-model="user_name" type="text" placeholder="Your Name" /> <input v-model="user" type="email" placeholder="Your Email" /> <input v-model="id" type="hidden" placeholder="Your User ID" /> <input v-model="date" type="datetime-local" placeholder="経費利用日" autofocus="" /> <input v-model="type" type="text" placeholder="費目" /> <input v-model="amount" type="number" placeholder="金額" /> <input v-model="description" type="text" placeholder="費用詳細" /> <button type="submit">経費申請</button> </form> <p v-if="error" class="error">経費登録失敗</p> </div> </script> |
図9 支払処理のVueコンポーネントとVue Router定義
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
// 経費リストを axios を利用して取得する関数 const getPayments = function (callback) { axios .get(`${baseUrl}/api/payment`, { // JWTの認可ヘッダー headers: { Authorization: `Bearer ${localStorage.token}`, }, }) .then((response) => { if (response.status === 200) { // "response.data" 配下に経費リストが含まれる callback(null, response.data); } else { callback(true, response); } }) .catch((response) => { callback(true, response); }); }; // 経費リスト用の Vueコンポーネント const Payment = { template: "#payment", data: function () { return { loading: false, error: false, payments: function () { return []; }, }; }, // 初期化されたときにデータを取得する created: function () { this.fetchData(); }, // ルーティングが変更されてきたときに再度データを取得する watch: { $route: "fetchData", }, // 経費データを取得するメソッドのメイン部分 methods: { fetchData: function () { this.loading = true; getPayments( function (err, payments) { this.loading = false; if (!err) this.payments = payments; else this.error = true; }.bind(this) ); }, }, }; const router = new VueRouter({ routes: [ (略) { path: "/payment", component: Payment, beforeEnter: (to, from, next) => { // 認証前の場合は /login ページへ遷移する if (!localStorage.token) { next({ path: "/login", query: { redirect: to.fullPath }, }); } else { next(); } }, }, (略) ], }); |
図10 支払処理のHTMLテンプレート
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<script type="text/x-template" id="payment"> <div> <table> <tr> <th>ユーザー名</th> <th>発生日</th> <th>費目</th> <th>経費</th> <th>詳細</th> </tr> <tr v-for="payment in payments"> <td>{{payment.user_name}}</td> <td>{{payment.date}}</td> <td>{{payment.type}}</td> <td>{{payment.amount}}</td> <td>{{payment.description}}</td> </tr> </table> <div class="loading" v-if="loading">アクセス中...</div> <p v-if="error" class="error">経費取得失敗</p> </div> </script> |
図14 controllers/auth/authentication.tsファイルの追加・改修部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import jwt from "jsonwebtoken"; export class Authentication { (略) static verifyLocal(username: string, password: string, done: any) { User.findOne({ where: { email: username, deleted_at: null, }, }) .then((user) => { if (user && bcrypt.compareSync(password, user.hash)) { const opts = { issuer: process.env.ISSUER, audience: process.env.AUDIENCE, expiresIn: process.env.EXPIRES, }; const secret: string = process.env.SECRET || "secret"; const token: string = jwt.sign( { email: user.email, id: user.id, user_name: user.last_name + " " + user.first_name, }, secret, opts ); return done(null, token); } return done(true, "authentication error"); }) .catch((err) => done(true, err)); } (略) } |
図15 controllers/auth/authorization.tsファイル
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import { Request, Response, NextFunction } from "express"; import { User } from "../../models/user"; import passport from "passport"; import { Strategy as JWTStrategy, ExtractJwt } from "passport-jwt"; export class Authorization { // JWTトークンで該当するユーザーの有無をチェック static verifyJWT(req: Request, jwt_payload: any, done: any) { User.findOne({ where: { email: jwt_payload.email, deleted_at: null, }, }).then((user) => { if (!user) return done(null, false); return done(null, user.get()); }); } // JWT Strategyの定義 static setJWTStrategy() { const field = { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), issuer: process.env.ISSUER, audience: process.env.AUDIENCE, secretOrKey: process.env.SECRET || "secret", passReqToCallback: true, }; passport.use(new JWTStrategy(field, this.verifyJWT)); } // 認可チェック static isAuthorized(req: Request, res: Response, next: NextFunction) { passport.authenticate("jwt", { session: false }, (err, user) => { if (err) { res.status(401).json({ status: "10001" }); } if (!user) { res.status(401).json({ status: "10002" }); } else { return next(); } })(req, res, next); } } |
図16 API用のモジュールとルーティング定義
1 2 3 4 5 6 7 8 9 |
// API用ルーティング先のモジュール import auth from "./api/auth"; import payment from "./api/payment"; import expense from "./api/expense"; // APIルーティング app.use("/api/auth", auth); app.use("/api/expense", Authorization.isAuthorized, expense); app.use("/api/payment", Authorization.isAuthorized, payment); |
図18 請求処理(/api/expense.ts)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import { Request, Response, NextFunction } from "express"; import Express from "express"; import { Expense } from "../models/expense"; const router = Express.Router(); // POST 経費の入力 router.post("/", (req: Request, res: Response, next: NextFunction) => { Expense.create(req.body) .then((result) => { res.status(200).json(result); }) .catch((err) => { console.log(err); res.status(400).json({ id: 20002, message: err }); }); }); export default router; |
図19 支払処理(/api/payment.ts)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { Request, Response, NextFunction } from "express"; import Express from "express"; import { Expense } from "../models/expense"; const router = Express.Router(); // POST / ユーザーの認証処理 router.get("/", (req: Request, res: Response, next: NextFunction) => { Expense.findAll() .then((results) => { res.status(200).json(results); }) .catch((err) => { res.status(400).json({ id: 20011, message: err }); }); }); export default router; |
図35 修正した/src/index.tsファイル
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
import { Request, Response, NextFunction } from "express"; import Express from "express"; const app = Express(); import logger from "morgan"; import { Authentication } from "./controllers/auth/authentication"; import { Authorization } from "./controllers/auth/authorization"; app.use(logger("dev")); app.use(Express.json()); app.use(Express.urlencoded({ extended: true })); Authentication.initialize(app); Authentication.setLocalStrategy(); Authorization.setJWTStrategy(); app.use(Express.static("htdocs")); // API用ルーティング import auth from "./api/auth"; import payment from "./api/payment"; import expense from "./api/expense"; // API app.use("/api/auth", auth); app.use("/api/expense", Authorization.isAuthorized, expense); app.use("/api/payment", Authorization.isAuthorized, payment); app.use((req: Request, res: Response, next: NextFunction) => { var err: any = new Error("Not Found"); err.status = 404; next(err); }); // error handler app.use((err: any, req: Request, res: Response, next: NextFunction) => { res.locals.message = err.message; res.locals.error = req.app.get("env") === "development" ? err : {}; res.status(err.status || 500); res.json(err); }); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`http://localhost:${port}`); }); export default app; |