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

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

著者:しょっさん

ソフトウエアを正しく作るために、エンジニアたちはどんなことを知らなければならないでしょうか。実際のコードを使って、より良くしていくためのステップを考えてみましょう。第3回は、前回のプロジェクトの計画や方針に基づいて2回の開発サイクル(イテレーション)を回します。

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

図4 TypeScriptに書き換えたメインのプログラム(index.ts)

import Express from 'express';
const app = Express();
import { Expense } from './models/expense';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import session from 'express-session';

const users = {
  'user01': 'p@ssw0rd',
  'user02': 'ewiojfsad'
};

app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(session({
  secret: 'secret',
  resave: false,
  saveUninitialized: false,
  cookie: {
    maxAge: 24 * 30 * 60 * 1000
  }
}));

app.get('/login', (req: Express.Request, res: Express.Response): void => {
  res.send('<h1>LOGIN</h1><form action="/login" method="post">ユーザーID:<input type="text" name="user" size="40"><br />パスワード<input type="password" name="password"><input type="submit" value="ログイン">');
});

app.post('/login', (req: Express.Request, res: Express.Response): void => {
  if (eval("users." + req.body.user) === req.body.password) {
    if (req.session) {
      req.session.user = req.body.user;
    }
  }
  res.redirect('/');
});

app.post('/expense', (req: Express.Request, res: Express.Response): void => {
  Expense.create(req.body)
    .then(() => {
      res.redirect('/');
    });
});

app.get('/', (req: Express.Request, res: Express.Response): void => {
  const user = req!.session!.user || '名無しの権兵衛';
  res.writeHead(200, { "Content-Type": "text/html" });
  res.write(<h1>Hello ${user}</h1><table><tr><th>ID</th><th>申請者名</th><th>日付</th><th>経費タイプ</th><th>経費詳細</th><th>金額</th></tr>);
  Expense.findAll()
    .then(results => {
      for (let i in results) {
        res.write(<tr><td>${results[i].id}</td><td>${results[i].user_name}</td><td>${results[i].date}</td><td>${results[i].type}</td><td>${results[i].description}</td><td>${results[i].amount}</td></tr>);
      }
      res.write('</table><a href="/login">ログイン</a><a href="/submit">経費入力</a>');
      res.end();
    });
});

app.get('/submit', (req: Express.Request, res: Express.Response): void => {
  const user = req!.session!.user || '名無しの権兵衛';
  res.send(<h2>経費入力</h2><form action="/expense" method="post">申請者名:<input type="text" name="user_name" value="${user}"><br />日付:<input type="date" name="date"><br />経費タイプ:<input type="text" name="type"><br />経費詳細:<input type="text" name="description"><br />金額:<input type="number" name="amount"><br /><input type="submit" value="経費申請">);
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(http://localhost:${port});
})

export default app;

図5 TypeScriptで作成した自作のモデルファイル(expense.ts)

import { Sequelize, Model, DataTypes } from 'sequelize';

// todo: データベース接続を定義する Typescript モジュール
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];
let sequelize;
if (config.use_env_variable) {
  const config_url: any = process.env[config.use_env_variable];
  sequelize = new Sequelize(config_url, config);
} else {
  sequelize = new Sequelize(config.database, config.username, config.password, config);
}

class Expense extends Model {
  public id!: number;
  public user_name!: string;
  public date!: Date;
  public type!: string;
  public description!: string | null;
  public amount!: number;
  public readonly careated_at!: Date;
  public readonly updated_at!: Date;
}

Expense.init({
  id: {
    type: DataTypes.INTEGER.UNSIGNED,
    autoIncrement: true,
    allowNull: false,
    primaryKey: true,
  },
  user_name: {
    type: DataTypes.STRING(256),
    allowNull: false,
    defaultValue: ''
  },
  date: {
    type: DataTypes.DATE,
    allowNull: false
  },
  type: {
    type: DataTypes.STRING(256),
    allowNull: false
  },
  description: {
    type: DataTypes.TEXT,
  },
  amount: {
    type: DataTypes.INTEGER.UNSIGNED,
    allowNull: false
  }
}, {
  tableName: 'expenses',
  underscored: true,
  sequelize: sequelize
});

export { Expense };

図6 トランスパイルオプションファイル(tsconfig.json)

{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "lib": [
      "es2018"
    ],
    "sourceMap": true,
    "outDir": "./dist",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": [
    "./src/**/*.ts"
  ],
  "exclude": [
    "node_modules",
    "**/*.test.ts"
  ]
}

図7 gulpfile.jsファイルの内容

const { src, dest, parallel, series } = require('gulp');
const ts = require('gulp-typescript');
const tsconfig = require('./tsconfig.json');

// gulp固有の設定
const config = {
  output: 'dist/',
  json: {
    source: 'src/**/*.json'
  }
};

// typescript のトランスパイルオプション ← tsconfig.json を再利用する
const typescript = () => {
  return src(tsconfig.include)
    .pipe(ts(tsconfig.compilerOptions))
    .pipe(dest(config.output));
};

// json ファイルのアウトプットディレクトリへのコピーを司る指令
const json = () => {
  return src(config.json.source)
    .pipe(dest(config.output));
};

// 実行時オプション
exports.typescript = typescript;
exports.default = series(parallel(typescript, json));

図8 jest.config.jsの内容

module.exports = {
  coverageDirectory: "coverage",
  preset: 'ts-jest',
  testEnvironment: "node",
};

図9 index.test.tsの内容

test('1 adds 2 is equal 3', () => {
   expect(1 + 2).toBe(3);
 })

図11 super.test.tsファイルの内容

import app from '../src';
import request from 'supertest';

describe('Root', () => {
  it('Root index is valid', async () => {
    const response = await request(app).get("/");
    expect(response.status).toBe(200);
  });
});

describe('Login', () => {
  const userCredentials = {
    user: 'user01',
    password: 'p@ssw0rd'
  };
  it('login form is varid', async () => {
    const response = await request(app).get("/login");
    expect(response.status).toBe(200);
  });
  it('login with user credential is valid', async () => {
    const response = await request(app).post("/login")
      .send(userCredentials);
    expect(response.status).toBe(302);
  })
});

describe('submit', () => {
  it('A submit form is valid', async () => {
    const response = await request(app).get("/submit");
    expect(response.status).toBe(200);
  });
});

図13 .circleci/config.ymlファイルの内容

# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2
jobs:
  build:
    docker:
      # specify the version you desire here
      - image: circleci/node:12.10.0

      # Specify service dependencies here if necessary
      # CircleCI maintains a library of pre-built images
      # documented at https://circleci.com/docs/2.0/circleci-images/
      # - image: circleci/mongo:3.4.4

    working_directory: ~/repo

    steps:
      - checkout

      # Download and cache dependencies
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            # fallback to using the latest cache if no exact match is found
            - v1-dependencies-

      - run: npm install

      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}

      # npm migrate
      - run: npm run migrate

      # run tests!
      - run: npm test

図14 書き換えたsuper.test.tsの内容

import app from '../src';
import request from 'supertest';

describe('Root', () => {
  it('Root index is valid', async () => {
    const response = await request(app).get("/");
    expect(response.status).toBe(200);
  });
});

describe('Login', () => {
  const userCredentials = {
    user: 'user01',
    password: 'p@ssw0rd'
  };
  it('login form is varid', async () => {
    const response = await request(app).get("/login");
    expect(response.status).toBe(200);
  });
  it('login with user credential is valid', async () => {
    const response = await request(app).post("/login")
      .send(userCredentials);
    expect(response.status).toBe(302);
  })
});

describe('submit', () => {
  it('A submit form is valid', async () => {
    const response = await request(app).get("/expenses/submit");
    expect(response.status).toBe(200);
  });
});

describe('payment', () => {
  it('A payment list is valid', async () => {
    const response = await request(app).get("/expenses/payment");
    expect(response.status).toBe(200);
  });
});

// エラーテスト
describe('/expense', () => {
  it('This URI is not valid', async () => {
    const response = await request(app).get("/expense");
    expect(response.status).toBe(404);
  });
});

図15 index.tsファイルの内容

import Express from 'express';
const router = Express.Router();

// GET / 最初に開く画面
router.get('/', (req: Express.Request, res: Express.Response) => {
  const user = req!.session!.user || '名無しの権兵衛';
  res.send(<h1>Hello ${user}</h1><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>);
})

export default router;

図16 login.tsファイルの内容

import Express from 'express';
const router = Express.Router();

// ユーザー&パスワード
const users = {
  'user01': 'p@ssw0rd',
  'user02': 'ewiojfsad'
};

// GET /login ユーザーログインフォーム
router.get('/', (req: Express.Request, res: Express.Response): void => {
  res.send('<h1>LOGIN</h1><form action="/login" method="post">ユーザーID:<input type="text" name="user" size="40"><br />パスワード<input type="password" name="password"><input type="submit" value="ログイン"><br /><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>');
});

// POST / ユーザーの認証処理
router.post('/', (req: Express.Request, res: Express.Response): void => {
  if (eval("users." + req.body.user) === req.body.password) {
    if (req.session) {
      req.session.user = req.body.user;
    }
  }
  res.redirect('/');
});

export default router;

図17 payment.tsファイルの内容

import Express from 'express';
const router = Express.Router();
import { Expense } from '../../models/expense';

// GET /expenses/payment もともと、最初に開かれる画面だった部分
router.get('/', (req: Express.Request, res: Express.Response): void => {
  const user = req!.session!.user || '名無しの権兵衛';
  res.writeHead(200, { "Content-Type": "text/html" });
  res.write(<h1>Hello ${user}</h1><table><tr><th>ID</th><th>申請者名</th><th>日付</th><th>経費タイプ</th><th>経費詳細</th><th>金額</th></tr>);
  Expense.findAll()
    .then(results => {
      for (let i in results) {
        res.write(<tr><td>${results[i].id}</td><td>${results[i].user_name}</td><td>${results[i].date}</td><td>${results[i].type}</td><td>${results[i].description}</td><td>${results[i].amount}</td></tr>);
      }
      res.write('</table><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>');
      res.end();
    });
});

export default router;

図18 submit.tsファイルの内容

import Express from 'express';
const router = Express.Router();
import { Expense } from '../../models/expense';

// GET /expenses/submit 入力フォーム
router.post('/', (req: Express.Request, res: Express.Response): void => {
  Expense.create(req.body)
    .then(() => {
      res.redirect('/');
    });
});

// POST /expenses/submit 経費の申請
router.get('/', (req: Express.Request, res: Express.Response): void => {
  const user = req!.session!.user || '名無しの権兵衛';
  res.send(<h2>経費入力</h2><form action="/expenses/submit" method="post">申請者名:<input type="text" name="user_name" value="${user}"><br />日付:<input type="date" name="date"><br />経費タイプ:<input type="text" name="type"><br />経費詳細:<input type="text" name="description"><br />金額:<input type="number" name="amount"><br /><input type="submit" value="経費申請"><br /><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>);
});

export default router;

図20 分割後のindex.tsファイルの内容

import Express from 'express';
const app = Express();
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import session from 'express-session';

app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(session({
  secret: 'secret',
  resave: false,
  saveUninitialized: false,
  cookie: {
    maxAge: 24 * 30 * 60 * 1000
  }
}));

// ルーティング
import index from './routes/index';
import login from './routes/login'; // ログイン機能
import payment from './routes/expenses/payment'; // 支払い機能
import submit from './routes/expenses/submit'; // 請求機能

app.use('/', index);
app.use('/login', login);
app.use('/expenses/payment', payment);
app.use('/expenses/submit', submit);

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(http://localhost:${port});
})

export default app;

図21 package.jsonファイルの内容

{
  "name": "webapp",
  "version": "0.3.0",
  "description": "This application is sample code of Shell Script Magazine",
  "main": "dist/index.js",
  "scripts": {
    "dev": "ts-node ./src/index.ts",
    "test": "jest",
    "clean": "rimraf dist/*",
    "migrate": "sequelize db:migrate",
    "transpile": "gulp",
    "build": "npm run clean && npm run migrate && npm run transpile",
    "start": "node ."
  },
  "engines": {
    "node": "12.10.0"
  },
  "author": "しょっさん",
  "license": "ISC",
  "dependencies": {
    "cookie-parser": "^1.4.4",
    "express": "^4.16.4",
    "express-session": "^1.16.1",
    "gulp": "^4.0.2",
    "gulp-cli": "^2.2.0",
    "gulp-typescript": "^5.0.1",
    "pg": "^7.12.1",
    "rimraf": "^3.0.0",
    "sequelize": "^5.18.0",
    "sequelize-cli": "^5.5.1"
  },
  "devDependencies": {
    "@types/bluebird": "^3.5.27",
    "@types/cookie-parser": "^1.4.2",
    "@types/express": "^4.17.1",
    "@types/express-session": "^1.15.14",
    "@types/jest": "^24.0.18",
    "@types/node": "^12.7.3",
    "@types/pg": "^7.11.0",
    "@types/supertest": "^2.0.8",
    "@types/validator": "^10.11.3",
    "jest": "^24.9.0",
    "jest-junit": "^8.0.0",
    "sqlite3": "^4.1.0",
    "supertest": "^4.0.2",
    "ts-jest": "^24.0.2",
    "ts-loader": "^6.0.4",
    "ts-node": "^8.3.0",
    "typescript": "^3.6.2"
  }
}