1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-24

はじめに

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

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

DynamoDB

1.コンソールからDynamoDBにアクセスしてテーブルを作成
- パーテーションキーはid
スクリーンショット 2025-04-23 17.39.21.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
実線通りに実装すると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.tsexportするとパスが/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を追加して以下のようになった場合を考えます。

//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でUserGroupを結合するのではないかなと思います。(特にNoSQLの場合)

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

コンストラクタではclienttableNameを受け取ります。DynamoDBではスネークケースでの保存が推奨されています。ここでは自作関数のtoSnakeCasetoCamelCaseを用いて変換を行っています。このように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をもらいます。こちらでも自作のparseParamsparseQueryparseBodyを使ってリクエストを解析します。それらで得たiditemUsecaseに投げて、結果をsuccessResponseerrorResponseでレスポンスに変換します。

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に変更します。

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が完成しました。

参考記事

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?