【Node.js】MySQL と接続して CRUD 機能を実装する

# はじめに
この記事では、Node.js / TypeScript / MySQL を使ってユーザーの CRUD 機能を実装する手順について記載します。

# 開発環境
開発環境は以下の通りです。

- Windows11
- VSCode
- Talend API Tester
- Docker Engine 27.0.3
- Docker Compose 2
- MySQL 9.0.0
- Node.js 20.15.1
- npm 10.8.2
- @types/node 20.14.11
- ts-node 10.9.2
- TypeScript 5.5.3

# 開発環境構築
以下の手順で開発環境を構築します。

https://qiita.com/Yasushi-Mo/items/f0b5b7350db4598e9573

# 実装方針
`src/index.ts` でルーティングを行い、`src/users.ts` で CRUD 処理を行います。
各 CRUD 処理は以下の流れで行います。

1. DB接続
1. CRUD クエリ実行
1. DB接続切断
1. レスポンス作成

# Read
Read 機能を実装します。Read 機能は、全件取得と一件取得の2種類実装します。

## 全件取得
- HTTP リクエストメソッド : `GET`
- エンドポイント : http://localhost:3000/users

`src/users.ts` で全ユーザー取得処理を行います。
他の機能でも利用する型定義とエラー処理も記載します。

```src/users.ts
import { ServerResponse } from "http";
import { createConnection } from "./database";
import { RowDataPacket } from "mysql2";

type User = RowDataPacket & {
  id: string;
  name: string;
  email: string;
};

const handleError = (res: ServerResponse, error: unknown) => {
  console.error("Error: ", error);
  res.writeHead(500, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ message: "Internal Server Error" }));
};

export const getUsers = async (res: ServerResponse) => {
  try {
    const connection = await createConnection();
    const [rows] = await connection.query<User[]>("SELECT * FROM users");
    await connection.end();
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(rows));
  } catch (error) {
    handleError(res, error);
  }
};
```

`src/users.ts` で実装した `getUsers` を `src/index.ts` で呼び出します。HTTP リクエストメソッドの種類で処理を振り分けます。

```src/index.ts
import { createServer, IncomingMessage, ServerResponse } from "http";
import { createConnection } from "./database";
import { getUsers } from "./users";

const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
  const [_, resource] = req.url?.split("/") || [];

  if (resource !== "users") {
    res.writeHead(404, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ massage: "Not Found" }));
    return;
  }

  switch (req.method) {
    case "GET":
      await getUsers(res);
      break;
    default:
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ massage: "Not Found" }));
  }
};

const server = createServer(handleRequest);

const PORT = process.env.PORT || 3000;

server.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
  createConnection();
});

```

動作確認をします。
初期データとしてDBに登録していた2件のユーザー情報が取得できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/d186db1a-3e01-d311-7756-9466836dc055.png)


## 一件取得
- HTTP リクエストメソッド : `GET`
- エンドポイント : http://localhost:3000/users/[:id]

一件取得の場合は、指定された ID のユーザーの有無による処理の分岐も行います。

```src/users.ts
...

export const getUserById = async (id: number, res: ServerResponse) => {
  try {
    const connection = await createConnection();
    const [rows] = await connection.query<User[]>(
      "SELECT * FROM users WHERE id = ?",
      [id]
    );
    connection.end();
    const user = rows[0];

    if (!user) {
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ message: "User not found" }));
      return;
    }

    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(user));
  } catch (error) {
    handleError(res, error);
  }
};

```

一件取得も `GET` を利用するので、リクエストに ID が含まれるかどうかで呼び出す関数を選択します。

```src/index.ts
...
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
  const [_, resource, resourceId] = req.url?.split("/") || [];

  if (resource !== "users") {
    res.writeHead(404, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ massage: "Not Found" }));
    return;
  }

  const DECIMAL_RADIX = 10; // Named constant for the radix
  const id = resourceId ? parseInt(resourceId, DECIMAL_RADIX) : null;

  switch (req.method) {
    case "GET":
      id ? await getUserById(id, res) : await getUsers(res);
      break;
    default:
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ massage: "Not Found" }));
  }
};
...
```

動作確認をします。
ID が 1 のユーザー情報が取得できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/6144e555-6504-fe4a-1e19-43221b626ea0.png)

存在しないユーザー ID を指定すると、404 エラーになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/463b5e6c-e564-94e8-7801-0dada3cc6150.png)

# Create
- HTTP リクエストメソッド : `POST`
- エンドポイント : http://localhost:3000/users
- リクエストボディ : 
```
{
  "name": string;
  "email": string;
}
```

リクエストボディから `"name"` と `email` を取り出す関数を追加します。この関数を使って取得した `"name"` と `email` をユーザーを作成するクエリ実行時に引数として渡します。

```src/users.ts
...
const parseBody = (req: IncomingMessage): Promise<Omit<User, "id">> => {
  return new Promise((resolve, reject) => {
    let body = "";
    req.on("data", (chunk) => {
      // Convert the chunk to a string and append it to the body
      body += chunk.toString();
    });
    req.on("end", () => {
      try {
        // Parse the accumulated body as JSON
        const parseBody: Omit<User, "id"> = JSON.parse(body);
        resolve(parseBody);
      } catch (error) {
        // Reject the promise if parsing fails
        reject(error);
      }
    });
  });
};

...

export const createUser = async (req: IncomingMessage, res: ServerResponse) => {
  try {
    const user: Omit<User, "id"> = await parseBody(req);
    const connection = await createConnection();
    await connection.query("INSERT INTO users (name, email) VALUES (?, ?)", [
      user.name,
      user.email,
    ]);
    connection.end();
    res.writeHead(201, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ message: "User created" }));
  } catch (error) {
    handleError(res, error);
  }
};

```

HTTP リクエストメソッドが `POST` の時、`createUser` を実行します。

```src/index.ts
...
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
  ...
  switch (req.method) {
    case "GET":
      id ? await getUserById(id, res) : await getUsers(res);
      break;
    case "POST":
      await createUser(req, res);
      break;
    default:
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ massage: "Not Found" }));
  }
};
...
```

動作確認をします。
成功すると、`201` と `"User created"` が返ってきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/f3bc1b29-2bce-0dd1-b89f-9e21d5a1ed60.png)

また、全ユーザー取得をすると、先ほど作成したユーザーも含まれていることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/4f6d6806-8f8f-0a02-cacf-f0fbb5547496.png)

# Update
- HTTP リクエストメソッド : `PUT`
- エンドポイント : http://localhost:3000/users/[:id]
- リクエストボディ : 
```
{
  "name"?: string;
  "email"?: string;
}
```

更新クエリを実行する前に更新対象ユーザーが DB に存在するか確認します。存在しない場合、404 を返します。
存在する場合、Create 時と同じくリクエストボディから `"name"` と `email` を取り出し、クエリ実行時に引数として渡します。

```src/users.ts
...
export const updateUser = async (
  id: number,
  req: IncomingMessage,
  res: ServerResponse
) => {
  try {
    const connection = await createConnection();

    const [targetUsers] = await connection.query<User[]>(
      "SELECT * FROM users WHERE id = ?",
      [id]
    );
    const targetUser = targetUsers[0];

    if (!targetUser) {
      connection.end();
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ message: "User not found" }));
      return;
    }

    const user: Omit<User, "id"> = await parseBody(req);
    await connection.query(
      "UPDATE users SET name = ?, email = ? WHERE id = ?",
      [user.name, user.email, id]
    );
    connection.end();

    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ message: "User updated" }));
  } catch (error) {
    handleError(res, error);
  }
};
```

HTTP リクエストメソッドが `POST` の時、`updateUser` を実行します。

```src/index.ts
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
  ...
  switch (req.method) {
    case "GET":
      id ? await getUserById(id, res) : await getUsers(res);
      break;
    case "POST":
      await createUser(req, res);
      break;
    case "PUT":
      id && (await updateUser(id, req, res));
      break;
    default:
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ massage: "Not Found" }));
  }
};
```

動作確認をします。
成功すると、`200` と `"User updated"` が返ってきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/6a17d13b-7d89-0fdb-155c-69cef819cf3b.png)

対象ユーザーの情報を取得すると、"name" と "email" が更新されていることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/7b1203de-b306-1291-b5c1-c09c8e03e4d5.png)

なお、存在しないユーザーを更新しようとすると、`404` エラーになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/c4532069-48bb-d591-43fa-7e7b282b3604.png)

# Delete
- HTTP リクエストメソッド : `DELETE`
- エンドポイント : http://localhost:3000/users/[:id]

`updateUser` と同じくクエリを実行する前に削除対象ユーザーが DB に存在するか確認します。存在しない場合、404 を返します。
存在する場合、クエリを実行します。

```src/index.ts
export const deleteUser = async (id: number, res: ServerResponse) => {
  try {
    const connection = await createConnection();

    const [targetUsers] = await connection.query<User[]>(
      "SELECT * FROM users WHERE id = ?",
      [id]
    );
    const targetUser = targetUsers[0];

    if (!targetUser) {
      connection.end();
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ message: "User not found" }));
      return;
    }

    await connection.query("DELETE FROM users WHERE id = ?", [id]);
    connection.end();

    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ message: "User deleted" }));
  } catch (error) {
    handleError(res, error);
  }
};
```

HTTP リクエストメソッドが `DELETE` の時、`deleteUser` を実行します。

```src/index.ts
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
  ...
  switch (req.method) {
    case "GET":
      id ? await getUserById(id, res) : await getUsers(res);
      break;
    case "POST":
      await createUser(req, res);
      break;
    case "PUT":
      id && (await updateUser(id, req, res));
      break;
    case "DELETE":
      id && (await deleteUser(id, res));
      break;
    default:
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ massage: "Not Found" }));
  }
};
...
```

動作確認をします。
成功すると、`200` と `"User deleted"` が返ってきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/77424aae-38e6-6c98-7943-a0dcb0196255.png)

削除したユーザーの情報を取得しようとすると、`404` エラーになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/6cbb5057-80b7-5aff-ac50-7f99b216e7a8.png)

なお、存在しないユーザーを削除しようとした場合も `404` エラーになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/e4f327ef-2308-1848-bdb3-032d51e8c986.png)

# 参考
- [SELECT](https://sidorares.github.io/node-mysql2/docs/examples/queries/simple-queries/select)

# 関連
https://github.com/yasushi-mo/docker-samples/tree/main/node-mysql-crud

https://qiita.com/Yasushi-Mo/items/f0b5b7350db4598e9573

https://qiita.com/Yasushi-Mo/items/3f6af80b871e2ff22568