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

# はじめに

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

# セットアップ

[Get started Express + TypeScript する](https://qiita.com/IgnorantCoder/items/c9b79dbab8c1a34b769f) が大変参考になった。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](https://github.com/progre/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"
  }
}
```

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

[jestで"SecurityError: localStorage is not available for opaque origins"と言われてテストがコケる](https://qiita.com/abetomo/items/b78af98d4857f55ec22b)

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

`src/index.ts` を以下のようにする。[Quick Start](http://restify.com/docs/home/)のecho serverをTSで書いただけ。

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

const respond: restify.RequestHandlerType = (req, res, 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](http://restify.com/docs/home/) に載ってるようにリクエストしてみる

```
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](https://github.com/jest-community/vscode-jest)入れてるとテストコード書くごとに自動でTestRunnerが走ってpass/failが可視化されてめっちゃ便利。そろそろ本格的にVSCodeに乗り換えるべきかなと思う。

![スクリーンショット 2018-08-04 17.12.31.png](https://qiita-image-store.s3.amazonaws.com/0/61995/10de7546-23da-ecff-f89b-9b56e0048577.png)

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