AWS&TypeScriptでサーバーレスAPIを構築②

## はじめに
TypeScript+Node.js+expressで作成したAPIをAWSのAPIGataway+Lambdaで公開する方法を紹介しています。前回ではプロジェクトの作成をしたのちにCodePipelineを用いてGitへのプッシュ時に自動でデプロイさせるところまで作成しました。

https://qiita.com/kmatsushita1012/items/d164053431301bf49a01

今回はDynamoDBを組み込んでデータの永続化を行なった上で、クリーンアーキテクチャを用いた実装を行っていきます。　参考は以下に。

https://github.com/kmatsushita1012/my-api

## DynamoDB
1.コンソールからDynamoDBにアクセスしてテーブルを作成
    - パーテーションキーは`id`に
    ![スクリーンショット 2025-04-23 17.39.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3994994/cdc6fd9b-d385-4496-a81c-dc543fd99fcb.png)

    
### DynamoDBライブラリのインストール
```
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
```

## コーディング
クリーンアーキテクチャを意識して行います。ざっくり以下のようなレイヤーで分割します。
- Domain層
    - Entity　（ヒト・モノ）
    - Interfaces (*依存関係の逆転*に用いる)
        - Repository
- Application層
    - Usecase (外部ライブラリに依存しないロジック)
- Interface層
    - Controller (リクエストの解析とレスポンスの生成)
    - Router (エンドポイントの設定)
- Infrastructure層
    - Repository (DBとの接続)
Interface層とInterfacesは似ているようで別物です。前者は"入口"的な意味で使っており(UIと同じ)、後者はオブジェクト指向の抽象クラスにあたります。

*依存関係の逆転*について説明します。以下の図は実線が実際の処理の流れ、点線が依存関係を表します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3994994/af710db9-cf9a-436b-a8ff-41953e88394b.png)
実線通りに実装するとApplication層がInfrastructure層に依存しますが、Application層はDomain層にしか依存できません。そこでDomain層にインターフェイスを定義してInfrastructure層ではその実装を行います。Application層が外部ライブラリを用いるときはDomain層のインターフェイスにアクセスする体で行います。実際どうやっているかはこの後のコードをみてもらえれば理解できると思います。

### Domain/Entity
ライブラリには一切依存せずに記述します。(あってもDateとかのみ)
ユーザーを定義します。簡単にidと名前だけにしました。Mock用に`sample`を用意しておくと後々便利です。
```src/domain/entities/users/User.ts
interface User {
  id: string;
  name: string;
}

export default User;

export const sample: User = {
  id: "0001",
  name: "Bob",
};
```
`index.ts`で`export`するとパスが`/domain/entities/users`までで済むので楽です。
```src/domain/entities/users/index.ts
import User, { sample } from "./User";

export { User, sample as userSample };
```
### Domain/Interfaces
TypeScriptでは`abstract class`を使ってインターフェイスを作成します。今回はDBに接続する`Repository`を抽象で定義し、のちに実装を作ります。基本的にDB操作は非同期になると思うので`Promise<T>`を戻り値にします。
```src/domain/interfaces/repositories/IUserRepository.ts
abstract class IUserRepository {
  abstract get(id: string): Promise<User | null>;
  abstract getAll(): Promise<User[]>;
  abstract put(item: User): Promise<string>;
  abstract delete(id: string): Promise<string>;
}

export default IUserRepository;
```

### Application/Usecase
こちらもその他ライブラリには一切依存せずにドメイン層にのみ依存して記述します。Domain/Interfacesで定義した`IUserRepository`をコンストラクタで受け取り、DBに見立ててアクセスします。

#### GET
`GET`はシンプルにかけます。
```application/usecases/user/GetUseCase.ts
export default class GetUsecase {
  constructor(private repository: IUserRepository) {}
  execute = async (id: string): Promise<User> => {
    const user = await this.repository.get(id);
    if (!user) {
      throw Errors.NotFound();
    }
    return user;
  };
}
```

```application/usecases/user/GetAllUseCase.ts
export default class GetAllUsecase {
  constructor(private repository: IUserRepository) {}
  execute = async (): Promise<User[]> => {
    const users = await this.repository.getAll();
    return users;
  };
}
```
Getが複雑になる場合を考えてみましょう。例えば`Group`を追加して以下のようになった場合を考えます。
```ts
//Userのモデル(DBで扱うモデル)
interface User {
    id: string
    name: string
    groupId: string
}
//Groupのモデル(同)
interface Group {
    id: string
    name: string
}
// '/user/:idで返してほしいモデル
interface UserResponse {
    id: string
    name: string
    groupName: string
}
```
このときUsecaseで`User`と`Group`を結合するのではないかなと思います。(特にNoSQLの場合)
```ts
const user = await this.userRepository.get(id);
const group = await this.groupRepository.get(user.groupId);
const response: UserResponse = { ... user, groupName: group.name}
```
#### POST
新規作成なので存在しないことを確認してからDBに保存します。
```application/usecases/user/PostUseCase.ts
export default class PostUsecase {
  constructor(private repository: IUserRepository) {}
  execute = async (item: User): Promise<string> => {
    const exsit = await this.repository.get(item.id);
    if (exsit) {
      throw Errors.Conflict();
    }
    const message = await this.repository.put(item);
    return message;
  };
}
```
#### PUT
idが合致していることと存在していることを確認し、その後上書きします。
```application/usecases/user/PutUseCase.ts
export default class PutUsecase {
  constructor(private repository: IUserRepository) {}
  execute = async (id: string, item: User): Promise<string> => {
    if (id != item.id) {
      throw Errors.BadRequest();
    }
    const exsit = await this.repository.get(id);
    if (!exsit) {
      throw Errors.NotFound();
    }
    const message = await this.repository.put(item);
    return message;
  };
}
```

#### DELETE

存在を確認してから削除します。
```application/usecases/user/DeleteUseCase.ts
export default class GetUsecase {
  constructor(private repository: IUserRepository) {}
  execute = async (id: string): Promise<string> => {
    const exsit = await this.repository.get(id);
    if (!exsit) {
      throw Errors.NotFound();
    }
    const message = await this.repository.delete(id);
    return message;
  };
}
```
### Infrastructure/Repository
ここでRepositoryの実装を行います。今回は本番環境用のDynamoDBとテスト用のMockの2つを作ります。

#### DynamoDB
コンストラクタでは`client`と`tableName`を受け取ります。DynamoDBではスネークケースでの保存が推奨されています。ここでは自作関数の`toSnakeCase`と`toCamelCase`を用いて変換を行っています。このようにDB都合の処理は全てここで吸収することを目指してください。

```src/infrastructure/repositories/DynamoDB/DynamoDBUserRepository.ts
class DynamoDBUserRepository implements IUserRepository {
  constructor(
    private client: DynamoDBDocumentClient,
    private tableName: string
  ) {}

  get = async (id: string): Promise<User | null> => {
    try {
      const result = await this.client.send(
        new GetCommand({
          TableName: this.tableName,
          Key: { id },
        })
      );
      if (!result.Item) return null;
      return toCamelCase<User>(result.Item);
    } catch (error) {
      throw Errors.InternalServerError();
    }
  };

  getAll = async (): Promise<User[]> => {
    try {
      const result = await this.client.send(
        new ScanCommand({
          TableName: this.tableName,
        })
      );

      const items = result.Items ?? [];
      return toCamelCase<[User]>(items);
    } catch (err) {
      console.log(err);
      throw Errors.InternalServerError();
    }
  };

  put = async (item: User): Promise<string> => {
    const snakedItem = toSnakeCase(item);
    try {
      await this.client.send(
        new PutCommand({
          TableName: this.tableName,
          Item: snakedItem,
          ConditionExpression: "attribute_exists(id)",
        })
      );
      return "Success";
    } catch (err) {
      throw Errors.InternalServerError();
    }
  };

  delete = async (id: string): Promise<string> => {
    try {
      await this.client.send(
        new DeleteCommand({
          TableName: this.tableName,
          Key: { id },
        })
      );
      return "Success";
    } catch (err) {
      console.log(err);
      throw Errors.InternalServerError();
    }
  };
}
export default { DynamoDBUserRepository };
```
先ほど説明した自作関数です。
```src/infrastructure/repositories/formatter.ts
export const toCamelCase = <T>(input: Record<string, any> | Array<any>): T => {
  if (Array.isArray(input)) {
    return input.map(toCamelCase) as unknown as T;
  } else if (typeof input === "object" && input !== null) {
    return Object.fromEntries(
      Object.entries(input).map(([key, value]) => [
        key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
        toCamelCase(value),
      ])
    ) as unknown as T;
  }
  return input as unknown as T;
};

export const toSnakeCase = (input: any): Record<string, any> | Array<any> => {
  if (Array.isArray(input)) {
    return input.map(toSnakeCase);
  } else if (typeof input === "object" && input !== null) {
    return Object.fromEntries(
      Object.entries(input).map(([key, value]) => [
        key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`),
        toSnakeCase(value),
      ])
    );
  }
  return input;
};
```

#### Mock
テスト用の模擬DBを作成します。定数を返せばOKです。
```src/infrastructure/repositories/Mock/MockUserRepository.ts
import { IUserRepository } from "../../../domain/interfaces/repositories";
import { User, userSample } from "../../../domain/entities/users";

class MockUserRepository implements IUserRepository {
  constructor() {}

  get = async (id: string): Promise<User | null> => {
    return userSample;
  };

  getAll = async (): Promise<User[]> => {
    return [userSample];
  };

  put = async (item: User): Promise<string> => {
    return "Success";
  };

  delete = async (id: string): Promise<string> => {
    return "Success";
  };
}
export default MockUserRepository;
```

### Interface/Controller
こちらではリクエストの解析とレスポンスの作成を行います。コンストラクタでは外部からUsecaseをもらいます。こちらでも自作の`parseParams`、`parseQuery`、`parseBody`を使ってリクエストを解析します。それらで得た`id`や`item`を`Usecase`に投げて、結果を`successResponse`や`errorResponse`でレスポンスに変換します。
```src/interfaces/controllers/UserController.ts
class UserController {
  constructor(private usecases: UserUsecases) {}

  get = async (req: APIGatewayRequest): Promise<ApiResponse> => {
    try {
      const { id } = parseParams(req, (params) => ({
        id: params.id as string,
      }));
      const result = await this.usecases.get.execute(id);
      return successResponse(result);
    } catch (error) {
      console.log(error);
      return errorResponse(error);
    }
  };

  getAll = async (req: APIGatewayRequest): Promise<ApiResponse> => {
    try {
      const result = await this.usecases.getAll.execute();
      return successResponse(result);
    } catch (error) {
      console.log(error);
      return errorResponse(error);
    }
  };

  post = async (req: APIGatewayRequest): Promise<ApiResponse> => {
    try {
      const item = parseBody<User>(req);
      const result = await this.usecases.post.execute(item);
      return successResponse(result);
    } catch (error) {
      console.log(error);
      throw errorResponse(error);
    }
  };

  put = async (req: APIGatewayRequest): Promise<ApiResponse> => {
    try {
      const { id } = parseParams(req, (params) => ({
        id: params.id as string,
      }));
      const item = parseBody<User>(req);
      const result = await this.usecases.put.execute(id, item);
      return successResponse(result);
    } catch (error) {
      console.log(error);
      throw errorResponse(error);
    }
  };

  delete = async (req: APIGatewayRequest): Promise<ApiResponse> => {
    try {
      const { id } = parseParams(req, (params) => ({
        id: params.id as string,
      }));
      const result = await this.usecases.delete.execute(id);
      return successResponse(result);
    } catch (error) {
      console.log(error);
      throw errorResponse(error);
    }
  };
}
export default UserController;
```
リクエストの解析は以下にまとめました。
```src/interfaces/request.ts
type APIGatewayRequest = Request & { apiGateway?: any };

const parseQuery = <T>(
  value: Request,
  predicate: (input: Record<string, any>) => T
): T => {
  try {
    return predicate(value.query);
  } catch (error) {
    throw Errors.BadRequest(String(error));
  }
};

const parseParams = <T>(
  value: Request,
  predicate: (input: Record<string, any>) => T
): T => {
  try {
    return predicate(value.params);
  } catch (error) {
    throw Errors.BadRequest(String(error));
  }
};

const parseBody = <T>(
  value: Request,
  predicate: (input: any) => T = (input) => {
    return input as T;
  }
): T => {
  try {
    return predicate(value.body);
  } catch (error) {
    throw Errors.BadRequest(String(error));
  }
};

export { APIGatewayRequest, parseQuery, parseParams, parseBody };
```
同様にレスポンス
```src/interfaces/responses.ts
const successResponse = (body: any, status: number = 200): ApiResponse => {
  return {
    statusCode: status,
    body: body,
  };
};

const errorResponse = (error: any): ApiResponse => {
  if (error instanceof APIError) {
    return {
      statusCode: error.statusCode,
      body: { error: error.message },
    };
  } else {
    return {
      statusCode: 500,
      body: { error: String(error) },
    };
  }
};

interface ApiResponse {
  statusCode: number;
  body: any;
}

export { ApiResponse, successResponse, errorResponse };
```

### Interface/Router
ルーティングは`exporess`を用いて行います。
```src/interfaces/router.ts
import express from "express";

const createRouter = (controller: UserController) => {
  const router = express.Router();

  const asyncHandler = (
    action: (req: APIGatewayRequest) => Promise<ApiResponse>
  ) => {
    return async (req: express.Request, res: express.Response) => {
      try {
        const result = await action(req);
        res.status(result.statusCode).json(result.body);
      } catch (error) {
        console.error(error);
        res.status(500).json({ message: "Internal Server Error" });
      }
    };
  };

  router.get("/users/:id", asyncHandler(controller.get));
  router.get("/users", asyncHandler(controller.getAll));
  router.post("/users", asyncHandler(controller.post));
  router.put("/users/:id", asyncHandler(controller.put));
  router.delete("/users/:id", asyncHandler(controller.delete));

  return router;
};

export default createRouter;
```

### index.ts
プロジェクトのルートかつ全ての汚れ役を請け負います。まず前半では依存関係の注入(Dependency Injection)を行います。基本的にindex.tsでのみ初期化(`new`)を行います。仮に実装が変わったりテストのために`Repository`を入れ替えるとき、`index.ts`をいじれば対応できます。後半では`express`を用いてアプリケーションを立ち上げます。

```src/index.ts
import express from "express";
import serverless from "@vendia/serverless-express";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

//依存関係の注入(DI)
const userTableName = "my-api-users";
const dynamoDBClient = new DynamoDBClient({ region: "ap-northeast-1" });
const client = DynamoDBDocumentClient.from(dynamoDBClient);
const userRepository: IUserRepository = new DynamoDBUserRepository(
  client,
  userTableName
);
const usecases = createUsecases(userRepository);
const controllers = new UserController(usecases);
const router = createRouter(controllers);
// 初期化
const app = express();
app.use(express.json());
app.use(router);
app.get("/", (req, res) => {
  res.send("Hello");
});
export const handler = serverless({ app });
```
#### Utils/Errors
各レイヤー共通で用いたエラーです。もっと作り込むなら各レイヤーごとにエラーを作成してハンドリングすべきですが今回は妥協します。
```src/utils/Errors.ts
export class APIError extends Error {
  public statusCode: number;

  constructor(statusCode: number, message?: string) {
    super(message);
    this.statusCode = statusCode;
  }
}

export const Errors = {
  BadRequest: (msg = "Bad Request") => new APIError(400, msg),
  Unauthorized: (msg = "Unauthorized") => new APIError(401, msg),
  Forbidden: (msg = "Forbidden") => new APIError(403, msg),
  NotFound: (msg = "Not Found") => new APIError(404, msg),
  MethodNotAllowed: (msg = "Method Not Allowed") => new APIError(405, msg),
  Conflict: (msg = "Conflict") => new APIError(409, msg),
  InternalServerError: (msg = "Internal Server Error") =>
    new APIError(500, msg),
  NotImplemented: (msg = "Not Implemented") => new APIError(501, msg),
};
```
## 動作確認
以下を実行して動作確認ができます。`POST`では`{id:0001,name:Bob}`を作成して`PUT`では`name:Alice`に変更します。
```bash
curl -X GET "<自分のURL>/users/0001"
curl -X GET "<自分の>/users"
curl -X POST "<自分のURL>/users" \
     -H "Content-Type: application/json" \
     -d '{"id":"0001", "name":"Bob"}'
curl -X PUT "<自分のURL>/users/0001" \
     -H "Content-Type: application/json" \
     -d '{"id":"0001", "name":"Alice"}'
curl -X DELETE "<自分のURL>/users/0001"
```

## まとめ
これで
- DBの読み書きができる
- 自動ビルド&デプロイ
- クリーンアーキテクチャ

という三拍子揃ったAPIが完成しました。


## 参考記事

https://qiita.com/akihiro-moriwaki/items/40547ff36d7744148822

https://www.ctc-g.co.jp/solutions/cloud/column/article/72.html

https://qiita.com/sadnessOjisan/items/ea5590efa3f55ef56edd

https://qiita.com/Michinosuke/items/879cfe47ca1aeae2c084