シェルスクリプトマガジン

Webアプリケーションの正しい作り方(Vol.67記載)

著者:しょっさん

ソフトウエアを正しく作るために、エンジニアたちはどんなことを知らなければならないでしょうか。実際のコードを使って、より良くしていくためのステップを考えてみましょう。第7回は、JavaScriptフレームワーク「Vue.js」でアプリを作成し、テストとリリースの方法を紹介します。

シェルスクリプトマガジン Vol.67は以下のリンク先でご購入できます。

図2 Vue.js および各ライブラリを利用するための定義(simple.htmlから抜粋)

<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ファイル側の定義

<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側の定義

const router = new VueRouter({
  routes: [
    // 精算処理
    {
      path: "/expense",
    },
    // 支払処理
    {
      path: "/payment",
    },
    // 認証 - ログイン
    {
      path: "/login",
    },
    // 認証 - ログアウト
    {
      path: "/logout",
    },
    // どのURLにも該当しなかった場合
    {
      path: "*",
      redirect: "/expense",
    },
  ],
});

図5 認証部分のVueコンポーネントとVue Router定義

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テンプレート

<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定義

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テンプレート

<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定義

// 経費リストを 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テンプレート

<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ファイルの追加・改修部分

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ファイル

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用のモジュールとルーティング定義

// 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)

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)

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ファイル

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;