概要
ハンズオン Node.jsの7章データストレージを試す。
型をつけて、typescriptで書いてみる。
今回はfile-systemの章。
環境はnodeのexpressをtsで作って無料でazureに公開したメモで作成したものを利用した
環境
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"
}
}
ソース
メインのソース
- ステータスコードは数字でみても分かりにくいので定数にした
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
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
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
types/index.d.ts
export type * from './storage'
export type * from './todo'
export type * from './utils'
export type * from './http'
- ハンズオンに書かれていたStorageの仕様を型にした
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としてまとめた
types/utils.d.ts
export type ValueOf<T> = T[keyof T]
export type OptionalKeys<T> = { [K in keyof T]?: T[K] | null }
- IDはエイリアスを切っただけ
types/common.d.ts
export type ID = string
types/todo.d.ts
import type { ID } from './common'
export interface Todo {
id: ID
title: string
completed: boolean
}
- ErrorにはstatusCodeプロパティがないので自前で定義
- expressの、引数を3つとるMiddlewareの型がなかったのでRequestParamHandlerを参考に定義
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拡張を使用した
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