ハンズオン Node.jsの7章データストレージ(file-system)をTypescriptで試したメモ

## 概要

[ハンズオン Node.js][*1]の7章データストレージを試す。
型をつけて、typescriptで書いてみる。
今回はfile-systemの章。
環境は[nodeのexpressをtsで作って無料でazureに公開したメモ](https://qiita.com/hibohiboo/items/844cef3899c4fa5c4fa1)で作成したものを利用した

[ソース](https://github.com/hibohiboo/develop/tree/b91e7ae5e0dbb1f3aaa1fe8a59649045cc67ddc6/tutorial/lesson/azure/node-app)


## 環境

```json:package.json
{
  "name": "node-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node ./bin/www",
    "file-system": "node ./bin/www",
    "tsc": "tsc",
    "watch": "tsc --watch"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "engines": {
    "node": "14.x"
  },
  "dependencies": {
    "express": "^4.17.1",
    "uuid": "^8.3.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.9",
    "@types/uuid": "^8.3.0",
    "@typescript-eslint/eslint-plugin": "^4.8.2",
    "@typescript-eslint/parser": "^4.8.2",
    "eslint": "^7.14.0",
    "eslint-config-prettier": "^6.15.0",
    "eslint-plugin-prettier": "^3.1.4",
    "isomorphic-fetch": "^3.0.0",
    "prettier": "^2.2.0",
    "typescript": "^4.1.2"
  }
}
```


## ソース

### メインのソース
* ステータスコードは数字でみても分かりにくいので定数にした

```ts:app.ts
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { statusCode, paths } from './constants'
import type { Todo, DataStorage, HttpError, MiddlewareHandler } from './types'

// const dataStorage: DataStorage<Todo> = require(`./${process.env.npm_lifecycle_event}`)
//   .default
const dataStorage: DataStorage<Todo> = require('./file-system').default

const app = express()
app.use(express.json())

// ToDo一覧の取得
app.get(paths.todos, (req, res, next) => {
  if (!req.query.completed) {
    return dataStorage.fetchAll().then((todos) => res.json(todos), next)
  }
  const completed = req.query.completed === 'true'
  dataStorage.fetchByCompleted(completed).then((todos) => res.json(todos), next)
})

// ToDoの新規登録
app.post(paths.todos, (req, res, next) => {
  const { title } = req.body
  if (typeof title !== 'string' || !title) {
    const err: HttpError = new Error('title is required')
    err.statusCode = statusCode.BadRequest
    return next(err)
  }
  const todo = { id: uuidv4(), title, completed: false }
  dataStorage
    .create(todo)
    .then(() => res.status(statusCode.Created).json(todo), next)
})

// Completedの設定、解除の共通処理
function completedHandler(completed: boolean): MiddlewareHandler {
  return (req, res, next) =>
    dataStorage.update(req.params.id, { completed }).then((todo) => {
      if (todo) {
        return res.json(todo)
      }
      const err: HttpError = new Error('Todo not found')
      err.statusCode = statusCode.NotFound
      next(err)
    }, next)
}

// ToDoのCompoetedの設定、解除
app
  .route(`${paths.todos}/:id/completed`)
  .put(completedHandler(true))
  .delete(completedHandler(false))

// Todoの削除
app.delete(`${paths.todos}/:id`, (req, res, next) =>
  dataStorage.remove(req.params.id).then((id) => {
    if (id !== null) {
      return res.status(statusCode.NoContent).end()
    }
    const err: HttpError = new Error('Todo not found')
    err.statusCode = statusCode.NotFound
    next(err)
  }, next),
)
export default app
```

```ts:file-system/index.ts
import { extname } from 'path'
import { readdir, readFile, writeFile, unlink } from 'fs/promises'
import type { Todo, DataStorage } from '../types'

const exportsObj: DataStorage<Todo> = {
  fetchAll: async () => {
    const files = (await readdir(__dirname)).filter(
      (file) => extname(file) === '.json',
    )
    return Promise.all(
      files.map((file) =>
        readFile(`${__dirname}/${file}`, 'utf8').then(JSON.parse),
      ),
    )
  },
  fetchByCompleted: (completed) =>
    exportsObj
      .fetchAll()
      .then((all) => all.filter((todo) => todo.completed === completed)),
  create: (todo) =>
    writeFile(`${__dirname}/${todo.id}.json`, JSON.stringify(todo)),
  update: async (id, update) => {
    const filename = `${__dirname}/${id}.json`
    return readFile(filename, 'utf8').then((content) => {
      const todo = { ...JSON.parse(content), ...update }
      return writeFile(filename, JSON.stringify(todo)).then(() => todo)
    })
  },
  remove: (id) =>
    unlink(`${__dirname}/${id}.json`).then(
      () => id,
      (err) => (err.code === 'ENOENT' ? null : Promise.reject(err)),
    ),
}
export default exportsObj
```

```ts:constants.ts
import type { ValueOf } from './types'
export const statusCode = {
  Created: 201,
  NoContent: 204,
  BadRequest: 400,
  NotFound: 404,
} as const
export type StatusCode = ValueOf<typeof statusCode>

export const paths = { todos: '/api/todos' }
```

### 型定義

* 各ファイルに分かれた定義をまとめてexport

```ts:types/index.d.ts
export type * from './storage'
export type * from './todo'
export type * from './utils'
export type * from './http'
```

* ハンズオンに書かれていたStorageの仕様を型にした

```ts:types/storage.d.ts
import type { ID } from './common'
import type { OptionalKeys } from './utils'
export interface DataStorage<T> {
  fetchAll: () => Promise<T[]>
  fetchByCompleted: (completed: boolean) => Promise<T[]>
  create: (todo: T) => Promise<void>
  update: (id: ID, update: OptionalKeys<T>) => Promise<T | null>
  remove: (id: ID) => Promise<ID | null>
}
```

* 型を定義するための汎用的な型をutilsとしてまとめた

```ts:types/utils.d.ts
export type ValueOf<T> = T[keyof T]
export type OptionalKeys<T> = { [K in keyof T]?: T[K] | null }
```

* IDはエイリアスを切っただけ

```ts:types/common.d.ts
export type ID = string
```

```ts:types/todo.d.ts
import type { ID } from './common'
export interface Todo {
  id: ID
  title: string
  completed: boolean
}
```

* ErrorにはstatusCodeプロパティがないので自前で定義
* expressの、引数を3つとるMiddlewareの型がなかったのでRequestParamHandlerを参考に定義

```ts:http.d.ts
import type { Request, Response, NextFunction } from 'express'
import type { StatusCode } from '../constants'
export interface HttpError extends Error {
  statusCode?: StatusCode
}
export type MiddlewareHandler = (
  req: Request,
  res: Response,
  next: NextFunction,
) => void
```

## テスト

* テストはVsCodeのREST Client拡張を使用した

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/ba334fc1-80cb-8886-50c4-2adea3af1f63.png)

```http:tools/connectionTest/todos.azure.http
GET https://az-node-app.azurewebsites.net/api/todos HTTP/1.1

###
POST https://az-node-app.azurewebsites.net/api/todos HTTP/1.1
content-type: application/json

{"title": "テスト"}

###
# タイトルがないと400エラー
POST https://az-node-app.azurewebsites.net/api/todos HTTP/1.1
content-type: application/json

{}

###
# 一つ目の要素をcompletedにする
PUT https://az-node-app.azurewebsites.net/api/todos/29010728-d64e-4db2-b49e-d7c2daf09a9a/completed HTTP/1.1

###
# 一つ目の要素のcompletedを解除
DELETE https://az-node-app.azurewebsites.net/api/todos/29010728-d64e-4db2-b49e-d7c2daf09a9a/completed HTTP/1.1

###
# 一つ目の要素を削除
DELETE https://az-node-app.azurewebsites.net/api/todos/29010728-d64e-4db2-b49e-d7c2daf09a9a HTTP/1.1

```



## 参考

[ハンズオン Node.js][*1]

[*1]:https://www.oreilly.co.jp/books/9784873119236/