Node.js
TypeScript
restify
webpack
jest

restify + TypeScript + Jestの開発環境をセットアップしてみる

はじめに

最近はTypeScript+ExpressでWeb APIサーバーぼちぼちやってるんだけど、restifyなるExpress拡張がなんかいい感じっぽいので今回はrestify + TypeScriptでRest API Serverを開発してみることにした。テストは最近気に入ってるJestのTypeScript用 ts-jest を使う。

セットアップ

Get started Express + TypeScript する が大変参考になった。restifyとexpressを読み替えればいい感じの Webpack なトランスパイル環境が整えられる。感謝。

以下、コマンドと設定を垂れ流し。

モジュールをインストールする

globalに TypeScriptやWebpackが入ってない場合は入れておく

npm i -g typescript webpack webpack-cli

プロジェクトルートを作成して必要なモジュールをインストールする

mkdir restify-typescript-example && cd $_
mkdir -p src/__tests__
npm init -f

npm i --save restify node-fetch

npm i --save-dev typescript ts-loader tslint tslint-loader tslint-config-airbnb jest ts-jest @types/restify @types/jest @types/node-fetch

npm i --save-dev webpack webpack-cli webpack-node-externals

webpack.configを設定する

上記記事を参考に webpack.config.dev.js webpack.config.prod.jsをプロジェクトルートに作成する

webpack.config.dev.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'development',
  entry: './src/index.ts',
  target: 'node',               // Module not found: Error: Can't resolve 'fs' 対策
  externals: [nodeExternals()], 
  devtool: 'inline-source-map',
  module: {
    rules: [
        {
          enforce: 'pre',
          loader: 'tslint-loader',
          test: /\.ts$/,
          exclude: [
            /node_modules/,
            "**/*.test.ts" 
          ],
          options: {
            emitErrors: true
          }
        },
        {
          loader: 'ts-loader',
          test: /\.ts$/,
          exclude: [
            /node_modules/,
            "**/*.test.ts" 
          ],
          options: {
            configFile: 'tsconfig.dev.json'
          }
        }
    ]
  },
  resolve: {
    extensions: [ '.ts', '.js' ]
  },
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'dist')
  }
};
webpack.config.prod.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'production',
  entry: './src/index.ts',
  target: 'node',               // Module not found: Error: Can't resolve 'fs' 対策
  externals: [nodeExternals()], 
  module: {
    rules: [
      {
        enforce: 'pre',
        loader: 'tslint-loader',
        test: /\.ts$/,
        exclude: [
          /node_modules/,
          "**/*.test.ts" 
        ],
        options: {
          emitErrors: true
        }
      },
      {
        loader: 'ts-loader',
        test: /\.ts$/,
        exclude: [
          /node_modules/,
          "**/*.test.ts" 
        ],
        options: {
          configFile: 'tsconfig.prod.json'
        }
      }
    ]
  },
  resolve: {
    extensions: [ '.ts', '.js' ]
  },
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'dist')
  }
};

テストファイルを無視するため exclude"**/*.test.ts"した

tsconfig を設定する

tsconfig.dev.json
{
  "compilerOptions": {
    "sourceMap": true,
    "noImplicitAny": true,
    "module": "es6",
    "target": "es5",
    "jsx": "react",
    "lib": ["es2018", "dom"],
    "moduleResolution": "node",
    "removeComments": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "strictFunctionTypes": false
  }
}
tsconfig.prod.json
{
  "compilerOptions": {
    "noImplicitAny": true,
    "module": "es6",
    "target": "es5",
    "jsx": "react",
    "lib": ["es2018", "dom"],
    "moduleResolution": "node",
    "removeComments": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "strictFunctionTypes": false
  }
}

記事にはないが tsconfig.jest.json を追加した。こいつでts-jest実行時のみコンパイルオプション module: "commonjs"を与えてやらないと ERROR: 'import' and 'export' may appear only with 'sourceType: "module"' でテストがコケる

https://github.com/kulshekhar/ts-jest#tsconfig

tsconfig.jest.json
{
  "extends": "./tsconfig.dev.json",
  "compilerOptions": {
    "module": "commonjs"
  }
}

tslintを設定する

tslint.json
{
  "extends": "tslint-config-airbnb",
  "rules": {
    "ter-indent": [true, 2],
    "no-boolean-literal-compare": false 
  }
}

Lintはtslint-config-airbnb
no-boolean-literal-compare: falseにした。ビルド時に警告が出てうざいので

package.jsonを設定する

package.json
{
  "name": "restify-typescript-example",
  "version": "0.1.0",
  "description": "Restify TypeScript Example",
  "main": "index.js",
  "scripts": {
    "start": "node dist/index.js",
    "test": "jest"
  },
  "jest": {
    "globals": {
      "ts-jest": {
        "tsConfigFile": "tsconfig.jest.json"
      }
    },
    "transform": {
      "^.+\\.(ts|tsx)$": "ts-jest"
    },
    "testURL": "http://localhost/",
    "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(ts?|jsx?|tsx?)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ],
    "collectCoverage": true,
    "collectCoverageFrom": [
      "src/**/*.ts"
    ]
  },
  "keywords": [],
  "author": "bathtimefish",
  "license": "MIT",
  "dependencies": {
    "node-fetch": "^2.2.0",
    "restify": "^7.2.1"
  },
  "devDependencies": {
    "@types/jest": "^23.3.1",
    "@types/node-fetch": "^2.1.2",
    "@types/restify": "^7.2.3",
    "jest": "^23.4.2",
    "ts-jest": "^23.1.2",
    "ts-loader": "^4.4.2",
    "tslint": "^5.11.0",
    "tslint-config-airbnb": "^5.9.2",
    "tslint-loader": "^3.6.0",
    "typescript": "^3.0.1",
    "webpack": "^4.16.4",
    "webpack-cli": "^3.1.0",
    "webpack-node-externals": "^1.7.2"
  }
}

とりあえず全部載せとくけど、編集したのは scriptsjest セクションのみ。jest.testURL: "http://localhost/" を設定しないと "SecurityError: localStorage is not available for opaque origins" などと吐かれてテストがコケる。これはハマった。以下の記事に救われました。ありがとうございます。

jestで"SecurityError: localStorage is not available for opaque origins"と言われてテストがコケる

API Serverのサンプルコードを書く

src/index.ts を以下のようにする。Quick Startのecho serverをTSで書いただけ。

index.ts
import * as restify from 'restify';

const respond =  (req:restify.Request, res:restify.Response, next:restify.Next) => {
  res.send('hello ' + req.params.name);
  next();
};

export const server = restify.createServer({
  name: 'Restify TypeScript Echo Server',
  version: '0.1.0',
});

server.get('/hello/:name', respond);
server.head('/hello/:name', respond);

server.listen(8080, () => {
  console.log('%s listening at %s', server.name, server.url);
});

テストコードを書く

src/__tests__/index.test.ts を以下のようにする。簡単なテスト

index.test.ts
import * as index from '../index';
import fetch from 'node-fetch';

describe('Index', () => {

  const server = index.server;

  afterAll(() => {
    console.log('afterAll: server.close()');
    server.close();
  });

  describe('server name', () => {
    it('should be valid server name', () => {
      const serverName = 'Restify TypeScript Echo Server';
      expect(server.name).toBe(serverName);
    });
  });

  describe('GET hello', () => {

    it('should be greet', async() => {
      const url = 'http://localhost:8080/hello/';
      const name = 'bathtimefish';
      const res = await fetch(url + name, {method: 'GET'});
      const json = await res.json();
      expect(json).toBe('hello ' + name);
    });

    it('content-type should be text/plain', async() => {
      const url = 'http://localhost:8080/hello/';
      const name = 'bathtimefish';
      const res = await fetch(url + name, {
        method: 'GET',
        headers: {'accept': 'text/plain'}
      });
      const contentType = res.headers.get('content-type');
      expect(contentType).toBe('text/plain');
      const json = await res.text();
      expect(json).toBe('hello ' + name);
    });

  });

  describe('HEAD hello', () => {
    it('connection should be close', async() => {
      const url = 'http://localhost:8080/hello/';
      const name = 'bathtimefish';
      const res = await fetch(url + name, {
        method: 'HEAD',
        headers: {'connection': 'close'}
      });
      const connection = res.headers.get('connection');
      expect(connection).toBe('close');
    });
  });

});

Testを実行する

npm test

> restify-typescript-example@0.1.0 test /Users/user/work/restify-typescript-example
> jest

 PASS  src/__tests__/index.test.ts
  Index
    server name
      ✓ should be valid server name (7ms)
    GET hello
      ✓ should be greet (63ms)
      ✓ content-type should be text/plain (6ms)
    HEAD hello
      ✓ connection should be close (4ms)

  console.log src/index.ts:17
    Restify TypeScript Echo Server listening at http://[::]:8080

  console.log src/__tests__/index.test.ts:9
    afterAll: server.close()

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |      100 |      100 |      100 |      100 |                   |
 index.ts |      100 |      100 |      100 |      100 |                   |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        4.718s
Ran all test suites.

とおった。

開発用にビルドする

Webpackでトランスパイルする。正常に完了すると dist/index.js ができる。

webpack --config webpack.config.dev.js

Hash: e19b805b0d985055c5b0
Version: webpack 4.16.4
Time: 3107ms
Built at: 2018-08-04 16:56:52
   Asset    Size  Chunks             Chunk Names
index.js  11 KiB    main  [emitted]  main
Entrypoint main = index.js
[./src/index.ts] 425 bytes {main} [built]
[restify] external "restify" 42 bytes {main} [built]

リリース用ビルドの場合は webpack --config webpack.config.prod.js を実行する。

開発用サーバを実行する

npm start

> restify-typescript-example@0.1.0 start /Users/user/work/restify-typescript-example
> node dist/index.js

Restify TypeScript Echo Server listening at http://[::]:8080

Quick Start に載ってるようにリクエストしてみる

curl -is http://localhost:8080/hello/bathtimefish -H 'accept: text/plain'

HTTP/1.1 200 OK
Server: Restify TypeScript Echo Server
Content-Type: text/plain
Content-Length: 18
Date: Sat, 04 Aug 2018 08:00:53 GMT
Connection: keep-alive

hello bathtimefish
curl -is http://localhost:8080/hello/bathtimefish

HTTP/1.1 200 OK
Server: Restify TypeScript Echo Server
Content-Type: application/json
Content-Length: 20
Date: Sat, 04 Aug 2018 08:02:10 GMT
Connection: keep-alive

"hello bathtimefish"
curl -is http://localhost:8080/hello/bathtimefish -X HEAD -H 'connection: close'

HTTP/1.1 200 OK
Server: Restify TypeScript Echo Server
Date: Sat, 04 Aug 2018 08:03:15 GMT
Connection: close

できた。

まとめ

全ソースコードはこちら

https://github.com/bathtimefish/restify-typescript-example

いい感じに整った気がする。細かいワークフローの自動化はここから適宜改造していけばいいと思う。

ちなみに、VSCodeでvscode-jest入れてるとテストコード書くごとに自動でTestRunnerが走ってpass/failが可視化されてめっちゃ便利。そろそろ本格的にVSCodeに乗り換えるべきかなと思う。

スクリーンショット 2018-08-04 17.12.31.png

さて、restifyちゃんと学習しないとな。