├── .eslintrc.js ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── add-issue-to-project.yml │ ├── cicd.yml │ ├── dependabot-auto-merge.yml │ └── manual-deploy-to-development.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── cspell.json ├── docs ├── system-design │ ├── README.md │ ├── rest-api.yml │ └── system-diagram.drawio.svg └── user-guide │ ├── README.md │ ├── package-manager.md │ └── pull-request-template.md ├── package-lock.json ├── package.json ├── packages ├── e2e │ ├── environment.ts │ ├── globalSetup.ts │ ├── package.json │ ├── rest-api │ │ ├── main.test.ts │ │ ├── test-cases │ │ │ ├── companies-get.ts │ │ │ ├── companies-id-delete.ts │ │ │ ├── companies-id-get.ts │ │ │ ├── companies-options.ts │ │ │ └── companies-post.ts │ │ └── vitest.config.ts │ ├── tsconfig.json │ ├── utils │ │ ├── cognito-helper.ts │ │ ├── companies-table-helper.ts │ │ ├── datetime.ts │ │ ├── rest-api-endpoint-helper.ts │ │ └── uuid.ts │ └── vitest.shared.ts ├── iac │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── bin │ │ ├── iac.ts │ │ └── parameter.ts │ ├── cdk.json │ ├── lib │ │ ├── constructs │ │ │ ├── alert-notification.ts │ │ │ ├── api.ts │ │ │ ├── cognito.ts │ │ │ ├── dynamodb.ts │ │ │ ├── github-actions-oidc.ts │ │ │ ├── monitoring │ │ │ │ ├── README.md │ │ │ │ ├── api-gateway-metrics.ts │ │ │ │ ├── dynamodb-metrics.ts │ │ │ │ ├── lambda-application-log.ts │ │ │ │ └── shared │ │ │ │ │ └── utils.ts │ │ │ ├── waf.ts │ │ │ └── web-acl-rules │ │ │ │ ├── rest-api.ts │ │ │ │ └── user-pool.ts │ │ └── main-stack.ts │ ├── package.json │ ├── test │ │ ├── __snapshots__ │ │ │ └── main.test.ts.snap │ │ ├── main.test.ts │ │ ├── plugins │ │ │ └── ignore-asset-hash.ts │ │ └── stack-test.ts │ ├── tsconfig.json │ └── vitest.config.ts └── server │ ├── README.md │ ├── package.json │ ├── src │ ├── lambda │ │ ├── domains │ │ │ ├── errors │ │ │ │ └── company-service.ts │ │ │ └── services │ │ │ │ ├── create-company.test.ts │ │ │ │ ├── create-company.ts │ │ │ │ ├── delete-company.test.ts │ │ │ │ ├── delete-company.ts │ │ │ │ ├── get-company.test.ts │ │ │ │ ├── get-company.ts │ │ │ │ ├── query-companies.test.ts │ │ │ │ ├── query-companies.ts │ │ │ │ ├── scan-companies.test.ts │ │ │ │ ├── scan-companies.ts │ │ │ │ └── schemas.ts │ │ ├── handlers │ │ │ └── api-gateway │ │ │ │ └── rest-api │ │ │ │ ├── companies-get.test.ts │ │ │ │ ├── companies-get.ts │ │ │ │ ├── companies-id-delete.test.ts │ │ │ │ ├── companies-id-delete.ts │ │ │ │ ├── companies-id-get.test.ts │ │ │ │ ├── companies-id-get.ts │ │ │ │ ├── companies-post.test.ts │ │ │ │ ├── companies-post.ts │ │ │ │ └── router.ts │ │ └── infrastructures │ │ │ ├── dynamodb │ │ │ ├── client.ts │ │ │ ├── companies-table.test.ts │ │ │ └── companies-table.ts │ │ │ └── errors │ │ │ └── companies-table.ts │ └── utils │ │ ├── datetime.ts │ │ ├── http-response.ts │ │ ├── logger.ts │ │ └── uuid.ts │ ├── tsconfig.json │ └── vitest.config.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@classmethod"], 3 | parserOptions: { 4 | project: true, 5 | tsconfigRootDir: __dirname, 6 | sourceType: "module", 7 | ecmaVersion: 2015, 8 | }, 9 | ignorePatterns: ["**/*.js"], 10 | parser: "@typescript-eslint/parser", 11 | }; 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | # MEMO: daily だとアクティブに開発をしていない期間に PR の作成頻度が過剰となるため、weekly を指定。 7 | interval: weekly 8 | groups: 9 | minor-and-patch: 10 | patterns: 11 | - "*" 12 | update-types: 13 | - minor 14 | - patch 15 | ## 最新版にバグが含まれるなどアップデートさせたくないパッケージは ignore で指定 16 | # ignore: 17 | # - dependency-name: "dependency-name" 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 機能概要 2 | 3 | 〇〇システムにおいて認証機能を実現する為に〇〇を実装します。 4 | 5 | ## 変更点 6 | 7 | - [ ] 変更点 A 8 | - [ ] 変更点 B 9 | 10 | ## 対象外 11 | 12 | - 対象外 A 13 | - 対象外 B 14 | 15 | ## アウトプット 16 | 17 | (ScreenShot or JSON image) 18 | 19 | ## 手動テスト内容 20 | 21 | - 単体テストがパスすることを確認 22 | - E2E テストがパスすることを確認 23 | 24 | ## 影響範囲 25 | 26 | - 新規機能開発の為影響無し 27 | 28 | ## 関連課題 29 | 30 | - 関連課題 URL 31 | 32 | ## その他 33 | 34 | - 特記事項を記載 35 | -------------------------------------------------------------------------------- /.github/workflows/add-issue-to-project.yml: -------------------------------------------------------------------------------- 1 | # @see https://peno022.hatenablog.com/entry/add-issues-to-github-project 2 | name: Add issue to project 3 | 4 | on: 5 | issues: 6 | types: 7 | - opened 8 | 9 | jobs: 10 | add-to-project: 11 | name: Add issue to project 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/add-to-project@v0.5.0 15 | with: 16 | project-url: https://github.com/orgs/cm-cxlabs/projects/2 17 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 18 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - closed 10 | 11 | env: 12 | TARGET_ENV: ${{ github.base_ref == 'main' && 'prd' || github.base_ref == 'staging' && 'stg' || 'dev' }} 13 | DEV_AWS_ACCOUNT_ID: ${{ vars.DEV_AWS_ACCOUNT_ID }} 14 | STG_AWS_ACCOUNT_ID: ${{ vars.STG_AWS_ACCOUNT_ID }} 15 | PRD_AWS_ACCOUNT_ID: ${{ vars.PRD_AWS_ACCOUNT_ID }} 16 | 17 | jobs: 18 | Integration: 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 10 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node.js 20 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: npm 30 | 31 | - name: Cache Dependency 32 | uses: actions/cache@v4 33 | id: cache_dependency 34 | env: 35 | cache-name: cache-dependency 36 | with: 37 | path: "**/node_modules" 38 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }} 39 | 40 | - name: Install Dependency 41 | if: ${{ steps.cache_dependency.outputs.cache-hit != 'true' }} 42 | run: npm ci --no-audit --progress=false --silent 43 | 44 | - name: Check Format 45 | run: | 46 | npm run check:format 47 | 48 | - name: Check Lint 49 | run: | 50 | npm run check:lint 51 | 52 | - name: Check Type 53 | run: npm run check:type 54 | 55 | - name: Check Cspell 56 | run: npm run check:cspell 57 | 58 | - name: Cdk Snapshot Test 59 | run: npm run test-snapshot -- run 60 | 61 | - name: Unit Test 62 | run: npm run test-unit -- run 63 | 64 | # TODO: CD を Environments を使った実装に置き換え予定 65 | # @see https://github.com/classmethod-internal/icasu-cdk-serverless-api-sample/issues/342 66 | 67 | Deploy: 68 | runs-on: ubuntu-latest 69 | timeout-minutes: 30 70 | if: github.event.pull_request.merged == true 71 | needs: Integration 72 | permissions: 73 | id-token: write 74 | contents: read 75 | steps: 76 | - name: Checkout 77 | uses: actions/checkout@v4 78 | 79 | - name: Setup Node.js 20 80 | uses: actions/setup-node@v4 81 | with: 82 | node-version: 20 83 | cache: npm 84 | 85 | - name: Restore Cache Dependency 86 | uses: actions/cache/restore@v4 87 | id: cache_dependency 88 | env: 89 | cache-name: cache-dependency 90 | with: 91 | path: "**/node_modules" 92 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }} 93 | 94 | - name: Assume Role 95 | uses: aws-actions/configure-aws-credentials@v4 96 | with: 97 | aws-region: "ap-northeast-1" 98 | role-to-assume: ${{ env.TARGET_ENV == 'prd' && vars.PRD_AWS_OIDC_ROLE_ARN || env.TARGET_ENV == 'stg' && vars.STG_AWS_OIDC_ROLE_ARN || vars.DEV_AWS_OIDC_ROLE_ARN }} 99 | 100 | - name: Deploy 101 | run: | 102 | npm run deploy:${{ env.TARGET_ENV }} 103 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | # @see https://docs.github.com/ja/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#enable-auto-merge-on-a-pull-request 2 | name: Dependabot auto-merge 3 | # ブランチ保護が無効の場合は CI Fail 時も自動マージが行われてしまう。 4 | # TODO: ブランチ保護有効後にトリガーを pull_request に戻す。 5 | # on: pull_request 6 | on: workflow_dispatch 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | dependabot: 14 | runs-on: ubuntu-latest 15 | if: ${{ github.actor == 'dependabot[bot]' }} 16 | steps: 17 | - name: Dependabot metadata 18 | id: metadata 19 | uses: dependabot/fetch-metadata@v1 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | - name: Enable auto-merge for Dependabot PRs 23 | # minor または patch バージョンのアップデートのみ自動マージ対象とする 24 | if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} 25 | run: gh pr merge --auto --merge "$PR_URL" 26 | env: 27 | # PR_URL: ${{github.event.pull_request.html_url}} # バリデーションエラー抑制のためコメントアウト。ブランチ保護有効後にコメントインする。 28 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | -------------------------------------------------------------------------------- /.github/workflows/manual-deploy-to-development.yml: -------------------------------------------------------------------------------- 1 | # 指定のブランチを開発環境に手動デプロイするワークフロー 2 | # 3 | # TODO: 4 | # - Reusable workflow を使用して cicd.yml と処理を共通化する 5 | # @see https://docs.github.com/en/actions/using-workflows/reusing-workflows 6 | 7 | name: Manual Deploy to Development 8 | 9 | on: 10 | workflow_dispatch: 11 | inputs: 12 | branch: 13 | description: "Branch name" 14 | required: true 15 | default: develop 16 | 17 | jobs: 18 | Deploy: 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 30 21 | permissions: 22 | id-token: write 23 | contents: read 24 | env: 25 | TARGET_ENV: dev 26 | DEV_AWS_ACCOUNT_ID: ${{ vars.DEV_AWS_ACCOUNT_ID }} 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | ref: ${{ github.event.inputs.branch }} 32 | 33 | - name: Setup Node.js 20 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | cache: npm 38 | 39 | - name: Cache Dependency 40 | uses: actions/cache@v4 41 | id: cache_dependency 42 | env: 43 | cache-name: cache-dependency 44 | with: 45 | path: "**/node_modules" 46 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }} 47 | 48 | - name: Assume Role 49 | uses: aws-actions/configure-aws-credentials@v4 50 | with: 51 | aws-region: "ap-northeast-1" 52 | role-to-assume: ${{ vars.DEV_AWS_OIDC_ROLE_ARN }} 53 | 54 | - name: Deploy 55 | run: | 56 | npm run deploy:${{ env.TARGET_ENV }} 57 | 58 | # TODO: E2E テスト実行処理の追加 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | .eslintcache 4 | tsconfig.tsbuildinfo 5 | .cspellcache 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "redhat.vscode-yaml", 6 | "Arjun.swagger-viewer" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "editor.formatOnSave": true 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ICASU CDK Serverless API Sample 2 | 3 | 本サンプルはサーバレスアーキテクチャでシステムを開発する上で以下の用途で活用可能です。 4 | 5 | - アプリケーション実装方法 6 | - IaC(AWS CDK) 7 | - CI/CD 8 | - 監視 9 | 10 | 詳細は[ユーザーガイド](docs/user-guide/README.md)をご覧ください。 11 | 12 | また ICASU の詳細は[こちら]をご覧ください。TODO: リンク先として ICASU の概要ページを作成予定 13 | 14 | ## システム構成図 15 | 16 | ![](docs/system-design/system-diagram.drawio.svg) 17 | 18 | ## 技術スタック 19 | 20 | このサンプルでは以下の技術スタックを使用しています。 21 | 22 | | 機能 | 技術スタック | 補足 | 23 | | --------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | 24 | | 言語 | TypeScript | | 25 | | ランタイム | Node.js | | 26 | | IaC | AWS CDK | | 27 | | テストフレームワーク | [Vitest](https://vitest.dev/) | | 28 | | デザインパターン | レイヤードアーキテクチャ + Humble Object パターン | | 29 | | データベース | Amazon DynamoDB | | 30 | | コンピューティング | [Serverless Express](https://github.com/CodeGenieApp/serverless-express) を使用したモノリシック Lambda | | 31 | | API | Amazon API Gateway REST API | | 32 | | 認証、ユーザーディレクトリ | Amazon Cognito user pools | | 33 | | パッケージマネージャー | [npm](https://www.npmjs.com/) | | 34 | | モノレポ管理ツール | [npm workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces) | | 35 | | リンター/フォーマッター | [eslint-config-classmethod](https://github.com/cm-cxlabs/eslint-config-classmethod) | | 36 | | CI/CD | GitHub Actions | [.github/workflows/cicd.yml](.github/workflows/cicd.yml) にて実装。PR のプッシュ時に CI実行、マージ時に CD 実行 | 37 | | アプリケーションログ監視/メトリクス監視 | | [こちら](packages/iac/lib/constructs/monitoring/README.md) にて詳細を記載 | 38 | 39 | ## システム設計書 40 | 41 | 本サンプルのシステム設計書は[こちら](docs/system-design/README.md)を参照してください。以下のドキュメントが含まれます。 42 | 43 | - シーケンス図 44 | - API 仕様書 45 | - テーブル仕様書 46 | - バケット仕様書 47 | - パラメーター仕様書 48 | 49 | ## Getting Started 50 | 51 | ### Shared Service Sample のデプロイ 52 | 53 | [Shared Service Sample](https://github.com/cm-cxlabs/shared-service-sample) の実装をデプロイして、次の機能を作成しておきます。 54 | 55 | - OIDC プロバイダー 56 | - アラート通知機能 57 | - AWS リソース横断のメトリクス監視機能 58 | 59 | ### 推奨の VS Code 拡張のインストール 60 | 61 | [.vscode/extensions.json](.vscode/extensions.json) に記載されている推奨の VS Code 拡張をインストールします。 62 | 63 | ```shell 64 | npm run install:recommended-vscode-extensions 65 | ``` 66 | 67 | ### 依存関係インストール 68 | 69 | すべてのワークスペースに対して依存関係をインストールします。 70 | 71 | ```shell 72 | npm ci 73 | ``` 74 | 75 | ### 単体テスト 76 | 77 | Lambda 関数のハンドラーで使用するモジュールの単体テストを実行します。 78 | 79 | ```shell 80 | npm run test-unit 81 | ``` 82 | 83 | ### パラメーターの指定 84 | 85 | CDK アプリのコンテキストとして使用するパラメーターを、次のファイルに指定します。 86 | 87 | - ファイル:[packages/iac/bin/parameter.ts](packages/iac/bin/parameter.ts) 88 | 89 | ### CDK スナップショットテスト 90 | 91 | 各環境に対するスナップショットテストを実行し、合成された CloudFormation テンプレートに差分が無いことを確認します。 92 | 93 | ```shell 94 | npm run test-snapshot 95 | ``` 96 | 97 | 実装変更により差分が発生する場合は、スナップショットを更新します。 98 | 99 | ```shell 100 | npm run test-snapshot:update 101 | ``` 102 | 103 | ### デプロイ 104 | 105 | #### 開発者のローカル環境からデプロイ 106 | 107 | デプロイ先の環境に対応する AWS アカウント ID を環境変数に指定します。 108 | 109 | ```shell 110 | # 開発環境 111 | export DEV_AWS_ACCOUNT_ID= 112 | 113 | # ステージング環境 114 | export STG_AWS_ACCOUNT_ID= 115 | 116 | # 本番環境 117 | export PRD_AWS_ACCOUNT_ID= 118 | ``` 119 | 120 | AWS CDK アプリ(開発環境)をデプロイします。 121 | 122 | ```shell 123 | npm run deploy:dev 124 | ``` 125 | 126 | デプロイ時に CloudFormation 変更セットの使用をスキップして、スタックデプロイを高速化する場合は、次のコマンドを使用します。 127 | 128 | ```shell 129 | npm run deploy:dev:direct 130 | ``` 131 | 132 | - 参考:[[AWS CDK] `cdk deploy` で変更セットの使用をスキップして、スタックデプロイを高速化してみた | DevelopersIO](https://dev.classmethod.jp/articles/cdk-deploy-directly-without-using-changeset/) 133 | 134 | #### GitHub Actions からデプロイ 135 | 136 | GitHub Actions の [Manual Deploy to Development ワークフロー](.github/workflows/manual-deploy-to-development.yml)を使用して、任意のブランチを開発環境に手動デプロイすることも可能です。 137 | 138 | ### E2E テスト 139 | 140 | #### API 141 | 142 | REST API の E2E テスト(開発環境)を実施します。 143 | 144 | ```shell 145 | npm run test-e2e-api:dev 146 | ``` 147 | 148 | ### GitHub Actions Variables の指定 149 | 150 | GitHub Actions Variables に、CI/CD ワークフロー で使用する AWS アカウントごとの OIDC 用 IAM Role Arn を指定します。 151 | 152 | | Variable Name | 説明 | 例 | 153 | | --------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- | 154 | | DEV_AWS_OIDC_ROLE_ARN | 開発環境用 AWS アカウントの OIDC 用 Role Arn | `arn:aws:iam::XXXXXXXXXXXX:role/dev-icasu-cdk-serverless--GitHubActionsOidcGitHubAc-Dpxxxxxxx` | 155 | | STG_AWS_OIDC_ROLE_ARN | ステージング環境用 AWS アカウントの OIDC 用 Role Arn | `arn:aws:iam::XXXXXXXXXXXX:role/stg-icasu-cdk-serverless--GitHubActionsOidcGitHubAc-Dpxxxxxxx` | 156 | | PRD_AWS_OIDC_ROLE_ARN | 本番環境用 AWS アカウントの OIDC 用 Role Arn | `arn:aws:iam::XXXXXXXXXXXX:role/prd-icasu-cdk-serverless--GitHubActionsOidcGitHubAc-Dpxxxxxxx` | 157 | | DEV_AWS_ACCOUNT_ID | 開発環境用 AWS アカウント ID | `012345678901` | 158 | | STG_AWS_ACCOUNT_ID | ステージング環境用 AWS アカウント ID | `012345678901` | 159 | | PRD_AWS_ACCOUNT_ID | 本番環境用 AWS アカウント ID | `012345678901` | 160 | 161 | T.B.D 組織移行後に [GitHub environments](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) を導入予定 162 | 163 | ## 貢献について 164 | 165 | 現在本リポジトリは、クラスメソッド内部のミラーとして機能しています。 166 | 167 | 今後 PR や Issue 受け入れを検討しており準備している段階です。フィードバックがありましたら、[tmk2154](https://x.com/tmk2154), [ryutawakatsuki](https://x.com/ryutawakatsuki)までお願いします。 168 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "customresources", 4 | "cxlabs", 5 | "Descryption", 6 | "icasu", 7 | "Imdsv", 8 | "Oidc", 9 | "oidc", 10 | "Opensearch", 11 | "opensearchservice", 12 | "powertools", 13 | "stepfunctions", 14 | "unmarshall", 15 | "codegenie", 16 | "datetime", 17 | "Luxon", 18 | "Millis", 19 | "cdate", 20 | "wafv", 21 | "OWASP", 22 | "ACFP", 23 | "classmethod" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /docs/system-design/README.md: -------------------------------------------------------------------------------- 1 | # システム設計書 2 | 3 | このディレクトリでは、本プロジェクトのシステム設計書を管理します。 4 | 5 | | ドキュメント名 | 形式 | 補足 | 6 | | ------------------------------------------- | ------------- | ----- | 7 | | [システム構成図](system-diagram.drawio.svg) | draw.io | | 8 | | シーケンス図 | Mermaid | T.B.D | 9 | | [API 仕様書](rest-api.yml) | OpenAPI 3.0.3 | | 10 | | テーブル仕様書 | Markdown | T.B.D | 11 | | バケット仕様書 | Markdown | T.B.D | 12 | | パラメーター仕様書 | Markdown | T.B.D | 13 | 14 | ## ICASU NOTE 15 | 16 | ### システム構成図 17 | 18 | 本プロジェクトのシステム構成図は、[Draw.io](https://www.diagrams.net/) で作成しています。[VS Code プラグイン](https://marketplace.visualstudio.com/items?itemName=hediet.vscode-drawio)を使うことにより作図をローカルで行うことができるようになります。 19 | 20 | - [Draw.io(diagrams.net)で作成したインフラ構成図をコードで管理する、GitHubで編集差分を確認する | DevelopersIO](https://dev.classmethod.jp/articles/create-infrastructure-diagrams-in-drawio-diactamsnet-manage-them-in-code-and-github/) 21 | 22 | ただし Draw.io に登録されている AWS アイコンはアップデートがされていないため、最新のアイコンを[公式サイト](https://aws.amazon.com/jp/architecture/icons/)からダウンロードして使用することをおすすめします。 23 | 24 | ### API 仕様書 25 | 26 | #### OAS バージョンについて 27 | 28 | 本プロジェクトの API 仕様書は、[OpenAPI Specification (OAS) 3.0.3](https://spec.openapis.org/oas/v3.0.3) に準拠したフォーマットで記述しています。 29 | 30 | 現在の OAS の最新バージョンは [3.1.0](https://spec.openapis.org/oas/v3.1.0) ですが、Swagger ビューワーによっては 3.0.3 までしか対応していない場合があるため、状況に応じて適切なバージョンを選択してください。 31 | 32 | #### パラメーターのフォーマット参考 33 | 34 | パラメーターのフォーマット(ケースの統一方法など)は、以下のドキュメントが参考になります。 35 | 36 | - [マネーフォワード クラウド経費APIドキュメント](https://expense.moneyforward.com/api/index.html) 37 | - [Nature API](https://swagger.nature.global/) 38 | - [Swagger Petstore](https://petstore.swagger.io/) 39 | 40 | 強く推奨されるフォーマットは無いので、パラメーター種類やデータ内容に応じて適切なフォーマットであること、およびプロジェクト内でフォーマットが統一されていることを意識して、チーム内で検討してください。 41 | 42 | #### レスポンスデータの階層について 43 | 44 | レスポンスデータに `nextToken` などのアイテム以外の情報を追加したい場合は、次のようにデータの階層を設ける実装としてください。 45 | 46 | ```js 47 | { 48 | "companies": [] 49 | "nextToken": "eyJ..." 50 | } 51 | ``` 52 | 53 | 本プロジェクトでは、レスポンスデータでアイテムのみを返すように、階層を設けない実装としています。 54 | 55 | #### 便利な VS Code 拡張 56 | 57 | OAS ドキュメントを使用した開発時に役に立つ VS Code 拡張機能として、以下の Extension を [.vscode/extensions.json](../../.vscode/extensions.json) に追加しています。 58 | 59 | | 名前 | 識別子 | 機能 | 注意点 | 60 | | -------------- | ---------------------- | ---------------------- | ----------------- | 61 | | YAML | `redhat.vscode-yaml` | バリデーション | | 62 | | Swagger Viewer | `Arjun.swagger-viewer` | リアルタイムプレビュー | OAS v3.1.0 未対応 | 63 | 64 | - 参考:[VSCodeでOpenAPI Specificationドキュメントを編集する際に便利なプラグインたち | DevelopersIO](https://dev.classmethod.jp/articles/useful-plugins-when-editing-openapi-specification-documents-with-vscode/) 65 | -------------------------------------------------------------------------------- /docs/system-design/rest-api.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: CDK Serverless API Sample REST API 4 | version: 1.0.0 5 | tags: 6 | - name: Company 7 | description: 会社 8 | security: 9 | - Bearer: [] 10 | paths: 11 | /companies: 12 | get: 13 | tags: 14 | - Company 15 | summary: 会社一覧取得 16 | parameters: 17 | - name: max_items 18 | description: 取得する最大件数 19 | in: query 20 | required: false 21 | schema: 22 | type: integer 23 | minimum: 1 24 | maximum: 5 25 | default: 3 26 | - name: industry 27 | description: 業種で絞り込み 28 | in: query 29 | required: false 30 | schema: 31 | $ref: "#/components/schemas/industry" 32 | - name: created_after 33 | description: この日時以降に作成されたデータを取得 34 | in: query 35 | required: false 36 | schema: 37 | type: number 38 | format: エポックミリ秒 39 | example: 1709251200000 40 | description: | 41 | created_after を使用する場合は、industryも指定してください。 42 | created_before より小さい日時を指定してください。 43 | - name: created_before 44 | description: この日時以前に作成されたデータを取得 45 | in: query 46 | required: false 47 | schema: 48 | type: number 49 | format: エポックミリ秒 50 | example: 1089126000000 51 | description: | 52 | created_before を使用する場合は、industryも指定してください。 53 | created_after より大きい日時を指定してください。 54 | responses: 55 | "200": 56 | description: OK 57 | content: 58 | application/json: 59 | schema: 60 | type: array 61 | items: 62 | $ref: "#/components/schemas/companiesGetResponseBody" 63 | "400": 64 | description: Bad Request(バリデーションエラー) 65 | "401": 66 | description: Unauthorized(認証エラー) 67 | "500": 68 | description: Internal Server Error(サーバーエラー) 69 | post: 70 | tags: 71 | - Company 72 | summary: 会社作成 73 | requestBody: 74 | required: true 75 | content: 76 | application/json: 77 | schema: 78 | $ref: "#/components/schemas/companiesPostRequestBody" 79 | responses: 80 | "201": 81 | description: Created 82 | content: 83 | application/json: 84 | schema: 85 | $ref: "#/components/schemas/companiesPostResponseBody" 86 | headers: 87 | Location: 88 | description: 作成されたリソースの URI 89 | schema: 90 | type: string 91 | format: uri 92 | example: /companies/e3162725-4b5b-4779-bf13-14d55d63a584 93 | "400": 94 | description: Bad Request(バリデーションエラー) 95 | "401": 96 | description: Unauthorized(認証エラー) 97 | "409": 98 | description: Conflict(リソースの競合) 99 | "500": 100 | description: Internal Server Error(サーバーエラー) 101 | /companies/{companyId}: 102 | get: 103 | tags: 104 | - Company 105 | summary: 会社取得 106 | parameters: 107 | - name: companyId 108 | in: path 109 | required: true 110 | schema: 111 | $ref: "#/components/schemas/companyId" 112 | responses: 113 | "200": 114 | description: OK 115 | content: 116 | application/json: 117 | schema: 118 | $ref: "#/components/schemas/companiesIdGetResponseBody" 119 | "400": 120 | description: Bad Request(バリデーションエラー) 121 | "401": 122 | description: Unauthorized(認証エラー) 123 | "404": 124 | description: Not Found(リソースが見つからない) 125 | "500": 126 | description: Internal Server Error(サーバーエラー) 127 | delete: 128 | tags: 129 | - Company 130 | summary: 会社削除 131 | parameters: 132 | - name: companyId 133 | in: path 134 | required: true 135 | schema: 136 | $ref: "#/components/schemas/companyId" 137 | responses: 138 | "204": 139 | description: No Content 140 | "400": 141 | description: Bad Request(バリデーションエラー) 142 | "401": 143 | description: Unauthorized(認証エラー) 144 | "404": 145 | description: Not Found(リソースが見つからない) 146 | "500": 147 | description: Internal Server Error(サーバーエラー) 148 | components: 149 | schemas: 150 | companiesGetResponseBody: 151 | type: object 152 | properties: 153 | companyId: 154 | $ref: "#/components/schemas/companyId" 155 | createdAt: 156 | $ref: "#/components/schemas/createdAt" 157 | companyName: 158 | $ref: "#/components/schemas/companyName" 159 | industry: 160 | $ref: "#/components/schemas/industry" 161 | required: 162 | - companyId 163 | - createdAt 164 | - companyName 165 | companiesPostRequestBody: 166 | type: object 167 | properties: 168 | name: 169 | $ref: "#/components/schemas/companyName" 170 | industry: 171 | $ref: "#/components/schemas/industry" 172 | required: 173 | - companyName 174 | companiesPostResponseBody: 175 | type: object 176 | properties: 177 | companyId: 178 | $ref: "#/components/schemas/companyId" 179 | createdAt: 180 | $ref: "#/components/schemas/createdAt" 181 | companyName: 182 | $ref: "#/components/schemas/companyName" 183 | industry: 184 | $ref: "#/components/schemas/industry" 185 | required: 186 | - id 187 | - createdAt 188 | - companyName 189 | companiesIdGetResponseBody: 190 | type: object 191 | properties: 192 | companyId: 193 | $ref: "#/components/schemas/companyId" 194 | createdAt: 195 | $ref: "#/components/schemas/createdAt" 196 | companyName: 197 | $ref: "#/components/schemas/companyName" 198 | industry: 199 | $ref: "#/components/schemas/industry" 200 | required: 201 | - companyId 202 | - createdAt 203 | - companyName 204 | companyId: 205 | type: string 206 | description: 会社 ID 207 | example: e3162725-4b5b-4779-bf13-14d55d63a584 208 | createdAt: 209 | type: number 210 | description: データ作成日時 211 | format: エポックミリ秒 212 | example: 1089126000000 213 | companyName: 214 | type: string 215 | description: 会社名 216 | example: ICASU, Inc. 217 | industry: 218 | type: string 219 | description: 業種 220 | enum: 221 | - IT 222 | - Manufacturing 223 | - Finance 224 | - Medical 225 | - Other 226 | example: IT 227 | securitySchemes: 228 | Bearer: 229 | type: http 230 | scheme: bearer 231 | bearerFormat: JWT 232 | -------------------------------------------------------------------------------- /docs/user-guide/README.md: -------------------------------------------------------------------------------- 1 | # ユーザーガイド 2 | 3 | ここでは本サンプルを参考にサーバレスアーキテクチャでシステムを開発したい方向けのユーザーガイドを記載しています。 4 | 5 | 本サンプルで採用しているアーキテクチャを使用すると、クラスメソッド社内での採用実績があるため安定稼働が期待でき、また社内外にノウハウが多くあるためサポートを受けやすいとうメリットもあります。 6 | 7 | ## ツール類の導入 8 | 9 | 開発プロジェクトの初回構築時に導入するツール類の導入手順および ICASU NOTE をリンク先で紹介しています。 10 | 11 | TODO: 各リンク先のドキュメントを作成する 12 | TODO: リンク先ではなく本ページへの直接の記載を検討する 13 | 14 | - [Pull Request テンプレート](./pull-request-template.md) 15 | - [パッケージ管理ツール (npm)](./package-manager.md) 16 | - モノレポ管理ツール (npm workspaces) 17 | - IaC ツール (AWS CDK) 18 | - CI/CD (GitHub Actions) 19 | - テストフレームワーク (Vitest) 20 | - リンター/フォーマッター (@classmethod/eslint-config) 21 | 22 | ## 実装サンプル 23 | 24 | 本サンプルリポジトリは上記のツール類を導入し、さらに以下の技術スタックを利用してサーバーレスアプリケーションを実装しています。実装サンプルとしてご利用ください。 25 | 26 | | 機能種別 | 技術スタック | 27 | | --------------------------------------- | ------------------------------------------------------------------------------------------------------ | 28 | | デザインパターン | レイヤードアーキテクチャ + Humble Object パターン | 29 | | データベース | Amazon DynamoDB | 30 | | コンピューティング | [Serverless Express](https://github.com/CodeGenieApp/serverless-express) を使用したモノリシック Lambda | 31 | | API | Amazon API Gateway REST API | 32 | | 認証、ユーザーディレクトリ | Amazon Cognito user pools | 33 | | アプリケーションログ監視/メトリクス監視 | Amazon CloudWatch, AWS Chatbot, Slack | 34 | -------------------------------------------------------------------------------- /docs/user-guide/package-manager.md: -------------------------------------------------------------------------------- 1 | # パッケージマネージャー 2 | 3 | パッケージマネージャーにより Node.js の依存関係を管理することができます。 4 | 5 | ここではパッケージマネージャーとして npm の ICASU NOTE および導入手順を記載します。 6 | 7 | ## ICASU NOTE 8 | 9 | - npm 社は GitHub 社により買収されたため、Dependabot をはじめとした GitHub の機能の優先的なサポートが期待できる 10 | - 例えば [pnpm は Dependabot alerts 非対応](https://docs.github.com/en/enterprise-cloud@latest/code-security/dependabot/dependabot-version-updates/about-dependabot-version-updates#pnpm) である 11 | - Node.js 標準のパッケージマネージャーであるため、Node.js 最新バージョンでの優先的なサポートが期待できる 12 | - AWS CDK 利用時の不具合報告が少ない 13 | - 例えば pnpm では AWS CDK の lambda-nodejs の利用において次のような事象が報告されているため、利用時には注意が必要である 14 | - [AWS CDK(aws-lambda-nodejs)のデプロイ時にNode.js v14非対応のpnpm v8が使われエラーになる問題を解消](https://zenn.dev/cureapp/articles/aws-lambda-nodejs-pnpm-error) 15 | - [(lambda-nodejs): Unable to use `nodeModules` with pnpm · Issue #21910 · aws/aws-cdk](https://github.com/aws/aws-cdk/issues/21910) 16 | - [lambda-nodejs: Bundling fails with recent pnpm version · Issue #25612 · aws/aws-cdk](https://github.com/aws/aws-cdk/issues/25612) 17 | 18 | ## npm 導入手順 19 | 20 | npm 環境を初期化により `package.json` を作成します。 21 | 22 | ```shell 23 | npm init -y 24 | ``` 25 | 26 | `.gitignore` ファイルを作成し、`node_modules` ディレクトリを無視するように設定します。 27 | 28 | ```shell 29 | touch .gitignore 30 | echo "node_modules" >> .gitignore 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/user-guide/pull-request-template.md: -------------------------------------------------------------------------------- 1 | # Pull Request テンプレート 2 | 3 | GitHub では、`.github` ディレクトリに配置した `pull_request_template.md` の内容をプルリクエスト作成時のテンプレートとすることができます。 4 | 5 | これにより、プルリクエストに記述して欲しい情報をチーム内で統一することができます。 6 | 7 | ## 導入手順 8 | 9 | 以下のコマンドを実行して、`.github/pull_request_template.md` ファイルを作成します。ファイルの内容は必要に応じて修正してください。 10 | 11 | ```shell 12 | mkdir -p .github 13 | cat << EOF > .github/pull_request_template.md 14 | ## 機能概要 15 | 16 | 〇〇システムにおいて認証機能を実現する為に〇〇を実装します。 17 | 18 | ## 変更点 19 | 20 | - [ ] 変更点 A 21 | - [ ] 変更点 B 22 | 23 | ## 対象外 24 | 25 | - 対象外 A 26 | - 対象外 B 27 | 28 | ## アウトプット 29 | 30 | (ScreenShot or JSON image) 31 | 32 | ## 手動テスト内容 33 | 34 | - 単体テストがパスすることを確認 35 | - E2E テストがパスすることを確認 36 | 37 | ## 影響範囲 38 | 39 | - 新規機能開発の為影響無し 40 | 41 | ## 関連課題 42 | 43 | - 関連課題 URL 44 | 45 | ## その他 46 | 47 | - 特記事項を記載 48 | EOF 49 | ``` 50 | 51 | ## 参考 52 | 53 | - [リポジトリ用のプルリクエストテンプレートの作成 - GitHub Docs](https://docs.github.com/ja/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository) 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-lambda-dynamodb-sample", 3 | "version": "0.0.0-github-release", 4 | "scripts": { 5 | "check": "run-p check:*", 6 | "check:format": "prettier --check --cache './packages/**/*.ts'", 7 | "check:lint": "eslint --cache './packages/**/*.ts' --max-warnings=0 --resolve-plugins-relative-to .", 8 | "check:type": "npm run check:type -ws", 9 | "check:cspell": "npm run check:cspell -ws", 10 | "test-unit": "npm run test -w packages/server", 11 | "test-snapshot": "npm run test-snapshot -w packages/iac", 12 | "test-snapshot:update": "npm run test-snapshot -w packages/iac -- run -u", 13 | "deploy:dev": "npm run deploy -w packages/iac -- --context environment=dev", 14 | "deploy:dev:direct": "npm run deploy -w packages/iac -- --context environment=dev --method=direct", 15 | "deploy:stg": "npm run deploy -w packages/iac -- --context environment=stg", 16 | "deploy:prd": "npm run deploy -w packages/iac -- --context environment=prd", 17 | "test-e2e-api:dev": "npm run test-api:dev -w packages/e2e", 18 | "test-e2e-api:stg": "npm run test-api:stg -w packages/e2e", 19 | "install:recommended-vscode-extensions": "for ext in $(jq -r '.recommendations[]' .vscode/extensions.json); do code --install-extension $ext; echo \"\"; done" 20 | }, 21 | "workspaces": [ 22 | "packages/iac", 23 | "packages/server", 24 | "packages/e2e" 25 | ], 26 | "devDependencies": { 27 | "@classmethod/eslint-config": "0.1.7", 28 | "@classmethod/prettier-config": "0.0.3", 29 | "cspell": "8.10.4", 30 | "npm-run-all": "4.1.5", 31 | "prettier": "3.3.3" 32 | }, 33 | "prettier": "@classmethod/prettier-config" 34 | } 35 | -------------------------------------------------------------------------------- /packages/e2e/environment.ts: -------------------------------------------------------------------------------- 1 | import { getParameter } from "@aws-lambda-powertools/parameters/ssm"; 2 | 3 | const env = process.env.ENVIRONMENT === "staging" ? "stg" : "dev"; 4 | 5 | const parameterPrefix = `/${env}/icasu-cdk-serverless-api-sample/e2e`; 6 | 7 | const envKeys = [ 8 | "COGNITO_USER_POOL_ID", 9 | "COGNITO_CLIENT_ID", 10 | "REST_API_ENDPOINT", 11 | "COMPANIES_TABLE_NAME", 12 | ]; 13 | 14 | export const setEnvironmentVariables = async (): Promise => { 15 | await Promise.all( 16 | envKeys.map(async (envKey) => { 17 | process.env[envKey] = await getParameter(`${parameterPrefix}/${envKey}`); 18 | }), 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/e2e/globalSetup.ts: -------------------------------------------------------------------------------- 1 | import { setEnvironmentVariables } from "@/environment"; 2 | import { createUser, getIdToken, deleteUser } from "@/utils/cognito-helper"; 3 | 4 | const timestamp = new Date().getTime().toString(); 5 | const testUserName = `${timestamp}_apiTestUser@example.com`; 6 | const testUserPassword = `${timestamp}aA!`; 7 | 8 | export const setup = async (): Promise => { 9 | await setEnvironmentVariables(); 10 | 11 | const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ""; 12 | const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ""; 13 | 14 | await createUser(testUserName, testUserPassword, COGNITO_USER_POOL_ID); 15 | const testUserIdToken = await getIdToken( 16 | testUserName, 17 | testUserPassword, 18 | COGNITO_USER_POOL_ID, 19 | COGNITO_CLIENT_ID, 20 | ); 21 | process.env["TEST_USER_ID_TOKEN"] = testUserIdToken; 22 | }; 23 | 24 | export const teardown = async (): Promise => { 25 | const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ""; 26 | 27 | await deleteUser(testUserName, COGNITO_USER_POOL_ID); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "0.0.0-github-release", 4 | "devDependencies": { 5 | "@aws-lambda-powertools/parameters": "1.18.1", 6 | "@aws-sdk/client-cognito-identity-provider": "3.614.0", 7 | "@aws-sdk/client-dynamodb": "3.614.0", 8 | "@aws-sdk/client-ssm": "3.614.0", 9 | "@aws-sdk/lib-dynamodb": "3.614.0", 10 | "@aws-sdk/util-dynamodb": "3.614.0", 11 | "dayjs": "1.11.11", 12 | "uuid": "9.0.1", 13 | "vitest": "1.6.0" 14 | }, 15 | "scripts": { 16 | "test-api:dev": "VITE_CJS_IGNORE_WARNING=true ENVIRONMENT=development vitest run api --config ./rest-api/vitest.config.ts", 17 | "test-api:stg": "VITE_CJS_IGNORE_WARNING=true ENVIRONMENT=staging vitest run api --config ./rest-api/vitest.config.ts", 18 | "check:type": "tsc --noEmit", 19 | "check:cspell": "cspell '**/*.{ts,json}' --cache --gitignore" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/e2e/rest-api/main.test.ts: -------------------------------------------------------------------------------- 1 | import { companiesGetTests } from "@/rest-api/test-cases/companies-get"; 2 | import { companiesIdDeleteTests } from "@/rest-api/test-cases/companies-id-delete"; 3 | import { companiesIdGetTests } from "@/rest-api/test-cases/companies-id-get"; 4 | import { companiesOptionsTests } from "@/rest-api/test-cases/companies-options"; 5 | import { companiesPostTests } from "@/rest-api/test-cases/companies-post"; 6 | 7 | describe("/companies", () => { 8 | describe("OPTIONS", () => { 9 | companiesOptionsTests(); 10 | }); 11 | 12 | describe("POST", () => { 13 | companiesPostTests(); 14 | }); 15 | 16 | describe("GET", () => { 17 | companiesGetTests(); 18 | }); 19 | 20 | describe("/:id", () => { 21 | describe("GET", () => { 22 | companiesIdGetTests(); 23 | }); 24 | 25 | describe("DELETE", (): void => { 26 | companiesIdDeleteTests(); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/e2e/rest-api/test-cases/companies-id-delete.ts: -------------------------------------------------------------------------------- 1 | import { getIdTokenFromEnv } from "@/utils/cognito-helper"; 2 | import { 3 | putCompany, 4 | deleteCompany, 5 | getCompany, 6 | Company, 7 | } from "@/utils/companies-table-helper"; 8 | import { getCurrentUnixTimestampMillis } from "@/utils/datetime"; 9 | import { 10 | requestToRestApi, 11 | RestApiResponse, 12 | } from "@/utils/rest-api-endpoint-helper"; 13 | import { generateUuidV4 } from "@/utils/uuid"; 14 | 15 | const idToken = getIdTokenFromEnv(); 16 | 17 | export const companiesIdDeleteTests = () => { 18 | const testCompanyId = generateUuidV4(); 19 | 20 | afterAll(async () => { 21 | await deleteCompany(testCompanyId); 22 | }); 23 | 24 | describe("正常なリクエストを送信した場合", () => { 25 | let response: RestApiResponse = undefined; 26 | let company: Company | undefined = undefined; 27 | 28 | beforeAll(async () => { 29 | await putCompany({ 30 | id: testCompanyId, 31 | createdAt: getCurrentUnixTimestampMillis(), 32 | name: "restApiCompaniesIdDeleteTest1", 33 | }); 34 | response = await requestToRestApi({ 35 | path: `companies/${testCompanyId}`, 36 | method: "DELETE", 37 | headers: { 38 | Authorization: idToken, 39 | }, 40 | }); 41 | 42 | company = await getCompany(testCompanyId); 43 | }); 44 | 45 | test("204 No Content レスポンスを取得すること", () => { 46 | expect(response?.status).toBe(204); 47 | expect(response?.headers).toMatchObject({ 48 | "access-control-allow-headers": "Content-Type,Authorization", 49 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 50 | "access-control-allow-origin": "*", 51 | }); 52 | expect(response?.data).toBe(""); 53 | }); 54 | 55 | test("削除された Company が取得できないこと", async () => { 56 | expect(company).toBeUndefined(); 57 | }); 58 | }); 59 | 60 | describe("不正な形式の CompanyID を指定した場合", () => { 61 | let response: RestApiResponse = undefined; 62 | 63 | beforeAll(async () => { 64 | response = await requestToRestApi({ 65 | path: `companies/invalid_id`, 66 | method: "DELETE", 67 | headers: { 68 | Authorization: idToken, 69 | }, 70 | }); 71 | }); 72 | 73 | test("400 Bad Request レスポンスを取得すること", () => { 74 | expect(response?.status).toBe(400); 75 | expect(response?.headers).toMatchObject({ 76 | "access-control-allow-headers": "Content-Type,Authorization", 77 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 78 | "access-control-allow-origin": "*", 79 | }); 80 | expect(response?.data).toBe(""); 81 | }); 82 | }); 83 | 84 | describe("存在しない CompanyID を指定した場合", () => { 85 | const nonExistCompanyId = generateUuidV4(); 86 | let response: RestApiResponse = undefined; 87 | 88 | beforeAll(async () => { 89 | response = await requestToRestApi({ 90 | path: `companies/${nonExistCompanyId}`, 91 | method: "DELETE", 92 | headers: { 93 | Authorization: idToken, 94 | }, 95 | }); 96 | }); 97 | 98 | test("404 Not Found レスポンスを取得すること", () => { 99 | expect(response?.status).toBe(404); 100 | expect(response?.headers).toMatchObject({ 101 | "access-control-allow-headers": "Content-Type,Authorization", 102 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 103 | "access-control-allow-origin": "*", 104 | }); 105 | expect(response?.data).toBe(""); 106 | }); 107 | }); 108 | 109 | describe("認証情報が不正なリクエストを送信した場合", () => { 110 | let response: RestApiResponse = undefined; 111 | 112 | beforeAll(async () => { 113 | response = await requestToRestApi({ 114 | path: `companies/${testCompanyId}`, 115 | method: "DELETE", 116 | headers: { 117 | Authorization: "invalid token", 118 | }, 119 | }); 120 | }); 121 | 122 | test("401 Unauthorized レスポンスを取得すること", () => { 123 | expect(response?.status).toBe(401); 124 | expect(response?.data).toEqual({ message: "Unauthorized" }); 125 | }); 126 | }); 127 | }; 128 | -------------------------------------------------------------------------------- /packages/e2e/rest-api/test-cases/companies-id-get.ts: -------------------------------------------------------------------------------- 1 | import { getIdTokenFromEnv } from "@/utils/cognito-helper"; 2 | import { putCompany, deleteCompany } from "@/utils/companies-table-helper"; 3 | import { getCurrentUnixTimestampMillis } from "@/utils/datetime"; 4 | import { 5 | requestToRestApi, 6 | RestApiResponse, 7 | } from "@/utils/rest-api-endpoint-helper"; 8 | import { generateUuidV4 } from "@/utils/uuid"; 9 | 10 | const idToken = getIdTokenFromEnv(); 11 | 12 | export const companiesIdGetTests = () => { 13 | const testCompanyId = generateUuidV4(); 14 | const testCompany = { 15 | id: testCompanyId, 16 | createdAt: getCurrentUnixTimestampMillis(), 17 | name: "restApiCompaniesIdGetTest1", 18 | }; 19 | 20 | beforeAll(async () => { 21 | await putCompany(testCompany); 22 | }); 23 | 24 | afterAll(async () => { 25 | await deleteCompany(testCompanyId); 26 | }); 27 | 28 | describe("正常なリクエストを送信した場合", () => { 29 | let response: RestApiResponse = undefined; 30 | 31 | beforeAll(async () => { 32 | response = await requestToRestApi({ 33 | path: `companies/${testCompanyId}`, 34 | method: "GET", 35 | headers: { 36 | Authorization: idToken, 37 | }, 38 | }); 39 | }); 40 | 41 | test("200 OK レスポンスを取得すること", () => { 42 | expect(response?.status).toBe(200); 43 | expect(response?.headers).toMatchObject({ 44 | "access-control-allow-headers": "Content-Type,Authorization", 45 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 46 | "access-control-allow-origin": "*", 47 | "content-type": "application/json; charset=utf-8", 48 | }); 49 | expect(response?.data).toEqual(testCompany); 50 | }); 51 | }); 52 | 53 | describe("不正な形式の CompanyID を指定した場合", () => { 54 | let response: RestApiResponse = undefined; 55 | 56 | beforeAll(async () => { 57 | response = await requestToRestApi({ 58 | path: `companies/invalid_id`, 59 | method: "GET", 60 | headers: { 61 | Authorization: idToken, 62 | }, 63 | }); 64 | }); 65 | 66 | test("400 Bad Request レスポンスを取得すること", () => { 67 | expect(response?.status).toBe(400); 68 | expect(response?.headers).toMatchObject({ 69 | "access-control-allow-headers": "Content-Type,Authorization", 70 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 71 | "access-control-allow-origin": "*", 72 | }); 73 | expect(response?.data).toBe(""); 74 | }); 75 | }); 76 | 77 | describe("存在しない CompanyID を指定した場合", () => { 78 | const nonExistCompanyId = generateUuidV4(); 79 | let response: RestApiResponse = undefined; 80 | 81 | beforeAll(async () => { 82 | response = await requestToRestApi({ 83 | path: `companies/${nonExistCompanyId}`, 84 | method: "GET", 85 | headers: { 86 | Authorization: idToken, 87 | }, 88 | }); 89 | }); 90 | 91 | test("404 Not Found レスポンスを取得すること", () => { 92 | expect(response?.status).toBe(404); 93 | expect(response?.headers).toMatchObject({ 94 | "access-control-allow-headers": "Content-Type,Authorization", 95 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 96 | "access-control-allow-origin": "*", 97 | }); 98 | expect(response?.data).toBe(""); 99 | }); 100 | }); 101 | 102 | describe("認証情報が不正なリクエストを送信した場合", () => { 103 | let response: RestApiResponse = undefined; 104 | 105 | beforeAll(async () => { 106 | response = await requestToRestApi({ 107 | path: `companies/${testCompanyId}`, 108 | method: "GET", 109 | headers: { 110 | Authorization: "invalid token", 111 | }, 112 | }); 113 | }); 114 | 115 | test("401 Unauthorized レスポンスを取得すること", () => { 116 | expect(response?.status).toBe(401); 117 | expect(response?.data).toEqual({ message: "Unauthorized" }); 118 | }); 119 | }); 120 | }; 121 | -------------------------------------------------------------------------------- /packages/e2e/rest-api/test-cases/companies-options.ts: -------------------------------------------------------------------------------- 1 | import { 2 | requestToRestApi, 3 | RestApiResponse, 4 | } from "@/utils/rest-api-endpoint-helper"; 5 | 6 | export const companiesOptionsTests = () => { 7 | let response: RestApiResponse = undefined; 8 | 9 | beforeAll(async () => { 10 | response = await requestToRestApi({ 11 | path: "companies", 12 | method: "OPTIONS", 13 | headers: { 14 | "Access-Control-Request-Method": "POST", 15 | "Access-Control-Request-Headers": "authorization", 16 | Origin: "https://example.com", 17 | }, 18 | }); 19 | }); 20 | 21 | test("Preflight Request が成功すること", () => { 22 | expect(response?.status).toBe(204); 23 | expect(response?.headers).toMatchObject({ 24 | "access-control-allow-headers": 25 | "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent", 26 | "access-control-allow-methods": "OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD", 27 | "access-control-allow-origin": "*", 28 | "access-control-max-age": "300", 29 | }); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/e2e/rest-api/test-cases/companies-post.ts: -------------------------------------------------------------------------------- 1 | import { getIdTokenFromEnv } from "@/utils/cognito-helper"; 2 | import { 3 | getCompany, 4 | listCompanies, 5 | deleteCompany, 6 | Company, 7 | companyIdRegexPattern, 8 | } from "@/utils/companies-table-helper"; 9 | import { 10 | getCurrentDayjs, 11 | isUnixTimestampMillisBetween, 12 | } from "@/utils/datetime"; 13 | import { 14 | requestToRestApi, 15 | RestApiResponse, 16 | } from "@/utils/rest-api-endpoint-helper"; 17 | 18 | const idToken = getIdTokenFromEnv(); 19 | 20 | export const companiesPostTests = () => { 21 | /** 22 | * ICASU_NOTE: 前処理を beforeAll() 、テスト実施を test() に明確に分離することにより、前処理とテスト項目が増えた場合でも可読性が損なわれないようになります。 23 | * 下記では、API へのリクエストによりレスポンスの取得と、リクエスト完了後のテーブル上のデータの取得、の 2 つを前処理として beforeAll() に記述しています。 24 | * 25 | * ICASU_NOTE: テストで確認したい項目が複数ある場合は、項目ごとに test() を分離することで、何をテストしているのかが明確になります。 26 | * 下記では、API レスポンスのテストと、リクエスト完了後のテーブル上のデータのテスト、の 2 つのテスト項目があるため、それぞれを別の test() に記述しています。 27 | */ 28 | describe("正常なリクエストを送信した場合", () => { 29 | const testStartDatetime = getCurrentDayjs(); 30 | const testEndDatetime = testStartDatetime.add(3, "second"); 31 | const testCompanyName = "restApiCompaniesPostTest1"; 32 | let response: RestApiResponse = undefined; 33 | let createdCompany: Company | undefined = undefined; 34 | 35 | beforeAll(async () => { 36 | response = await requestToRestApi({ 37 | path: "companies", 38 | method: "POST", 39 | headers: { 40 | Authorization: idToken, 41 | "Content-Type": "application/json", 42 | }, 43 | data: { 44 | name: testCompanyName, 45 | }, 46 | }); 47 | 48 | createdCompany = await getCompany(response?.data.id); 49 | }); 50 | 51 | test("201 Created レスポンスを取得すること", () => { 52 | expect(response?.status).toBe(201); 53 | expect(response?.headers).toMatchObject({ 54 | "access-control-allow-headers": "Content-Type,Authorization", 55 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 56 | "access-control-allow-origin": "*", 57 | "content-type": "application/json; charset=utf-8", 58 | location: `/companies/${response?.data.id}`, 59 | }); 60 | expect(response?.data).toEqual({ 61 | id: expect.any(String), 62 | createdAt: expect.any(Number), 63 | name: testCompanyName, 64 | }); 65 | }); 66 | 67 | test("会社データの ID が UUID v4 形式であること", () => { 68 | expect(response?.data.id).toMatch(companyIdRegexPattern); 69 | }); 70 | 71 | test("会社データの作成日時がリクエスト送信時刻から 3 秒以内であること", () => { 72 | expect( 73 | isUnixTimestampMillisBetween( 74 | response?.data.createdAt, 75 | testStartDatetime, 76 | testEndDatetime, 77 | ), 78 | ).toBeTruthy(); 79 | }); 80 | 81 | test("テーブルにデータが作成されていること", () => { 82 | expect(createdCompany).toEqual(response?.data); 83 | }); 84 | 85 | afterAll(async () => { 86 | await deleteCompany(response?.data.id); 87 | }); 88 | }); 89 | 90 | describe("データを定義しないリクエストを送信した場合", () => { 91 | const testCompanyName = 92 | new Date().getTime().toString() + "_apiTestCompanyName"; 93 | let response: RestApiResponse = undefined; 94 | let createdCompany: Company | undefined = undefined; 95 | 96 | beforeAll(async () => { 97 | response = await requestToRestApi({ 98 | path: "companies", 99 | method: "POST", 100 | headers: { 101 | Authorization: idToken, 102 | "Content-Type": "application/json", 103 | }, 104 | }); 105 | 106 | const companies = await listCompanies(); 107 | createdCompany = companies.find((item) => item.name === testCompanyName); 108 | }); 109 | 110 | test("400 Bad Request レスポンスを取得すること", () => { 111 | expect(response?.status).toBe(400); 112 | expect(response?.headers).toMatchObject({ 113 | "access-control-allow-headers": "Content-Type,Authorization", 114 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 115 | "access-control-allow-origin": "*", 116 | }); 117 | expect(response?.data).toBe(""); 118 | }); 119 | 120 | test("テーブルにデータが作成されていないこと", () => { 121 | expect(createdCompany).toBeUndefined(); 122 | }); 123 | }); 124 | 125 | describe("データスキーマが不正なリクエストを送信した場合", () => { 126 | const testCompanyName = 127 | new Date().getTime().toString() + "_apiTestCompanyName"; 128 | let response: RestApiResponse = undefined; 129 | let createdCompany: Company | undefined = undefined; 130 | 131 | beforeAll(async () => { 132 | response = await requestToRestApi({ 133 | path: "companies", 134 | method: "POST", 135 | headers: { 136 | Authorization: idToken, 137 | "Content-Type": "application/json", 138 | }, 139 | data: {}, 140 | }); 141 | 142 | const companies = await listCompanies(); 143 | createdCompany = companies.find((item) => item.name === testCompanyName); 144 | }); 145 | 146 | test("400 Bad Request レスポンスを取得すること", () => { 147 | expect(response?.status).toBe(400); 148 | expect(response?.headers).toMatchObject({ 149 | "access-control-allow-headers": "Content-Type,Authorization", 150 | "access-control-allow-methods": "OPTIONS,POST,PUT,GET,DELETE", 151 | "access-control-allow-origin": "*", 152 | }); 153 | expect(response?.data).toBe(""); 154 | }); 155 | 156 | test("テーブルにデータが作成されていないこと", () => { 157 | expect(createdCompany).toBeUndefined(); 158 | }); 159 | }); 160 | 161 | describe("認証情報が不正なリクエストを送信した場合", () => { 162 | const testCompanyName = 163 | new Date().getTime().toString() + "_apiTestCompanyName"; 164 | let response: RestApiResponse = undefined; 165 | let createdCompany: Company | undefined = undefined; 166 | 167 | beforeAll(async () => { 168 | response = await requestToRestApi({ 169 | path: "companies", 170 | method: "POST", 171 | headers: { 172 | Authorization: "invalid token", 173 | "Content-Type": "application/json", 174 | }, 175 | data: { 176 | name: "test company", 177 | }, 178 | }); 179 | 180 | const companies = await listCompanies(); 181 | createdCompany = companies.find((item) => item.name === testCompanyName); 182 | }); 183 | 184 | test("401 Unauthorized レスポンスを取得すること", () => { 185 | expect(response?.status).toBe(401); 186 | expect(response?.data).toEqual({ message: "Unauthorized" }); 187 | }); 188 | 189 | test("テーブルにデータが作成されていないこと", () => { 190 | expect(createdCompany).toBeUndefined(); 191 | }); 192 | }); 193 | }; 194 | -------------------------------------------------------------------------------- /packages/e2e/rest-api/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import { configShared } from "../vitest.shared"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | ...configShared.test, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "@/*": ["*"] 7 | }, 8 | // vitest.shared.ts で path モジュールを import で読み込む際のワークアラウンド 9 | // @see https://github.com/TypeStrong/ts-node/issues/1096 10 | "esModuleInterop": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/e2e/utils/cognito-helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CognitoIdentityProviderClient, 3 | AdminInitiateAuthCommand, 4 | AdminCreateUserCommand, 5 | MessageActionType, 6 | AdminSetUserPasswordCommand, 7 | AdminDeleteUserCommand, 8 | AuthFlowType, 9 | } from "@aws-sdk/client-cognito-identity-provider"; 10 | 11 | const client = new CognitoIdentityProviderClient({ 12 | apiVersion: "2016-04-18", 13 | region: "ap-northeast-1", 14 | }); 15 | 16 | // ユーザー作成 17 | export const createUser = async ( 18 | Username: string, 19 | Password: string, 20 | UserPoolId: string, 21 | ): Promise => { 22 | await client.send( 23 | new AdminCreateUserCommand({ 24 | UserPoolId, 25 | Username, 26 | UserAttributes: [ 27 | { 28 | Name: "email", 29 | Value: Username, 30 | }, 31 | { 32 | Name: "email_verified", 33 | Value: "true", 34 | }, 35 | ], 36 | MessageAction: MessageActionType.SUPPRESS, 37 | }), 38 | ); 39 | 40 | await client.send( 41 | new AdminSetUserPasswordCommand({ 42 | UserPoolId, 43 | Username, 44 | Password, 45 | Permanent: true, 46 | }), 47 | ); 48 | }; 49 | 50 | // ID トークン取得 51 | export const getIdToken = async ( 52 | Username: string, 53 | Password: string, 54 | UserPoolId: string, 55 | ClientId: string, 56 | ): Promise => { 57 | const auth = await client.send( 58 | new AdminInitiateAuthCommand({ 59 | UserPoolId, 60 | ClientId, 61 | AuthFlow: AuthFlowType.ADMIN_USER_PASSWORD_AUTH, 62 | AuthParameters: { 63 | USERNAME: Username, 64 | PASSWORD: Password, 65 | }, 66 | }), 67 | ); 68 | 69 | if (!auth.AuthenticationResult || !auth.AuthenticationResult.IdToken) { 70 | throw new Error("Failed get id token."); 71 | } 72 | 73 | return auth.AuthenticationResult.IdToken; 74 | }; 75 | 76 | // ユーザー削除 77 | export const deleteUser = async ( 78 | Username: string, 79 | UserPoolId: string, 80 | ): Promise => { 81 | await client.send( 82 | new AdminDeleteUserCommand({ 83 | UserPoolId, 84 | Username, 85 | }), 86 | ); 87 | }; 88 | 89 | export const getIdTokenFromEnv = (): string => { 90 | const TEST_USER_ID_TOKEN = process.env.TEST_USER_ID_TOKEN; 91 | 92 | if (!TEST_USER_ID_TOKEN) { 93 | throw new Error("TEST_USER_ID_TOKEN is not defined."); 94 | } 95 | 96 | return TEST_USER_ID_TOKEN; 97 | }; 98 | -------------------------------------------------------------------------------- /packages/e2e/utils/companies-table-helper.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; 2 | import { 3 | DynamoDBDocument, 4 | GetCommand, 5 | DeleteCommand, 6 | ScanCommand, 7 | PutCommand, 8 | } from "@aws-sdk/lib-dynamodb"; 9 | 10 | const dynamoClient = new DynamoDBClient({ 11 | region: "ap-northeast-1", 12 | }); 13 | const dynamoDBDocument = DynamoDBDocument.from(dynamoClient); 14 | 15 | export type Industry = "IT" | "Manufacturing" | "Finance" | "Medical" | "Other"; 16 | 17 | export interface Company { 18 | id: string; 19 | name: string; 20 | createdAt: number; 21 | industry?: Industry; 22 | } 23 | 24 | const COMPANIES_TABLE_NAME = process.env.COMPANIES_TABLE_NAME || ""; 25 | 26 | export const getCompany = async (id: string): Promise => { 27 | const result = await dynamoDBDocument.send( 28 | new GetCommand({ 29 | TableName: COMPANIES_TABLE_NAME, 30 | Key: { 31 | id: id, 32 | }, 33 | }), 34 | ); 35 | return result.Item as Company; 36 | }; 37 | 38 | export const deleteCompany = async (id: string): Promise => { 39 | await dynamoDBDocument.send( 40 | new DeleteCommand({ 41 | TableName: COMPANIES_TABLE_NAME, 42 | Key: { 43 | id: id, 44 | }, 45 | }), 46 | ); 47 | }; 48 | 49 | export const listCompanies = async (): Promise => { 50 | const result = await dynamoDBDocument.send( 51 | new ScanCommand({ 52 | TableName: COMPANIES_TABLE_NAME, 53 | }), 54 | ); 55 | return result.Items as Company[]; 56 | }; 57 | 58 | export const putCompany = async (company: { 59 | id: string; 60 | createdAt: number; 61 | name: string; 62 | }): Promise => { 63 | await dynamoDBDocument.send( 64 | new PutCommand({ 65 | TableName: COMPANIES_TABLE_NAME, 66 | Item: company, 67 | }), 68 | ); 69 | }; 70 | 71 | export const companyIdRegexPattern = 72 | /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/; 73 | -------------------------------------------------------------------------------- /packages/e2e/utils/datetime.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { Dayjs } from "dayjs"; 2 | import isBetween from "dayjs/plugin/isBetween"; 3 | 4 | dayjs.extend(isBetween); 5 | 6 | /** 7 | * 現在日時のエポックミリ秒を取得する 8 | * 9 | * @returns {number} 10 | */ 11 | export const getCurrentUnixTimestampMillis = (): number => dayjs().valueOf(); 12 | 13 | /** 14 | * 現在日時の Dayjs オブジェクトを取得する 15 | * 16 | * @returns {Dayjs} 17 | */ 18 | export const getCurrentDayjs = (): Dayjs => dayjs(); 19 | 20 | /** 21 | * 指定日時が範囲内にあるかどうかを判定する 22 | * 23 | * @param {number} target 判定対象日時(エポックミリ秒) 24 | * @param {Dayjs} start 判定範囲開始日時 25 | * @param {Dayjs} end 判定範囲終了日時 26 | * @returns {Boolean} 27 | */ 28 | export const isUnixTimestampMillisBetween = ( 29 | target: number, 30 | start: Dayjs, 31 | end: Dayjs, 32 | ): Boolean => dayjs(target).isBetween(start, end); 33 | 34 | /** 35 | * 指定の ISO 8601 形式の日時文字列をエポックミリ秒に変換する 36 | * 37 | * @param {string} iso8601String - ISO 8601 形式の日時文字列 38 | * @returns {number} エポックミリ秒 39 | */ 40 | export const convertIso8601StringToUnixTimestampMillis = ( 41 | iso8601String: string, 42 | ): number => dayjs(iso8601String).valueOf(); 43 | -------------------------------------------------------------------------------- /packages/e2e/utils/rest-api-endpoint-helper.ts: -------------------------------------------------------------------------------- 1 | export type RestApiResponse = 2 | | { 3 | data: T; 4 | status: number; 5 | statusText: string; 6 | headers: Record; 7 | } 8 | | undefined; 9 | 10 | type ParamValueType = string | number | undefined; 11 | 12 | const REST_API_ENDPOINT = process.env.REST_API_ENDPOINT || ""; 13 | 14 | const convertObjectValuesToString = ( 15 | params: Record, 16 | ) => { 17 | return Object.entries(params).reduce( 18 | (acc: Record, [key, value]) => { 19 | if (value !== undefined) { 20 | acc[key] = value.toString(); 21 | return acc; 22 | } 23 | return acc; 24 | }, 25 | {}, 26 | ); 27 | }; 28 | 29 | const convertParamsToQueryString = ( 30 | params: Record | undefined, 31 | ): string => { 32 | if (!params) { 33 | return ""; 34 | } 35 | return new URLSearchParams(convertObjectValuesToString(params)).toString(); 36 | }; 37 | 38 | const buildUrl = ( 39 | path: string, 40 | params: { [key: string]: ParamValueType } | undefined, 41 | ) => { 42 | if (!params) { 43 | return `${REST_API_ENDPOINT}/${path}`; 44 | } 45 | const queryParams = convertParamsToQueryString(params); 46 | return `${REST_API_ENDPOINT}/${path}${queryParams ? "?" + queryParams : ""}`; 47 | }; 48 | 49 | const extractJsonBody = async (response: Response) => { 50 | try { 51 | return await response.json(); 52 | } catch (error: unknown) { 53 | return ""; 54 | } 55 | }; 56 | 57 | const extractHeaders = (response: Response) => { 58 | const responseHeaders = response.headers; 59 | const headersObj: Record = {}; 60 | responseHeaders.forEach((v, k) => { 61 | headersObj[k] = v; 62 | }); 63 | return headersObj; 64 | }; 65 | 66 | export const requestToRestApi = async (request: { 67 | path: string; 68 | method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD"; 69 | headers?: { 70 | [key: string]: string; 71 | }; 72 | params?: { 73 | [key: string]: ParamValueType; 74 | }; 75 | data?: { 76 | [key: string]: string; 77 | }; 78 | }): Promise => { 79 | const { path, method, headers, params, data } = request; 80 | 81 | const url = buildUrl(path, params); 82 | 83 | const response = await fetch(url, { 84 | method, 85 | headers, 86 | body: JSON.stringify(data), 87 | }); 88 | 89 | const extractedBody = await extractJsonBody(response); 90 | const extractedHeaders = extractHeaders(response); 91 | 92 | return { 93 | status: response.status, 94 | statusText: response.statusText, 95 | headers: extractedHeaders, 96 | data: extractedBody, 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /packages/e2e/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | import * as Uuid from "uuid"; 2 | 3 | export const generateUuidV4 = (): string => Uuid.v4(); 4 | -------------------------------------------------------------------------------- /packages/e2e/vitest.shared.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export const configShared = { 4 | test: { 5 | alias: { 6 | "@": path.resolve(__dirname, "."), 7 | }, 8 | globalSetup: ["./globalSetup.ts"], 9 | globals: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/iac/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | 5 | # CDK asset staging directory 6 | .cdk.staging 7 | cdk.out 8 | -------------------------------------------------------------------------------- /packages/iac/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /packages/iac/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | You should explore the contents of this project. It demonstrates a CDK app with an instance of a stack (`IacStack`) 4 | which contains an Amazon SQS queue that is subscribed to an Amazon SNS topic. 5 | 6 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 7 | 8 | ## Useful commands 9 | 10 | * `npm run build` compile typescript to js 11 | * `npm run watch` watch for changes and compile 12 | * `cdk deploy` deploy this stack to your default AWS account/region 13 | * `cdk diff` compare deployed stack with current state 14 | * `cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /packages/iac/bin/iac.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from "aws-cdk-lib"; 2 | import { MainStack } from "../lib/main-stack"; 3 | import { getAppParameter } from "./parameter"; 4 | 5 | const app = new cdk.App(); 6 | 7 | const argContext = "environment"; 8 | const envKey = app.node.tryGetContext(argContext); 9 | const appParameter = getAppParameter(envKey); 10 | 11 | new MainStack( 12 | app, 13 | `${appParameter.envName}-${appParameter.projectName}-MainStack`, 14 | { 15 | ...appParameter, 16 | terminationProtection: true, // 削除保護を有効化 17 | }, 18 | ); 19 | -------------------------------------------------------------------------------- /packages/iac/bin/parameter.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from "aws-cdk-lib"; 2 | 3 | export interface AppParameter { 4 | projectName: string; // プロジェクト名 5 | envName: "dev" | "stg" | "prd"; // 環境名フラグ 6 | env: Environment; // デプロイ先の AWS アカウント ID および AWS リージョン 7 | commonResourcesProjectName: string; // 共通リソース管理用のプロジェクト名 8 | cognito: { 9 | // Cognito ユーザープールの設定 10 | domainPrefix: string; // ドメインプレフィックス 11 | callbackUrls: string[]; // コールバック URL 12 | logoutUrls: string[]; // ログアウト URL 13 | addClientForE2ETest: boolean; // E2E テストで ID トークンを管理者権限で取得する用のクライアントを追加するフラグ。本番環境では有効にしないこと 14 | }; 15 | dynamodb: { 16 | companiesTableIndustryCreatedAtIndexName: string; // 会社テーブルの GSI の名前 17 | }; 18 | gitHub: { 19 | // GitHub Actions の実行環境の情報 20 | owner: string; // GitHub リポジトリのオーナー 21 | repo: string; // GitHub リポジトリ名 22 | }; 23 | } 24 | 25 | const commonParameter = { 26 | projectName: "icasu-cdk-serverless-api-sample", 27 | commonResourcesProjectName: "icasu-cdk-common-resources-sample", 28 | dynamodb: { 29 | companiesTableIndustryCreatedAtIndexName: "IndustryCreatedAtIndex", 30 | }, 31 | gitHub: { 32 | owner: "classmethod-internal", 33 | repo: "icasu-cdk-serverless-api-sample", 34 | }, 35 | }; 36 | 37 | export const devParameter: AppParameter = { 38 | ...commonParameter, 39 | envName: "dev", 40 | env: { 41 | region: "ap-northeast-1", 42 | }, 43 | cognito: { 44 | domainPrefix: `dev-${commonParameter.projectName}`, 45 | callbackUrls: ["https://dev.example.com/"], 46 | logoutUrls: ["https://dev.example.com/"], 47 | addClientForE2ETest: true, 48 | }, 49 | }; 50 | 51 | export const stgParameter: AppParameter = { 52 | ...commonParameter, 53 | envName: "stg", 54 | env: { 55 | region: "ap-northeast-1", 56 | }, 57 | cognito: { 58 | domainPrefix: `stg-${commonParameter.projectName}`, 59 | callbackUrls: ["https://stg.example.com/"], 60 | logoutUrls: ["https://stg.example.com/"], 61 | addClientForE2ETest: true, 62 | }, 63 | }; 64 | 65 | export const prdParameter: AppParameter = { 66 | ...commonParameter, 67 | envName: "prd", 68 | env: { 69 | region: "ap-northeast-1", 70 | }, 71 | cognito: { 72 | domainPrefix: `prd-${commonParameter.projectName}`, 73 | callbackUrls: ["https://example.com/"], 74 | logoutUrls: ["https://example.com/"], 75 | addClientForE2ETest: false, 76 | }, 77 | }; 78 | 79 | /** 80 | * AWS アカウント ID を環境変数から取得する 81 | * @param envKey 環境キー 82 | * @returns AWS アカウント ID 83 | */ 84 | const getAwsAccountIdFromProcessEnv = ( 85 | envKey: "dev" | "stg" | "prd", 86 | ): string => { 87 | const awsAccountId = process.env[`${envKey.toUpperCase()}_AWS_ACCOUNT_ID`]; 88 | if (!awsAccountId) { 89 | throw new Error(`Not found AWS account ID: ${envKey}`); 90 | } 91 | return awsAccountId; 92 | }; 93 | 94 | /** 95 | * 指定した環境のパラメータを取得する 96 | * @param envKey 環境キー 97 | * @returns 指定した環境のパラメータ 98 | */ 99 | export const getAppParameter = ( 100 | envKey: "dev" | "stg" | "prd", 101 | ): AppParameter => { 102 | const parameters = [devParameter, stgParameter, prdParameter]; 103 | const appParameters = parameters.filter( 104 | (obj: AppParameter) => obj.envName === envKey, 105 | ); 106 | if (appParameters.length === 0) { 107 | throw new Error(`Not found environment key: ${envKey}`); 108 | } 109 | const appParameter = appParameters[0]; 110 | 111 | const awsAccountId = getAwsAccountIdFromProcessEnv(envKey); 112 | 113 | appParameter.env = { 114 | account: awsAccountId, 115 | region: appParameter.env.region, 116 | }; 117 | 118 | return appParameter; 119 | }; 120 | -------------------------------------------------------------------------------- /packages/iac/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/iac.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 38 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 39 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 40 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 41 | "@aws-cdk/aws-route53-patters:useCertificate": true, 42 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 43 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 44 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 45 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 46 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 47 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 48 | "@aws-cdk/aws-redshift:columnId": true, 49 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 50 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 51 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 52 | "@aws-cdk/aws-kms:aliasNameRef": true, 53 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 54 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 55 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/alert-notification.ts: -------------------------------------------------------------------------------- 1 | import { aws_ssm, aws_sns, aws_cloudwatch_actions } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | 4 | interface AlertNotificationConstructProps { 5 | readonly envName: string; 6 | readonly commonResourcesProjectName: string; 7 | } 8 | 9 | export class AlertNotificationConstruct extends Construct { 10 | public readonly notificationSnsAction: aws_cloudwatch_actions.SnsAction; 11 | constructor( 12 | scope: Construct, 13 | id: string, 14 | props: AlertNotificationConstructProps, 15 | ) { 16 | super(scope, id); 17 | 18 | const { envName, commonResourcesProjectName } = props; 19 | 20 | /** 21 | * アラート通知用 SNS トピックの Arn を取得 22 | */ 23 | const alertNotificationTopicArn = 24 | aws_ssm.StringParameter.fromStringParameterName( 25 | this, 26 | "AlertNotificationTopicArnParameter", 27 | `/${envName}/${commonResourcesProjectName}/alertNotificationTopicArn`, 28 | ).stringValue; 29 | 30 | /** 31 | * アラート通知用 SNS トピックを取得 32 | */ 33 | const alertNotificationTopic = aws_sns.Topic.fromTopicArn( 34 | this, 35 | "AlertNotificationTopic", 36 | alertNotificationTopicArn, 37 | ); 38 | 39 | /** 40 | * アラート通知用 SNS トピックをアクションとして指定 41 | */ 42 | this.notificationSnsAction = new aws_cloudwatch_actions.SnsAction( 43 | alertNotificationTopic, 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_lambda, 3 | aws_lambda_nodejs, 4 | aws_dynamodb, 5 | aws_apigateway, 6 | aws_cognito, 7 | aws_cloudwatch_actions, 8 | aws_ssm, 9 | aws_wafv2, 10 | Duration, 11 | } from "aws-cdk-lib"; 12 | import { Construct } from "constructs"; 13 | 14 | import { ApiGatewayMetricsMonitoringConstruct } from "./monitoring/api-gateway-metrics"; 15 | import { LambdaApplicationLogMonitoringConstruct } from "./monitoring/lambda-application-log"; 16 | 17 | interface ApiConstructProps { 18 | projectName: string; 19 | envName: "dev" | "stg" | "prd"; 20 | companiesTableIndustryCreatedAtIndexName: string; 21 | companiesTable: aws_dynamodb.Table; 22 | cognitoUserPool: aws_cognito.UserPool; 23 | notificationSnsAction: aws_cloudwatch_actions.SnsAction; 24 | wafWebAcl: aws_wafv2.CfnWebACL; 25 | } 26 | 27 | /** 28 | * ICASU_NOTE: API Gateway による Restful API の実装は、REST API と HTTP API の2つが利用可能です。 29 | * セキュリティやモニタリングのサービス品質を満たすために、AWS WAF や AWS X-Ray トレース機能をサポートしている **REST API** の採用を推奨します。 30 | * 31 | * サポートされている機能の詳細については、以下の AWS 公式ドキュメントを参考にしてください。 32 | * @see https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/http-api-vs-rest.html 33 | * 34 | * 本プロジェクトでは REST API を採用しています。 35 | */ 36 | 37 | export class ApiConstruct extends Construct { 38 | constructor(scope: Construct, id: string, props: ApiConstructProps) { 39 | super(scope, id); 40 | 41 | const { 42 | projectName, 43 | envName, 44 | companiesTableIndustryCreatedAtIndexName, 45 | companiesTable, 46 | cognitoUserPool, 47 | notificationSnsAction, 48 | wafWebAcl, 49 | } = props; 50 | 51 | /** 52 | * Lambda 関数を作成 53 | */ 54 | const restApiFunc = new aws_lambda_nodejs.NodejsFunction( 55 | this, 56 | "RestApiFunc", 57 | { 58 | architecture: aws_lambda.Architecture.ARM_64, // 既定では X86_64 が指定される。パフォーマンスおよびコスト効率が向上する ARM_64 を指定 59 | entry: "../server/src/lambda/handlers/api-gateway/rest-api/router.ts", 60 | runtime: aws_lambda.Runtime.NODEJS_20_X, 61 | environment: { 62 | COMPANIES_TABLE_NAME: companiesTable.tableName, 63 | COMPANIES_TABLE_INDUSTRY_CREATED_AT_INDEX_NAME: 64 | companiesTableIndustryCreatedAtIndexName, 65 | POWERTOOLS_SERVICE_NAME: `${envName}-${projectName}`, // Powertools for AWS Lambda の Logger の出力で service(サービス名)を指定 66 | }, 67 | tracing: aws_lambda.Tracing.ACTIVE, // AWS X-Ray によるトレースを有効化 68 | }, 69 | ); 70 | companiesTable.grantReadWriteData(restApiFunc); 71 | 72 | /** 73 | * Lambda アプリケーションログ監視構成を作成 74 | */ 75 | new LambdaApplicationLogMonitoringConstruct( 76 | this, 77 | `${restApiFunc.node.id}ApplicationLogMonitoring`, 78 | { 79 | notificationSnsAction, 80 | lambdaFunction: restApiFunc, 81 | logLevelError: { 82 | enable: true, 83 | threshold: 1, 84 | evaluationPeriods: 1, 85 | }, 86 | logLevelWarning: { 87 | enable: true, 88 | threshold: 1, 89 | evaluationPeriods: 1, 90 | }, 91 | }, 92 | ); 93 | 94 | /** 95 | * Cognito ユーザープール Authorizer を作成 96 | */ 97 | const cognitoUserPoolsAuthorizer = 98 | new aws_apigateway.CognitoUserPoolsAuthorizer( 99 | this, 100 | "CognitoUserPoolsAuthorizer", 101 | { 102 | cognitoUserPools: [cognitoUserPool], 103 | }, 104 | ); 105 | 106 | /** 107 | * REST API を作成 108 | */ 109 | const restApi = new aws_apigateway.LambdaRestApi(this, "RestApi", { 110 | handler: restApiFunc, 111 | defaultCorsPreflightOptions: { 112 | allowOrigins: aws_apigateway.Cors.ALL_ORIGINS, // TODO: オリジンを制限する 113 | allowMethods: aws_apigateway.Cors.ALL_METHODS, 114 | allowHeaders: aws_apigateway.Cors.DEFAULT_HEADERS, 115 | maxAge: Duration.minutes(5), 116 | }, // Web アプリケーションからの CORS リクエストを許可する場合はこの記述を追加 117 | deployOptions: { 118 | stageName: "v1", // 既定では "prod" になるため、適切なステージ名に変更 119 | tracingEnabled: true, // AWS X-Ray によるトレースを有効化 120 | }, 121 | defaultMethodOptions: { authorizer: cognitoUserPoolsAuthorizer }, 122 | }); 123 | 124 | /** 125 | * API レベルのエラー発生時のレスポンスに CORS エラーを抑制するヘッダーを追加 126 | */ 127 | restApi.addGatewayResponse("Default4xx", { 128 | type: aws_apigateway.ResponseType.DEFAULT_4XX, 129 | responseHeaders: { 130 | "Access-Control-Allow-Origin": "'*'", 131 | "Access-Control-Allow-Headers": "'*'", 132 | "Access-Control-Allow-Methods": "'*'", 133 | }, 134 | }); 135 | restApi.addGatewayResponse("Default5xx", { 136 | type: aws_apigateway.ResponseType.DEFAULT_5XX, 137 | responseHeaders: { 138 | "Access-Control-Allow-Origin": "'*'", 139 | "Access-Control-Allow-Headers": "'*'", 140 | "Access-Control-Allow-Methods": "'*'", 141 | }, 142 | }); 143 | 144 | /** 145 | * API Gateway のメトリクス監視 146 | */ 147 | new ApiGatewayMetricsMonitoringConstruct( 148 | this, 149 | "ApiGatewayMetricsMonitoring", 150 | { 151 | notificationSnsAction, 152 | restApiList: [restApi], 153 | }, 154 | ); 155 | 156 | /** 157 | * E2E テストで使用するために、SSM パラメータストアに API Gateway のエンドポイントを登録 158 | */ 159 | new aws_ssm.StringParameter(this, "RestApiEndpointParameter", { 160 | parameterName: `/${envName}/${projectName}/e2e/REST_API_ENDPOINT`, 161 | stringValue: restApi.deploymentStage.urlForPath(), 162 | }); 163 | 164 | /** 165 | * Web ACL と REST API の関連付け 166 | */ 167 | new aws_wafv2.CfnWebACLAssociation(this, "WebAclRestApiAssociation", { 168 | resourceArn: restApi.deploymentStage.stageArn, 169 | webAclArn: wafWebAcl.attrArn, 170 | }); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/cognito.ts: -------------------------------------------------------------------------------- 1 | import { aws_cognito, aws_ssm, aws_wafv2 } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | 4 | interface CognitoConstructProps { 5 | projectName: string; 6 | envName: "dev" | "stg" | "prd"; 7 | domainPrefix: string; 8 | callbackUrls: string[]; 9 | logoutUrls: string[]; 10 | addClientForE2ETest: boolean; 11 | wafWebAcl: aws_wafv2.CfnWebACL; 12 | } 13 | 14 | export class CognitoConstruct extends Construct { 15 | public readonly userPool: aws_cognito.UserPool; 16 | constructor(scope: Construct, id: string, props: CognitoConstructProps) { 17 | super(scope, id); 18 | 19 | const { 20 | projectName, 21 | envName, 22 | domainPrefix, 23 | callbackUrls, 24 | logoutUrls, 25 | addClientForE2ETest, 26 | wafWebAcl, 27 | } = props; 28 | 29 | /** 30 | * Cognito ユーザープールを作成 31 | */ 32 | const userPool = new aws_cognito.UserPool(this, "UserPool", { 33 | signInAliases: { 34 | email: true, // サインインIDにユーザーネームではなくメールアドレスを使用する 35 | }, 36 | deletionProtection: true, // 誤削除防止 37 | mfa: aws_cognito.Mfa.OPTIONAL, // MFA を任意で有効化可能とする 38 | }); 39 | this.userPool = userPool; 40 | 41 | /** 42 | * Cognito ユーザープールドメインを作成 43 | */ 44 | userPool.addDomain("UserPoolDomain", { 45 | cognitoDomain: { domainPrefix }, 46 | }); 47 | 48 | /** 49 | * Cognito ユーザープールクライアントを作成 50 | */ 51 | userPool.addClient("UserPoolClient", { 52 | generateSecret: false, 53 | oAuth: { 54 | callbackUrls: callbackUrls, 55 | logoutUrls: logoutUrls, 56 | flows: { authorizationCodeGrant: true }, // 大抵のケースでは authorizationCodeGrant のみ有効化すれば良い。既定では implicitCodeGrant フローが有効になっているため、無効化する。 57 | scopes: [ 58 | // 既定値には必要以上のスコープが含まれているため、必要最小限のスコープを指定する 59 | aws_cognito.OAuthScope.EMAIL, 60 | aws_cognito.OAuthScope.PROFILE, 61 | aws_cognito.OAuthScope.OPENID, 62 | ], 63 | }, 64 | }); 65 | 66 | /** 67 | * E2E テストで使用するために、SSM パラメータストアに Cognito ユーザープール ID を登録 68 | */ 69 | new aws_ssm.StringParameter(this, "UserPoolIdParameter", { 70 | parameterName: `/${envName}/${projectName}/e2e/COGNITO_USER_POOL_ID`, 71 | stringValue: userPool.userPoolId, 72 | }); 73 | 74 | /** 75 | * E2E テスト用の Cognito ユーザープールクライアントを作成 76 | */ 77 | if (addClientForE2ETest) { 78 | const userPoolClient = userPool.addClient("UserPoolClientForE2ETest", { 79 | generateSecret: false, 80 | oAuth: { 81 | callbackUrls: callbackUrls, 82 | logoutUrls: logoutUrls, 83 | flows: { authorizationCodeGrant: true }, 84 | scopes: [ 85 | aws_cognito.OAuthScope.EMAIL, 86 | aws_cognito.OAuthScope.PROFILE, 87 | aws_cognito.OAuthScope.OPENID, 88 | ], 89 | }, 90 | authFlows: { adminUserPassword: true }, // 管理者権限でユーザーの ID トークンを取得可能とするため有効化 91 | }); 92 | 93 | /** 94 | * E2E テストで使用する Cognito クライアント ID を SSM パラメータストアに登録 95 | */ 96 | new aws_ssm.StringParameter(this, "ClientIdParameter", { 97 | parameterName: `/${envName}/${projectName}/e2e/COGNITO_CLIENT_ID`, 98 | stringValue: userPoolClient.userPoolClientId, 99 | }); 100 | } 101 | 102 | /** 103 | * User Pool と REST API の関連付け 104 | */ 105 | new aws_wafv2.CfnWebACLAssociation(this, "WebAclUserPoolAssociation", { 106 | resourceArn: userPool.userPoolArn, 107 | webAclArn: wafWebAcl.attrArn, 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/dynamodb.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_dynamodb, 3 | aws_cloudwatch_actions, 4 | aws_ssm, 5 | RemovalPolicy, 6 | } from "aws-cdk-lib"; 7 | import { Construct } from "constructs"; 8 | 9 | import { DynamodbMetricsMonitoringConstruct } from "./monitoring/dynamodb-metrics"; 10 | 11 | interface DynamodbConstructProps { 12 | projectName: string; 13 | envName: "dev" | "stg" | "prd"; 14 | companiesTableIndustryCreatedAtIndexName: string; 15 | notificationSnsAction: aws_cloudwatch_actions.SnsAction; 16 | } 17 | 18 | export class DynamodbConstruct extends Construct { 19 | public readonly companiesTable: aws_dynamodb.Table; 20 | 21 | constructor(scope: Construct, id: string, props: DynamodbConstructProps) { 22 | super(scope, id); 23 | 24 | const { 25 | projectName, 26 | envName, 27 | companiesTableIndustryCreatedAtIndexName, 28 | notificationSnsAction, 29 | } = props; 30 | 31 | /** 32 | * 会社テーブル 33 | */ 34 | const companiesTable = new aws_dynamodb.Table(this, "CompaniesTable", { 35 | partitionKey: { 36 | name: "id", 37 | type: aws_dynamodb.AttributeType.STRING, 38 | }, 39 | billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST, 40 | deletionProtection: true, // 削除保護を有効化 41 | removalPolicy: RemovalPolicy.DESTROY, // FIXME: 必要に応じて RETAIN に変更する 42 | }); 43 | this.companiesTable = companiesTable; 44 | 45 | /** 46 | * 会社テーブル GSI 47 | */ 48 | companiesTable.addGlobalSecondaryIndex({ 49 | indexName: companiesTableIndustryCreatedAtIndexName, 50 | partitionKey: { 51 | name: "industry", 52 | type: aws_dynamodb.AttributeType.STRING, 53 | }, 54 | sortKey: { 55 | name: "createdAt", 56 | type: aws_dynamodb.AttributeType.NUMBER, 57 | }, 58 | projectionType: aws_dynamodb.ProjectionType.ALL, // FIXME: 必要に応じて KEYS_ONLY または INCLUDE に変更する 59 | }); 60 | 61 | /** 62 | * DynamoDB のメトリクス監視 63 | */ 64 | new DynamodbMetricsMonitoringConstruct(this, "MetricsMonitoring", { 65 | notificationSnsAction, 66 | tableList: [companiesTable], 67 | tableIndexList: [ 68 | { 69 | table: companiesTable, 70 | indexName: companiesTableIndustryCreatedAtIndexName, 71 | }, 72 | ], 73 | }); 74 | 75 | /** 76 | * E2E テストで使用するために、会社テーブル名 を SSM パラメータストアに保存 77 | */ 78 | new aws_ssm.StringParameter(this, "CompaniesTableNameParameter", { 79 | parameterName: `/${envName}/${projectName}/e2e/COMPANIES_TABLE_NAME`, 80 | stringValue: companiesTable.tableName, 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/github-actions-oidc.ts: -------------------------------------------------------------------------------- 1 | import { aws_iam, Stack, CfnOutput } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | 4 | const CDK_QUALIFIER = "hnb659fds"; // 既定の CDK Bootstrap Stack 識別子 5 | 6 | interface GitHubActionsOidcConstructProps { 7 | gitHubOwner: string; 8 | gitHubRepo: string; 9 | } 10 | 11 | export class GitHubActionsOidcConstruct extends Construct { 12 | constructor( 13 | scope: Construct, 14 | id: string, 15 | props: GitHubActionsOidcConstructProps, 16 | ) { 17 | super(scope, id); 18 | 19 | const { gitHubOwner, gitHubRepo } = props; 20 | const awsAccountId = Stack.of(this).account; 21 | const region = Stack.of(this).region; 22 | 23 | /** 24 | * AssumeRole の引受先を制限する信頼ポリシーを定めたロールを作成 25 | * @see https://github.blog/2021-11-23-secure-deployments-openid-connect-github-actions-generally-available/ 26 | */ 27 | const gitHubActionsOidcRole = new aws_iam.Role( 28 | this, 29 | "GitHubActionsOidcRole", 30 | { 31 | assumedBy: new aws_iam.FederatedPrincipal( 32 | `arn:aws:iam::${awsAccountId}:oidc-provider/token.actions.githubusercontent.com`, 33 | /** 34 | * GitHub Actions が OIDC トークンを使って AssumeRole する際の条件定義 35 | * 36 | * MEMO: manual-deploy.yml による手動デプロイを実施可能とするために、sub 条件で Pull Request 以外のイベントも許可している 37 | */ 38 | { 39 | StringEquals: { 40 | "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", 41 | }, 42 | StringLike: { 43 | "token.actions.githubusercontent.com:sub": `repo:${gitHubOwner}/${gitHubRepo}:*`, 44 | }, 45 | }, 46 | "sts:AssumeRoleWithWebIdentity", 47 | ), 48 | }, 49 | ); 50 | new CfnOutput(this, "GitHubActionsOidcRoleArnOutput", { 51 | value: gitHubActionsOidcRole.roleArn, 52 | }); 53 | 54 | /** 55 | * AssumeRole に必要なポリシーを作成 56 | */ 57 | const cdkDeployPolicy = new aws_iam.Policy(this, "CdkDeployPolicy", { 58 | policyName: "CdkDeployPolicy", 59 | statements: [ 60 | new aws_iam.PolicyStatement({ 61 | effect: aws_iam.Effect.ALLOW, 62 | actions: ["s3:getBucketLocation", "s3:List*"], 63 | resources: ["arn:aws:s3:::*"], 64 | }), 65 | new aws_iam.PolicyStatement({ 66 | effect: aws_iam.Effect.ALLOW, 67 | actions: [ 68 | "cloudformation:CreateStack", 69 | "cloudformation:CreateChangeSet", 70 | "cloudformation:DeleteChangeSet", 71 | "cloudformation:DescribeChangeSet", 72 | "cloudformation:DescribeStacks", 73 | "cloudformation:DescribeStackEvents", 74 | "cloudformation:ExecuteChangeSet", 75 | "cloudformation:GetTemplate", 76 | ], 77 | resources: [ 78 | `arn:aws:cloudformation:${region}:${awsAccountId}:stack/*/*`, 79 | ], 80 | }), 81 | new aws_iam.PolicyStatement({ 82 | effect: aws_iam.Effect.ALLOW, 83 | actions: ["s3:PutObject", "s3:GetObject"], 84 | resources: [ 85 | `arn:aws:s3:::cdk-${CDK_QUALIFIER}-assets-${awsAccountId}-${region}/*`, 86 | ], 87 | }), 88 | new aws_iam.PolicyStatement({ 89 | effect: aws_iam.Effect.ALLOW, 90 | actions: ["ssm:GetParameter"], 91 | resources: [ 92 | `arn:aws:ssm:${region}:${awsAccountId}:parameter/cdk-bootstrap/${CDK_QUALIFIER}/version`, 93 | ], 94 | }), 95 | new aws_iam.PolicyStatement({ 96 | effect: aws_iam.Effect.ALLOW, 97 | actions: ["iam:PassRole"], 98 | resources: [ 99 | `arn:aws:iam::${awsAccountId}:role/cdk-${CDK_QUALIFIER}-cfn-exec-role-${awsAccountId}-${region}`, 100 | ], 101 | }), 102 | ], 103 | }); 104 | 105 | /** 106 | * AssumeRole に必要なポリシーをロールにアタッチ 107 | */ 108 | gitHubActionsOidcRole.attachInlinePolicy(cdkDeployPolicy); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/monitoring/README.md: -------------------------------------------------------------------------------- 1 | # Monitoring Application Log and Metrics 2 | 3 | [Shared Service Sample](https://github.com/cm-cxlabs/shared-service-sample) で構成したアラート通知機能を利用して、アプリケーションログ監視とメトリクス監視を実現するための CDK Construct です。 4 | 5 | | 監視項目 | 説明 | 実装 | 使用例 | 6 | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------- | 7 | | Lambda アプリケーションログ | Lambda 関数が出力した `ERROR` および `WARN` という文字列を含むアプリケーションログに対する、CloudWatch メトリクスフィルターおよびアラームを実装します。 | [Monitoring Lambda Application Log](./lambda-application-log.ts) | [API](./../api.ts) | 8 | | Lambda メトリクス | リソース個別のメトリクスの監視実装は無し。 | - | - | 9 | | DynamoDB メトリクス | テーブルおよび GSI リソース個別のメトリクス `SystemErrors`、`ThrottledRequests`、`ConsumedReadCapacityUnits` および `ConsumedWriteCapacityUnits` を監視するアラームを実装します。 | [Monitoring DynamoDB Metrics](./dynamodb-metrics.ts) | [DynamoDB](./../dynamodb.ts) | 10 | | API Gateway メトリクス | REST API リソース個別のメトリクス `5XXError` を監視するアラームを実装します。 | [Monitoring API Gateway Metrics](./api-gateway-metrics.ts) | [API](./../api.ts) | 11 | 12 | AWS リソース横断のメトリクス監視については [Shared Service Sample](https://github.com/cm-cxlabs/shared-service-sample/tree/feature-monitoring-resource/packages/iac/lib/constructs/monitoring) で別途実装しています。 13 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/monitoring/api-gateway-metrics.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_apigateway, 3 | aws_cloudwatch, 4 | aws_cloudwatch_actions, 5 | Duration, 6 | } from "aws-cdk-lib"; 7 | import { Construct } from "constructs"; 8 | 9 | import { getConstructId } from "./shared/utils"; 10 | 11 | interface ApiGatewayMetricsMonitoringConstructConstructProps { 12 | /** 13 | * アラート通知先の SNS アクション。 14 | */ 15 | readonly notificationSnsAction: aws_cloudwatch_actions.SnsAction; 16 | /** 17 | * 監視対象の REST API のリスト 18 | */ 19 | readonly restApiList: aws_apigateway.RestApi[]; 20 | } 21 | 22 | export class ApiGatewayMetricsMonitoringConstruct extends Construct { 23 | constructor( 24 | scope: Construct, 25 | id: string, 26 | props: ApiGatewayMetricsMonitoringConstructConstructProps, 27 | ) { 28 | super(scope, id); 29 | 30 | const { notificationSnsAction, restApiList } = props; 31 | 32 | // 各 REST API の監視アラームを作成 33 | restApiList.forEach((restApi) => { 34 | this.createAlarm(restApi, notificationSnsAction); 35 | }); 36 | } 37 | 38 | /** 39 | * REST API の監視アラームを作成 40 | * @param restApi 41 | * @param notificationSnsAction 42 | */ 43 | private createAlarm = ( 44 | restApi: aws_apigateway.RestApi, 45 | notificationSnsAction: aws_cloudwatch_actions.SnsAction, 46 | ) => { 47 | const restApiConstructId = getConstructId(restApi); 48 | 49 | /** 50 | * サーバーエラーの監視 51 | * @see https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-metrics-and-dimensions.html 52 | */ 53 | const errorAlarm = new aws_cloudwatch.Alarm( 54 | this, 55 | `${restApiConstructId}5XXErrorAlarm`, 56 | { 57 | alarmName: `ApiGateway${restApiConstructId}5XXError`, 58 | metric: new aws_cloudwatch.Metric({ 59 | namespace: "AWS/ApiGateway", 60 | metricName: "5XXError", 61 | dimensionsMap: { 62 | ApiName: restApi.restApiName, 63 | }, 64 | period: Duration.seconds(30), 65 | statistic: aws_cloudwatch.Stats.SUM, 66 | }), 67 | threshold: 10, // TODO: 必要に応じて調整する 68 | evaluationPeriods: 1, 69 | treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 70 | }, 71 | ); 72 | errorAlarm.addAlarmAction(notificationSnsAction); 73 | errorAlarm.addOkAction(notificationSnsAction); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/monitoring/dynamodb-metrics.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_dynamodb, 3 | aws_cloudwatch, 4 | aws_cloudwatch_actions, 5 | Duration, 6 | } from "aws-cdk-lib"; 7 | import { Construct } from "constructs"; 8 | 9 | import { getConstructId } from "./shared/utils"; 10 | 11 | interface TableIndex { 12 | readonly table: aws_dynamodb.Table; 13 | readonly indexName: string; 14 | } 15 | 16 | interface DynamodbMetricsMonitoringConstructProps { 17 | /** 18 | * アラート通知先の SNS アクション。 19 | */ 20 | readonly notificationSnsAction: aws_cloudwatch_actions.SnsAction; 21 | /** 22 | * 監視対象のテーブルのリスト 23 | * @default undefined 24 | */ 25 | readonly tableList?: aws_dynamodb.Table[]; 26 | /** 27 | * 監視対象のテーブル GSI のリスト 28 | * @default undefined 29 | */ 30 | readonly tableIndexList?: TableIndex[]; 31 | } 32 | 33 | export class DynamodbMetricsMonitoringConstruct extends Construct { 34 | constructor( 35 | scope: Construct, 36 | id: string, 37 | props: DynamodbMetricsMonitoringConstructProps, 38 | ) { 39 | super(scope, id); 40 | 41 | const { notificationSnsAction, tableList, tableIndexList } = props; 42 | 43 | // 各テーブルの監視アラームを作成 44 | if (tableList) { 45 | tableList.forEach((table) => { 46 | this.createTableAlarm(table, notificationSnsAction); 47 | }); 48 | } 49 | 50 | // 各テーブル GSI の監視アラームを作成 51 | if (tableIndexList) { 52 | tableIndexList.forEach((tableIndex) => { 53 | this.createTableIndexAlarm(tableIndex, notificationSnsAction); 54 | }); 55 | } 56 | } 57 | 58 | /** 59 | * テーブルの監視アラームを作成 60 | * @param table 61 | * @param notificationSnsAction 62 | */ 63 | private createTableAlarm = ( 64 | table: aws_dynamodb.Table, 65 | notificationSnsAction: aws_cloudwatch_actions.SnsAction, 66 | ): void => { 67 | const tableName = table.tableName; 68 | const tableConstructId = getConstructId(table); 69 | 70 | /** 71 | * システムエラー監視アラーム 72 | * @see https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/metrics-dimensions.html#SystemErrors 73 | */ 74 | const systemErrorsAlarm = new aws_cloudwatch.Alarm( 75 | this, 76 | `${tableConstructId}SystemErrorsAlarm`, 77 | { 78 | alarmName: `Dynamodb${tableConstructId}SystemErrors`, 79 | metric: new aws_cloudwatch.Metric({ 80 | namespace: "AWS/DynamoDB", 81 | metricName: "SystemErrors", 82 | dimensionsMap: { 83 | TableName: tableName, 84 | }, 85 | period: Duration.minutes(1), 86 | statistic: aws_cloudwatch.Stats.SUM, 87 | }), 88 | threshold: 3, // TODO: 必要に応じて調整する 89 | evaluationPeriods: 1, 90 | treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 91 | }, 92 | ); 93 | systemErrorsAlarm.addAlarmAction(notificationSnsAction); 94 | systemErrorsAlarm.addOkAction(notificationSnsAction); 95 | 96 | /** 97 | * プロビジョニング済みスループット監視アラーム(オンデマンドモードのテーブルでは不要) 98 | * @see https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/metrics-dimensions.html#ThrottledRequests 99 | */ 100 | // const throttledRequestsAlarm = new aws_cloudwatch.Alarm( 101 | // this, 102 | // `${tableConstructId}ThrottledRequestsAlarm`, 103 | // { 104 | // alarmName: `Dynamodb${tableConstructId}ThrottledRequests`, 105 | // metric: new aws_cloudwatch.Metric({ 106 | // namespace: "AWS/DynamoDB", 107 | // metricName: "ThrottledRequests", 108 | // dimensionsMap: { 109 | // TableName: tableName, 110 | // }, 111 | // period: Duration.minutes(1), 112 | // statistic: aws_cloudwatch.Stats.SUM, 113 | // }), 114 | // threshold: 1, // TODO: 必要に応じて調整する 115 | // evaluationPeriods: 1, 116 | // treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 117 | // }, 118 | // ); 119 | // throttledRequestsAlarm.addAlarmAction(notificationSnsAction); 120 | // throttledRequestsAlarm.addOkAction(notificationSnsAction); 121 | 122 | /** 123 | * 読み取りキャパシティユニット監視アラーム 124 | * @see https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/metrics-dimensions.html#ConsumedReadCapacityUnits 125 | */ 126 | const consumedReadCapacityUnitsAlarm = new aws_cloudwatch.Alarm( 127 | this, 128 | `${tableConstructId}ConsumedReadCapacityUnitsAlarm`, 129 | { 130 | alarmName: `Dynamodb${tableConstructId}ConsumedReadCapacityUnits`, 131 | metric: new aws_cloudwatch.Metric({ 132 | namespace: "AWS/DynamoDB", 133 | metricName: "ConsumedReadCapacityUnits", 134 | dimensionsMap: { 135 | TableName: tableName, 136 | }, 137 | period: Duration.minutes(1), 138 | statistic: aws_cloudwatch.Stats.SUM, 139 | }), 140 | threshold: 10000, // TODO: 必要に応じて調整する 141 | evaluationPeriods: 1, 142 | treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 143 | }, 144 | ); 145 | consumedReadCapacityUnitsAlarm.addAlarmAction(notificationSnsAction); 146 | consumedReadCapacityUnitsAlarm.addOkAction(notificationSnsAction); 147 | 148 | /** 149 | * 書き込みキャパシティユニット監視アラーム 150 | * @see https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/metrics-dimensions.html#ConsumedWriteCapacityUnits 151 | */ 152 | const consumedWriteCapacityUnitsAlarm = new aws_cloudwatch.Alarm( 153 | this, 154 | `${tableConstructId}ConsumedWriteCapacityUnitsAlarm`, 155 | { 156 | alarmName: `Dynamodb${tableConstructId}ConsumedWriteCapacityUnits`, 157 | metric: new aws_cloudwatch.Metric({ 158 | namespace: "AWS/DynamoDB", 159 | metricName: "ConsumedWriteCapacityUnits", 160 | dimensionsMap: { 161 | TableName: tableName, 162 | }, 163 | period: Duration.minutes(1), 164 | statistic: aws_cloudwatch.Stats.SUM, 165 | }), 166 | threshold: 10000, // TODO: 必要に応じて調整する 167 | evaluationPeriods: 1, 168 | treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 169 | }, 170 | ); 171 | consumedWriteCapacityUnitsAlarm.addAlarmAction(notificationSnsAction); 172 | consumedWriteCapacityUnitsAlarm.addOkAction(notificationSnsAction); 173 | }; 174 | 175 | /** 176 | * GSI の監視アラームを作成 177 | * @param tableIndex 178 | * @param notificationSnsAction 179 | */ 180 | private createTableIndexAlarm = ( 181 | tableIndex: TableIndex, 182 | notificationSnsAction: aws_cloudwatch_actions.SnsAction, 183 | ) => { 184 | const tableName = tableIndex.table.tableName; 185 | const tableConstructId = getConstructId(tableIndex.table); 186 | const indexName = tableIndex.indexName; 187 | 188 | /** 189 | * 読み取りキャパシティユニット監視アラーム 190 | * @see https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/metrics-dimensions.html#ConsumedReadCapacityUnits 191 | */ 192 | const consumedReadCapacityUnitsAlarm = new aws_cloudwatch.Alarm( 193 | this, 194 | `${tableConstructId}${indexName}ConsumedReadCapacityUnitsAlarm`, 195 | { 196 | alarmName: `Dynamodb${tableConstructId}${indexName}ConsumedReadCapacityUnits`, 197 | metric: new aws_cloudwatch.Metric({ 198 | namespace: "AWS/DynamoDB", 199 | metricName: "ConsumedReadCapacityUnits", 200 | dimensionsMap: { 201 | GlobalSecondaryIndexName: indexName, 202 | TableName: tableName, 203 | }, 204 | period: Duration.minutes(1), 205 | statistic: aws_cloudwatch.Stats.SUM, 206 | }), 207 | threshold: 10000, // TODO: 必要に応じて調整する 208 | evaluationPeriods: 1, 209 | treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 210 | }, 211 | ); 212 | consumedReadCapacityUnitsAlarm.addAlarmAction(notificationSnsAction); 213 | consumedReadCapacityUnitsAlarm.addOkAction(notificationSnsAction); 214 | 215 | /** 216 | * 書き込みキャパシティユニット監視アラーム 217 | * @see https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/metrics-dimensions.html#ConsumedWriteCapacityUnits 218 | */ 219 | const consumedWriteCapacityUnitsAlarm = new aws_cloudwatch.Alarm( 220 | this, 221 | `${tableConstructId}${indexName}ConsumedWriteCapacityUnitsAlarm`, 222 | { 223 | alarmName: `Dynamodb${tableConstructId}${indexName}ConsumedWriteCapacityUnits`, 224 | metric: new aws_cloudwatch.Metric({ 225 | namespace: "AWS/DynamoDB", 226 | metricName: "ConsumedWriteCapacityUnits", 227 | dimensionsMap: { 228 | GlobalSecondaryIndexName: indexName, 229 | TableName: tableName, 230 | }, 231 | period: Duration.minutes(1), 232 | statistic: aws_cloudwatch.Stats.SUM, 233 | }), 234 | threshold: 10000, // TODO: 必要に応じて調整する 235 | evaluationPeriods: 1, 236 | treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 237 | }, 238 | ); 239 | consumedWriteCapacityUnitsAlarm.addAlarmAction(notificationSnsAction); 240 | consumedWriteCapacityUnitsAlarm.addOkAction(notificationSnsAction); 241 | }; 242 | } 243 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/monitoring/lambda-application-log.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws_lambda, 3 | aws_logs, 4 | aws_cloudwatch, 5 | aws_cloudwatch_actions, 6 | Duration, 7 | RemovalPolicy, 8 | } from "aws-cdk-lib"; 9 | import { Construct } from "constructs"; 10 | 11 | enum LogLevel { 12 | ERROR = "ERROR", 13 | WARN = "WARN", 14 | } 15 | 16 | interface LogLevelProps { 17 | /** 18 | * 指定したログレベルの文字列の監視を有効化する 19 | * @default false 20 | */ 21 | readonly enable: boolean; 22 | /** 23 | * アラート発報の閾値 24 | * @default 1 25 | */ 26 | readonly threshold?: number; 27 | /** 28 | * アラート発報の評価期間(分) 29 | * @default 1 30 | */ 31 | readonly evaluationPeriods?: number; 32 | } 33 | 34 | interface LambdaApplicationLogMonitoringConstructProps { 35 | /** 36 | * アラート通知先の SNS アクション。 37 | */ 38 | readonly notificationSnsAction: aws_cloudwatch_actions.SnsAction; 39 | /** 40 | * ログを監視する Lambda 関数 41 | */ 42 | readonly lambdaFunction: aws_lambda.Function; 43 | /** 44 | * ログレベル "ERROR" の監視設定 45 | * @default undefined 46 | */ 47 | readonly logLevelError?: LogLevelProps; 48 | /** 49 | * ログレベル "WARNING" の監視設定 50 | * @default undefined 51 | */ 52 | readonly logLevelWarning?: LogLevelProps; 53 | } 54 | 55 | /** 56 | * Lambda 関数のアプリケーションログを監視する構成を作成するコンストラクト 57 | */ 58 | export class LambdaApplicationLogMonitoringConstruct extends Construct { 59 | constructor( 60 | scope: Construct, 61 | id: string, 62 | props: LambdaApplicationLogMonitoringConstructProps, 63 | ) { 64 | super(scope, id); 65 | 66 | const { 67 | lambdaFunction, 68 | logLevelError, 69 | logLevelWarning, 70 | notificationSnsAction, 71 | } = props; 72 | 73 | const logGroup = new aws_logs.LogGroup(this, "LogGroup", { 74 | logGroupName: `/aws/lambda/${lambdaFunction.functionName}`, 75 | removalPolicy: RemovalPolicy.DESTROY, // TODO: 必要に応じて Retain に変更 76 | }); 77 | 78 | if (logLevelError && logLevelError.enable) { 79 | this.createAlarm( 80 | LogLevel.ERROR, 81 | logLevelError, 82 | this, 83 | lambdaFunction, 84 | logGroup, 85 | notificationSnsAction, 86 | ); 87 | } 88 | 89 | if (logLevelWarning && logLevelWarning.enable) { 90 | this.createAlarm( 91 | LogLevel.WARN, 92 | logLevelWarning, 93 | this, 94 | lambdaFunction, 95 | logGroup, 96 | notificationSnsAction, 97 | ); 98 | } 99 | } 100 | 101 | private createAlarm = ( 102 | logLevel: LogLevel, 103 | logLevelProps: LogLevelProps, 104 | scope: Construct, 105 | lambdaFunction: aws_lambda.Function, 106 | logGroup: aws_logs.LogGroup, 107 | notificationSnsAction: aws_cloudwatch_actions.SnsAction, 108 | ): void => { 109 | const functionId = lambdaFunction.node.id; 110 | 111 | const metricFilter = new aws_logs.MetricFilter( 112 | scope, 113 | `${logLevel}MetricFilter`, 114 | { 115 | logGroup, 116 | metricNamespace: `Lambda/${functionId}`, 117 | metricName: logLevel, 118 | filterPattern: { logPatternString: logLevel }, 119 | }, 120 | ); 121 | 122 | const metric = metricFilter.metric({ 123 | period: Duration.minutes(1), 124 | statistic: aws_cloudwatch.Stats.SUM, 125 | }); 126 | 127 | const alarm = new aws_cloudwatch.Alarm(scope, `${logLevel}Alarm`, { 128 | alarmName: `${functionId}ApplicationLog${logLevel}`, 129 | metric, 130 | threshold: logLevelProps.threshold ?? 1, 131 | evaluationPeriods: logLevelProps.evaluationPeriods ?? 1, 132 | treatMissingData: aws_cloudwatch.TreatMissingData.NOT_BREACHING, 133 | }); 134 | 135 | alarm.addAlarmAction(notificationSnsAction); 136 | alarm.addOkAction(notificationSnsAction); 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/monitoring/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from "constructs"; 2 | 3 | /** 4 | * Construct の NodePath から ConstructId を取り出す 5 | * @param コンストラクトオブジェクト 6 | * @returns コンストラクト ID 7 | * @example "dev-icasu-cdk-serverless-api-sample-MainStack/Dynamodb/CompaniesTable" という NodePath から "CompaniesTable" を取り出す 8 | */ 9 | export const getConstructId = (construct: Construct): string => { 10 | const tableNodePath = construct.node.path; 11 | const parts = tableNodePath.split("/"); 12 | return parts[parts.length - 1]; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/waf.ts: -------------------------------------------------------------------------------- 1 | import { aws_wafv2, aws_logs, StackProps, RemovalPolicy } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | 4 | interface WafConstructProps extends StackProps { 5 | webAclScope: "REGIONAL" | "CLOUDFRONT"; 6 | webAclRules: aws_wafv2.CfnWebACL.RuleProperty[]; 7 | } 8 | 9 | /** 10 | * ICASU_NOTE: AWS WAF は、アプリケーションへのリクエストの特性の変化や、脆弱性情報の更新、新しい攻撃手法の発見などに対応するため、継続的な運用が必要です。 11 | * 顧客側で運用が難しい場合は WafCharm などの WAF 運用サービスの導入も検討してください。 12 | * @see: https://www.wafcharm.com/jp/ 13 | */ 14 | 15 | export class WafConstruct extends Construct { 16 | public readonly webAcl: aws_wafv2.CfnWebACL; 17 | 18 | constructor(scope: Construct, id: string, props: WafConstructProps) { 19 | super(scope, id); 20 | 21 | const { webAclScope, webAclRules } = props; 22 | 23 | const webAcl = new aws_wafv2.CfnWebACL(this, "WebAcl", { 24 | defaultAction: { allow: {} }, 25 | scope: webAclScope, 26 | visibilityConfig: { 27 | cloudWatchMetricsEnabled: true, 28 | sampledRequestsEnabled: true, 29 | metricName: "WebAcl", 30 | }, 31 | /** 32 | * Web ACL に適用するルールリストの設定。以下理由により別ファイルでの管理としています: 33 | * 34 | * - Web ACL の適用対象のリソース種類や機能によって、適したルールを選択可能とするため 35 | * - ルールは継続的な変更が発生する可能性があり、他の WAF に関する実装とライフサイクルを分けるため 36 | */ 37 | rules: webAclRules, 38 | }); 39 | this.webAcl = webAcl; 40 | 41 | /** 42 | * WAF ログ出力先となる CloudWatch Logs ロググループを作成 43 | * 44 | * ICASU_NOTE: ログを長期間保管したい場合や、書き込みおよび保管コストを削減したい場合は、 45 | * ログ出力先として S3 Bucket や Data Firehose Delivery Stream の利用を検討してください。 46 | */ 47 | const logGroup = new aws_logs.LogGroup(this, "LogGroup", { 48 | logGroupName: `aws-waf-logs-${webAcl.attrId}`, 49 | retention: aws_logs.RetentionDays.ONE_MONTH, // FIXME: 必要に応じて適切な保持期間に変更する 50 | removalPolicy: RemovalPolicy.DESTROY, // FIXME: 必要に応じて RETAIN に変更する 51 | }); 52 | 53 | /** 54 | * WAF ログ出力設定 55 | */ 56 | const logConfig = new aws_wafv2.CfnLoggingConfiguration(this, "LogConfig", { 57 | logDestinationConfigs: [logGroup.logGroupArn], 58 | resourceArn: webAcl.attrArn, 59 | /** 60 | * WAF ログ出力フィルタリング設定 61 | * @see https://dev.classmethod.jp/articles/aws-waf-config-trouble-caused-by-different-versions-of-awscli/#toc-6 62 | * @see https://docs.aws.amazon.com/waf/latest/APIReference/API_LoggingFilter.html 63 | * 64 | * ICASU_NOTE: ログ出力の対象を、ルールの条件にヒットしたリクエストに限定することにより、ログの量を抑制してコストを軽減しています。 65 | * すべてのリクエストをログ出力する場合は、LoggingFilter の設定を削除してください。 66 | */ 67 | loggingFilter: { 68 | Filters: [ 69 | { 70 | Behavior: "KEEP", 71 | Requirement: "MEETS_ANY", 72 | Conditions: [ 73 | { 74 | ActionCondition: { 75 | Action: "BLOCK", 76 | }, 77 | }, 78 | { 79 | ActionCondition: { 80 | Action: "COUNT", 81 | }, 82 | }, 83 | { 84 | ActionCondition: { 85 | Action: "EXCLUDED_AS_COUNT", 86 | }, 87 | }, 88 | ], 89 | }, 90 | ], 91 | DefaultBehavior: "DROP", 92 | }, 93 | }); 94 | 95 | // L1 Construct 同士の依存関係を明示的に設定 96 | logConfig.addDependency(webAcl); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/web-acl-rules/rest-api.ts: -------------------------------------------------------------------------------- 1 | import { aws_wafv2 } from "aws-cdk-lib"; 2 | 3 | /** 4 | * Web ACL ルールリスト(Rest API 用) 5 | * 6 | * ICASU_NOTE: AWS WAF の運用開始直後は、マネージドルールグループによる評価時のアクションを COUNT で上書きする設定とし、 7 | * 正常なリクエストがブロックされないことを確認する期間を設けることを推奨します。 8 | * ブロックされないことを確認できたら、評価時の上書きアクションを NONE に変更してください。 9 | * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-waf.html#user-pool-waf-evaluating-and-logging 10 | */ 11 | export const webAclRulesForRestApi: aws_wafv2.CfnWebACL.RuleProperty[] = [ 12 | /** 13 | * AWSManagedRulesCommonRuleSet (コアルールセット (CRS) マネージドルールグループ):推奨 14 | * 15 | * Web アプリケーションに一般的に適用可能なルールが含まれます。 16 | * OWASP Top 10 などで報告されている脆弱性の悪用(クロスサイトスクリプティングなど)から API を保護します。 17 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-crs 18 | */ 19 | { 20 | priority: 1, 21 | overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 22 | visibilityConfig: { 23 | sampledRequestsEnabled: true, 24 | cloudWatchMetricsEnabled: true, 25 | metricName: "AWS-AWSManagedRulesCommonRuleSet", 26 | }, 27 | name: "AWSManagedRulesCommonRuleSet", 28 | statement: { 29 | managedRuleGroupStatement: { 30 | vendorName: "AWS", 31 | name: "AWSManagedRulesCommonRuleSet", 32 | }, 33 | }, 34 | }, 35 | 36 | /** 37 | * AWSManagedRulesKnownBadInputsRuleSet (既知の不正な入力マネージドルールグループ):推奨 38 | * 39 | * 既知の不正なリクエストパターンを検知するルールが含まれます。 40 | * 主にサーバーサイドの脆弱性の悪用(インジェクションなど)から API を保護します。 41 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-known-bad-inputs 42 | */ 43 | { 44 | priority: 2, 45 | overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 46 | visibilityConfig: { 47 | sampledRequestsEnabled: true, 48 | cloudWatchMetricsEnabled: true, 49 | metricName: "AWS-AWSManagedRulesKnownBadInputsRuleSet", 50 | }, 51 | name: "AWSManagedRulesKnownBadInputsRuleSet", 52 | statement: { 53 | managedRuleGroupStatement: { 54 | vendorName: "AWS", 55 | name: "AWSManagedRulesKnownBadInputsRuleSet", 56 | }, 57 | }, 58 | }, 59 | 60 | /** 61 | * AWSManagedRulesAmazonIpReputationList (Amazon IP 評価リストマネージドルールグループ):推奨 62 | * 63 | * Amazon 内部脅威インテリジェンスに基づくルールが含まれます。 64 | * ボット、偵察、DDOS に関連するアクティビティに関連付けられている IP アドレスから送信されたリクエストから API を保護します。 65 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html#aws-managed-rule-groups-ip-rep-amazon 66 | */ 67 | { 68 | priority: 3, 69 | overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 70 | visibilityConfig: { 71 | sampledRequestsEnabled: true, 72 | cloudWatchMetricsEnabled: true, 73 | metricName: "AWS-AWSManagedRulesAmazonIpReputationList", 74 | }, 75 | name: "AWSManagedRulesAmazonIpReputationList", 76 | statement: { 77 | managedRuleGroupStatement: { 78 | vendorName: "AWS", 79 | name: "AWSManagedRulesAmazonIpReputationList", 80 | }, 81 | }, 82 | }, 83 | 84 | /** 85 | * AWSManagedRulesAnonymousIpList (匿名 IP リストマネージドルールグループ):検討 86 | * 87 | * 匿名アクセスが疑われる IP リストからのリクエストを検知します。 88 | * 匿名プロキシなどを利用したアクセスの脅威から API を保護したい場合に適用を検討してください。 89 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html#aws-managed-rule-groups-ip-rep-anonymous 90 | */ 91 | // { 92 | // priority: 4, 93 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 94 | // visibilityConfig: { 95 | // sampledRequestsEnabled: true, 96 | // cloudWatchMetricsEnabled: true, 97 | // metricName: "AWS-AWSManagedRulesAnonymousIpList", 98 | // }, 99 | // name: "AWSManagedRulesAnonymousIpList", 100 | // statement: { 101 | // managedRuleGroupStatement: { 102 | // vendorName: "AWS", 103 | // name: "AWSManagedRulesAnonymousIpList", 104 | // }, 105 | // }, 106 | // }, 107 | 108 | /** 109 | * AWSManagedRulesSQLiRuleSet (SQL データベースマネージドルールグループ):検討 110 | * 111 | * SQL インジェクションなどの SQL データベース悪用に関するリクエストを検知します。 112 | * SQL データベースを利用している場合に適用を検討してください。 113 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-use-case.html#aws-managed-rule-groups-use-case-sql-db 114 | */ 115 | // { 116 | // priority: 5, 117 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 118 | // visibilityConfig: { 119 | // sampledRequestsEnabled: true, 120 | // cloudWatchMetricsEnabled: true, 121 | // metricName: "AWS-AWSManagedRulesSQLiRuleSet", 122 | // }, 123 | // name: "AWSManagedRulesSQLiRuleSet", 124 | // statement: { 125 | // managedRuleGroupStatement: { 126 | // vendorName: "AWS", 127 | // name: "AWSManagedRulesSQLiRuleSet", 128 | // }, 129 | // }, 130 | // }, 131 | 132 | /** 133 | * AWSManagedRulesAdminProtectionRuleSet (管理者保護マネージドルールグループ):検討 134 | * 135 | * 公開されている管理ページへの外部アクセスをブロックするためのルールが含まれます。 136 | * 管理者機能を提供している API を保護したい場合に適用をしてください。 137 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-admin 138 | */ 139 | // { 140 | // priority: 6, 141 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 142 | // visibilityConfig: { 143 | // sampledRequestsEnabled: true, 144 | // cloudWatchMetricsEnabled: true, 145 | // metricName: "AWS-AWSManagedRulesAdminProtectionRuleSet", 146 | // }, 147 | // name: "AWSManagedRulesAdminProtectionRuleSet", 148 | // statement: { 149 | // managedRuleGroupStatement: { 150 | // vendorName: "AWS", 151 | // name: "AWSManagedRulesAdminProtectionRuleSet", 152 | // }, 153 | // }, 154 | // }, 155 | 156 | /** 157 | * AWSManagedRulesBotControlRuleSet (Bot Control ルールグループ):検討 158 | * 159 | * ボットからのリクエストを管理するルールを提供します。**利用には追加料金が必要**です。 160 | * ウェブスクレイピングや過剰な自動アクセスの脅威から API を保護したい場合に適用を検討してください。 161 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-bot.html 162 | */ 163 | // { 164 | // priority: 7, 165 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 166 | // visibilityConfig: { 167 | // sampledRequestsEnabled: true, 168 | // cloudWatchMetricsEnabled: true, 169 | // metricName: "AWS-AWSManagedRulesBotControlRuleSet", 170 | // }, 171 | // name: "AWSManagedRulesBotControlRuleSet", 172 | // statement: { 173 | // managedRuleGroupStatement: { 174 | // vendorName: "AWS", 175 | // name: "AWSManagedRulesBotControlRuleSet", 176 | // }, 177 | // }, 178 | // }, 179 | 180 | /** 181 | * AWSManagedRulesACFPRuleSet (AWS WAF Fraud Control Account Creation Fraud Prevention (ACFP) ルールグループ):検討 182 | * 183 | * アカウント作成エンドポイントに送信されるリクエストを検査します。**利用には追加料金が必要**です。 184 | * アカウント作成機能を提供する API を保護したい場合に適用を検討してください。 185 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-acfp.html 186 | */ 187 | // { 188 | // priority: 8, 189 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 190 | // visibilityConfig: { 191 | // sampledRequestsEnabled: true, 192 | // cloudWatchMetricsEnabled: true, 193 | // metricName: "AWS-AWSManagedRulesACFPRuleSet", 194 | // }, 195 | // name: "AWSManagedRulesACFPRuleSet", 196 | // statement: { 197 | // managedRuleGroupStatement: { 198 | // vendorName: "AWS", 199 | // name: "AWSManagedRulesACFPRuleSet", 200 | // }, 201 | // }, 202 | // }, 203 | 204 | /** 205 | * AWSManagedRulesATPRuleSet (アカウント乗っ取り防止 (ATP) のルールグループ):検討 206 | * 207 | * 悪意のあるアカウント乗っ取りの試みの一部である可能性があるリクエストにラベルを付けて管理します。**利用には追加料金が必要**です。 208 | * 不正ログインや詐取されたアカウントから API を保護したい場合に適用を検討してください。 209 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-atp.html 210 | */ 211 | // { 212 | // priority: 9, 213 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 214 | // visibilityConfig: { 215 | // sampledRequestsEnabled: true, 216 | // cloudWatchMetricsEnabled: true, 217 | // metricName: "AWS-AWSManagedRulesATPRuleSet", 218 | // }, 219 | // name: "AWSManagedRulesATPRuleSet", 220 | // statement: { 221 | // managedRuleGroupStatement: { 222 | // vendorName: "AWS", 223 | // name: "AWSManagedRulesATPRuleSet", 224 | // }, 225 | // }, 226 | // }, 227 | ]; 228 | -------------------------------------------------------------------------------- /packages/iac/lib/constructs/web-acl-rules/user-pool.ts: -------------------------------------------------------------------------------- 1 | import { aws_wafv2 } from "aws-cdk-lib"; 2 | 3 | /** 4 | * Web ACL ルールリスト(Cognito user pool 用) 5 | * Cognito の次の認証エンドポイントを保護します: 6 | * 7 | * - サインアップ、サインインおよびサインアップに関連するエンドポイント 8 | * - フェデレーションエンドポイント 9 | * - Hosted UI エンドポイント 10 | * - 公開 API オペレーション(Lambda トリガー)のエンドポイント 11 | * - InitiateAuth 12 | * - RespondToAuthChallenge 13 | * - GetUser 14 | * 15 | * ICASU_NOTE: AWS WAF の運用開始直後は、マネージドルールグループによる評価時のアクションを COUNT で上書きする設定とし、 16 | * 正常なリクエストがブロックされないことを確認する期間を設けることを推奨します。 17 | * ブロックされないことを確認できたら、評価時の上書きアクションを NONE に変更してください。 18 | * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-waf.html#user-pool-waf-evaluating-and-logging 19 | */ 20 | export const webAclRulesForUserPool: aws_wafv2.CfnWebACL.RuleProperty[] = [ 21 | /** 22 | * AWSManagedRulesCommonRuleSet (コアルールセット (CRS) マネージドルールグループ):推奨 23 | * 24 | * Web アプリケーションに一般的に適用可能なルールが含まれます。 25 | * OWASP Top 10 などで報告されている脆弱性の悪用(クロスサイトスクリプティングなど)から認証エンドポイントを保護します。 26 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-crs 27 | */ 28 | { 29 | priority: 1, 30 | overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 31 | visibilityConfig: { 32 | sampledRequestsEnabled: true, 33 | cloudWatchMetricsEnabled: true, 34 | metricName: "AWS-AWSManagedRulesCommonRuleSet", 35 | }, 36 | name: "AWSManagedRulesCommonRuleSet", 37 | statement: { 38 | managedRuleGroupStatement: { 39 | vendorName: "AWS", 40 | name: "AWSManagedRulesCommonRuleSet", 41 | }, 42 | }, 43 | }, 44 | 45 | /** 46 | * AWSManagedRulesKnownBadInputsRuleSet (既知の不正な入力マネージドルールグループ):推奨 47 | * 48 | * 既知の不正なリクエストパターンを検知するルールが含まれます。 49 | * 主にサーバーサイドの脆弱性の悪用(インジェクションなど)から認証エンドポイントを保護します。 50 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-known-bad-inputs 51 | */ 52 | { 53 | priority: 2, 54 | overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 55 | visibilityConfig: { 56 | sampledRequestsEnabled: true, 57 | cloudWatchMetricsEnabled: true, 58 | metricName: "AWS-AWSManagedRulesKnownBadInputsRuleSet", 59 | }, 60 | name: "AWSManagedRulesKnownBadInputsRuleSet", 61 | statement: { 62 | managedRuleGroupStatement: { 63 | vendorName: "AWS", 64 | name: "AWSManagedRulesKnownBadInputsRuleSet", 65 | }, 66 | }, 67 | }, 68 | 69 | /** 70 | * AWSManagedRulesAmazonIpReputationList (Amazon IP 評価リストマネージドルールグループ):推奨 71 | * 72 | * Amazon 内部脅威インテリジェンスに基づくルールが含まれます。 73 | * ボット、偵察、DDOS に関連するアクティビティに関連付けられている IP アドレスから送信されたリクエストから認証エンドポイントを保護します。 74 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html#aws-managed-rule-groups-ip-rep-amazon 75 | */ 76 | { 77 | priority: 3, 78 | overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 79 | visibilityConfig: { 80 | sampledRequestsEnabled: true, 81 | cloudWatchMetricsEnabled: true, 82 | metricName: "AWS-AWSManagedRulesAmazonIpReputationList", 83 | }, 84 | name: "AWSManagedRulesAmazonIpReputationList", 85 | statement: { 86 | managedRuleGroupStatement: { 87 | vendorName: "AWS", 88 | name: "AWSManagedRulesAmazonIpReputationList", 89 | }, 90 | }, 91 | }, 92 | 93 | /** 94 | * AWSManagedRulesAnonymousIpList (匿名 IP リストマネージドルールグループ):検討 95 | * 96 | * 匿名アクセスが疑われる IP リストからのリクエストを検知します。 97 | * 匿名プロキシなどを利用したアクセスの脅威から認証エンドポイントを保護したい場合に適用を検討してください。 98 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html#aws-managed-rule-groups-ip-rep-anonymous 99 | */ 100 | // { 101 | // priority: 4, 102 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 103 | // visibilityConfig: { 104 | // sampledRequestsEnabled: true, 105 | // cloudWatchMetricsEnabled: true, 106 | // metricName: "AWS-AWSManagedRulesAnonymousIpList", 107 | // }, 108 | // name: "AWSManagedRulesAnonymousIpList", 109 | // statement: { 110 | // managedRuleGroupStatement: { 111 | // vendorName: "AWS", 112 | // name: "AWSManagedRulesAnonymousIpList", 113 | // }, 114 | // }, 115 | // }, 116 | 117 | /** 118 | * AWSManagedRulesSQLiRuleSet (SQL データベースマネージドルールグループ):検討 119 | * 120 | * SQL インジェクションなどの SQL データベース悪用に関するリクエストを検知します。 121 | * Lambda トリガーから SQL データベースを利用している場合に適用を検討してください。 122 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-use-case.html#aws-managed-rule-groups-use-case-sql-db 123 | */ 124 | // { 125 | // priority: 5, 126 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 127 | // visibilityConfig: { 128 | // sampledRequestsEnabled: true, 129 | // cloudWatchMetricsEnabled: true, 130 | // metricName: "AWS-AWSManagedRulesSQLiRuleSet", 131 | // }, 132 | // name: "AWSManagedRulesSQLiRuleSet", 133 | // statement: { 134 | // managedRuleGroupStatement: { 135 | // vendorName: "AWS", 136 | // name: "AWSManagedRulesSQLiRuleSet", 137 | // }, 138 | // }, 139 | // }, 140 | 141 | /** 142 | * AWSManagedRulesBotControlRuleSet (Bot Control ルールグループ):検討 143 | * 144 | * ボットからのリクエストを管理するルールを提供します。**利用には追加料金が必要**です。 145 | * ウェブスクレイピングや過剰な自動アクセスの脅威から認証エンドポイントを保護したい場合に適用を検討してください。 146 | * @see https://docs.aws.amazon.com/ja_jp/waf/latest/developerguide/aws-managed-rule-groups-bot.html 147 | */ 148 | // { 149 | // priority: 6, 150 | // overrideAction: { count: {} }, // FIXME: `{ none: {} }` に変更してブロックを有効化する 151 | // visibilityConfig: { 152 | // sampledRequestsEnabled: true, 153 | // cloudWatchMetricsEnabled: true, 154 | // metricName: "AWS-AWSManagedRulesBotControlRuleSet", 155 | // }, 156 | // name: "AWSManagedRulesBotControlRuleSet", 157 | // statement: { 158 | // managedRuleGroupStatement: { 159 | // vendorName: "AWS", 160 | // name: "AWSManagedRulesBotControlRuleSet", 161 | // }, 162 | // }, 163 | // }, 164 | 165 | /** 166 | * 一部のルールグループは以下の理由により Cognito user pool への適用対象外としています。 167 | * 168 | * 理由:Cognito では保護対象となる管理者機能のパスが存在しないため。 169 | * 170 | * - AWSManagedRulesAdminProtectionRuleSet (管理者保護マネージドルールグループ) 171 | * 172 | * 理由:Cognito では利用できないルールグループであるため。 173 | * 174 | * - AWSManagedRulesACFPRuleSet (AWS WAF Fraud Control Account Creation Fraud Prevention (ACFP) ルールグループ) 175 | * - AWSManagedRulesATPRuleSet (アカウント乗っ取り防止 (ATP) のルールグループ) 176 | */ 177 | ]; 178 | -------------------------------------------------------------------------------- /packages/iac/lib/main-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps } from "aws-cdk-lib"; 2 | import { Construct } from "constructs"; 3 | 4 | import { AppParameter } from "../bin/parameter"; 5 | import { AlertNotificationConstruct } from "./constructs/alert-notification"; 6 | import { ApiConstruct } from "./constructs/api"; 7 | import { CognitoConstruct } from "./constructs/cognito"; 8 | import { DynamodbConstruct } from "./constructs/dynamodb"; 9 | import { GitHubActionsOidcConstruct } from "./constructs/github-actions-oidc"; 10 | import { WafConstruct } from "./constructs/waf"; 11 | import { webAclRulesForRestApi } from "./constructs/web-acl-rules/rest-api"; 12 | import { webAclRulesForUserPool } from "./constructs/web-acl-rules/user-pool"; 13 | 14 | export class MainStack extends Stack { 15 | constructor(scope: Construct, id: string, props: AppParameter & StackProps) { 16 | super(scope, id, props); 17 | 18 | const { 19 | projectName, 20 | envName, 21 | commonResourcesProjectName, 22 | cognito, 23 | dynamodb, 24 | gitHub, 25 | } = props; 26 | const { companiesTableIndustryCreatedAtIndexName } = dynamodb; 27 | 28 | new GitHubActionsOidcConstruct(this, "GitHubActionsOidc", { 29 | gitHubOwner: gitHub.owner, 30 | gitHubRepo: gitHub.repo, 31 | }); 32 | 33 | const { notificationSnsAction } = new AlertNotificationConstruct( 34 | this, 35 | "AlertNotification", 36 | { 37 | envName, 38 | commonResourcesProjectName, 39 | }, 40 | ); 41 | 42 | const dynamodbConstruct = new DynamodbConstruct(this, "Dynamodb", { 43 | envName, 44 | projectName, 45 | companiesTableIndustryCreatedAtIndexName, 46 | notificationSnsAction, 47 | }); 48 | 49 | const wafForUserPool = new WafConstruct(this, "WafForUserPool", { 50 | webAclScope: "REGIONAL", 51 | webAclRules: webAclRulesForUserPool, 52 | }); 53 | 54 | const cognitoConstruct = new CognitoConstruct(this, "Cognito", { 55 | projectName, 56 | envName, 57 | ...cognito, 58 | wafWebAcl: wafForUserPool.webAcl, 59 | }); 60 | 61 | const wafForApi = new WafConstruct(this, "WafForApi", { 62 | webAclScope: "REGIONAL", 63 | webAclRules: webAclRulesForRestApi, 64 | }); 65 | 66 | new ApiConstruct(this, "Api", { 67 | projectName, 68 | envName, 69 | notificationSnsAction, 70 | companiesTableIndustryCreatedAtIndexName, 71 | companiesTable: dynamodbConstruct.companiesTable, 72 | cognitoUserPool: cognitoConstruct.userPool, 73 | wafWebAcl: wafForApi.webAcl, 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/iac/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iac", 3 | "version": "0.0.0-github-release", 4 | "bin": { 5 | "iac": "bin/iac.js" 6 | }, 7 | "scripts": { 8 | "test-snapshot": "VITE_CJS_IGNORE_WARNING=true vitest --dir ./test", 9 | "deploy": "cdk deploy --require-approval never --all", 10 | "check:type": "tsc --noEmit", 11 | "check:cspell": "cspell '**/*.{ts,json}' --cache --gitignore" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "20.14.10", 15 | "aws-cdk": "2.149.0", 16 | "ts-node": "10.9.2", 17 | "typescript": "5.5.3" 18 | }, 19 | "dependencies": { 20 | "aws-cdk-lib": "2.149.0", 21 | "constructs": "10.3.0", 22 | "esbuild": "0.23.0", 23 | "vitest": "1.6.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/iac/test/main.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | 3 | import { snapshotStackTests } from "./stack-test"; 4 | 5 | describe("Develop Environment", () => { 6 | snapshotStackTests("dev"); 7 | }); 8 | 9 | describe("Staging Environment", () => { 10 | snapshotStackTests("stg"); 11 | }); 12 | 13 | describe("Production Environment", () => { 14 | snapshotStackTests("prd"); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/iac/test/plugins/ignore-asset-hash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Asset Hash の変更を無視するための Serializer 3 | * @see https://zenn.dev/junkor/articles/3674f576c6f4c0 4 | */ 5 | export const ignoreAssetHashSerializer = { 6 | test: (val: unknown) => typeof val === "string", 7 | serialize: (val: string) => { 8 | return `"${val.replace(/([A-Fa-f0-9]{64}.zip)/, "HASH-REPLACED.zip")}"`; 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/iac/test/stack-test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from "aws-cdk-lib"; 2 | import { Template } from "aws-cdk-lib/assertions"; 3 | import { expect, test } from "vitest"; 4 | 5 | import { getAppParameter } from "../bin/parameter"; 6 | import { MainStack } from "../lib/main-stack"; 7 | import { ignoreAssetHashSerializer } from "./plugins/ignore-asset-hash"; 8 | 9 | export const snapshotStackTests = (envName: "dev" | "stg" | "prd") => { 10 | process.env[`${envName.toUpperCase()}_AWS_ACCOUNT_ID`] = 11 | `${envName}-dummy-aws-account-id`; 12 | 13 | const app = new cdk.App(); 14 | 15 | const appParameter = getAppParameter(envName); 16 | 17 | const stack = new MainStack( 18 | app, 19 | `${appParameter.envName}-${appParameter.projectName}-MainStack`, 20 | appParameter, 21 | ); 22 | const template = Template.fromStack(stack).toJSON(); 23 | 24 | test("snapshot", (): void => { 25 | expect.addSnapshotSerializer(ignoreAssetHashSerializer); 26 | expect(template).toMatchSnapshot(); 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/iac/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "cdk.out" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/iac/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | // Vitest v1 導入時に発生するエラーのワークアラウンド 6 | // @see https://github.com/aws/aws-cdk/issues/20873#issuecomment-1847529085 7 | pool: "forks", 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # サーバーサイドアプリケーション 2 | 3 | Lambda 関数により実装するサーバーサイドアプリケーションのデザインパターンは「レイヤードアーキテクチャ + Humble Object パターン」を採用しています。1つの Lambda の実装内で処理の役割に応じてモジュールを階層化することにより、テスタブルなコードを実現しています。詳しくは[参考](#参考)も参照してください。 4 | 5 | 本サンプルでは [./src/lambda](./src/lambda) 配下で次のようなレイヤー階層を定義しています。 6 | 7 | | レイヤー | パス | 役割 | 依存 | 8 | | ---------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------- | 9 | | プレゼンテーション層 | `./src/lambda/handlers//` | クライアントからのリクエストの受け付け
リクエストのバリデーション
クライアントへのレスポンスの返却 | ユースケース層
ドメイン層 | 10 | | ユースケース層 | `./src/lambda/domain/usecases/` | 複数のドメイン層の処理の組み合わせたワークフローを実装してユースケースを実現 | ドメイン層 | 11 | | ドメイン層 | `./src/lambda/domain/services/` | 独立した特定の業務ロジックを実装 | インフラストラクチャ層 | 12 | | インフラストラクチャ層 | `./src/lambda/infrastructures/` | AWS サービスなどアプリケーション外のシステムにアクセスする処理を実装 | AWS SDK または各種ライブラリ | 13 | 14 | ## 参考 15 | 16 | Humble Object パターンの参考記事はこちら。 17 | 18 | - [【登壇資料】ServerlessDays Fukuoka 2019 で TypeScriprt と Jest を使ったサーバーレステストの話をしました #serverlessdays #serverlessfukuoka | DevelopersIO](https://dev.classmethod.jp/articles/serverless-testing-using-typescript-and-jest/) 19 | - [TypeScriptとJestではじめる AWS製サーバーレス REST API のユニットテスト・E2Eテスト #serverlessfukuoka #serverlessdays / Serverless testing using TypeScript and Jest - Speaker Deck](https://speakerdeck.com/wadayusuke/serverless-testing-using-typescript-and-jest?slide=25) 20 | - [Humble Objectパターンでテスタブルに | shtnkgm](https://shtnkgm.com/blog/2020-05-17-humble-object-pattern.html) 21 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0-github-release", 4 | "scripts": { 5 | "test": "VITE_CJS_IGNORE_WARNING=true vitest --dir ./src", 6 | "check:type": "tsc --noEmit", 7 | "check:cspell": "cspell '**/*.{ts,json}' --cache --gitignore" 8 | }, 9 | "dependencies": { 10 | "@aws-lambda-powertools/logger": "1.18.1", 11 | "@aws-sdk/client-dynamodb": "3.614.0", 12 | "@aws-sdk/lib-dynamodb": "3.614.0", 13 | "@codegenie/serverless-express": "4.14.1", 14 | "@middy/core": "5.4.5", 15 | "@smithy/node-http-handler": "2.5.0", 16 | "aws-lambda": "1.0.7", 17 | "aws-xray-sdk": "3.9.0", 18 | "cors": "2.8.5", 19 | "dayjs": "1.11.11", 20 | "express": "4.19.2", 21 | "uuid": "9.0.1", 22 | "zod": "3.23.8" 23 | }, 24 | "devDependencies": { 25 | "@types/aws-lambda": "8.10.141", 26 | "@types/cors": "2.8.17", 27 | "@types/express": "4.17.21", 28 | "@types/sinon-express-mock": "1.3.12", 29 | "@types/uuid": "9.0.8", 30 | "aws-sdk-client-mock": "4.0.1", 31 | "sinon-express-mock": "2.2.1", 32 | "vitest": "1.6.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/errors/company-service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 会社サービスで発生したエラーを表す既定クラス 3 | */ 4 | class CompanyServiceError extends Error { 5 | public constructor() { 6 | super(); 7 | 8 | if (Error.captureStackTrace) { 9 | Error.captureStackTrace(this, CompanyServiceError); 10 | } 11 | 12 | this.name = this.constructor.name; 13 | } 14 | } 15 | 16 | /** 17 | * 会社サービスで発生した会社が存在しないエラーを表すクラス 18 | */ 19 | export class CompanyServiceNotExistsError extends CompanyServiceError { 20 | public id: string; 21 | 22 | public constructor(id: string) { 23 | super(); 24 | 25 | this.name = this.constructor.name; 26 | this.id = id; 27 | } 28 | } 29 | 30 | /** 31 | * 会社サービスで発生した未知のエラーを表すクラス 32 | */ 33 | export class CompanyServiceUnknownError extends CompanyServiceError { 34 | public error: unknown; 35 | 36 | public constructor(error: unknown) { 37 | super(); 38 | 39 | this.name = this.constructor.name; 40 | this.error = error; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/create-company.test.ts: -------------------------------------------------------------------------------- 1 | import { createCompanyService } from "./create-company"; 2 | import { CompanyServiceUnknownError } from "@/lambda/domains/errors/company-service"; 3 | import { putItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 4 | import { 5 | CompaniesTableAlreadyExistsError, 6 | CompaniesTableUnknownError, 7 | } from "@/lambda/infrastructures/errors/companies-table"; 8 | 9 | /** 10 | * すべての vi.mock がファイルの先頭で実行される(最後のモックが優先される)仕様のワークアラウンド 11 | * @see https://zenn.dev/you_5805/articles/vitest-mock-hoisting https://vitest.dev/api/vi#vi-hoisted-0-31-0 12 | */ 13 | const { putItemMock, v4Mock } = vi.hoisted(() => { 14 | return { 15 | putItemMock: vi.fn(), 16 | v4Mock: vi.fn(), 17 | }; 18 | }); 19 | vi.mock("uuid", () => { 20 | return { 21 | v4: v4Mock, 22 | }; 23 | }); 24 | vi.mock("@/lambda/infrastructures/dynamodb/companies-table", () => { 25 | return { 26 | putItem: putItemMock, 27 | }; 28 | }); 29 | 30 | const fakeTime = new Date("2024-01-01T00:00:00+09:00"); 31 | const fakeTimestamp = fakeTime.getTime(); 32 | 33 | vi.useFakeTimers(); 34 | vi.setSystemTime(fakeTime); 35 | 36 | describe("createCompanyService", () => { 37 | afterEach(() => { 38 | vi.resetAllMocks(); 39 | }); 40 | 41 | const dummyCompanyId1 = "e3162725-4b5b-4779-bf13-14d55d63a584"; 42 | const dummyCompanyName1 = "dummy-name-1"; 43 | const dummyCompanyItem1 = { 44 | id: dummyCompanyId1, 45 | createdAt: fakeTimestamp, 46 | name: dummyCompanyName1, 47 | }; 48 | 49 | describe("実行が正常に行われた場合", () => { 50 | let result: unknown = undefined; 51 | 52 | beforeAll(async () => { 53 | v4Mock.mockReturnValue(dummyCompanyId1); 54 | putItemMock.mockResolvedValue(void 0); 55 | 56 | result = await createCompanyService({ name: dummyCompanyName1 }); 57 | }); 58 | 59 | test("putItem の呼び出しが期待通り行われること", () => { 60 | expect(putItem).toBeCalledTimes(1); 61 | expect(putItem).toHaveBeenCalledWith(dummyCompanyItem1); 62 | }); 63 | 64 | test("作成された会社データが返されること", () => { 65 | expect(result).toEqual(dummyCompanyItem1); 66 | }); 67 | }); 68 | 69 | describe("CompaniesTableAlreadyExistsError をキャッチした場合", () => { 70 | let result: unknown = undefined; 71 | 72 | beforeAll(async () => { 73 | v4Mock.mockReturnValue(dummyCompanyId1); 74 | putItemMock.mockRejectedValue(CompaniesTableAlreadyExistsError); 75 | 76 | result = createCompanyService({ name: dummyCompanyName1 }); // Promise を作成 77 | }); 78 | 79 | test("CompaniesTableAlreadyExistsError がスローされること", async () => { 80 | // NOTE: 非同期関数の rejects をアサーションする場合は Promise を指定して await で待機する。(待機しない場合は偽陽性となる可能性がある) 81 | // @see https://vitest.dev/api/expect#rejects 82 | await expect(result).rejects.toThrowError( 83 | new CompaniesTableAlreadyExistsError( 84 | "dummy-tableName", 85 | dummyCompanyItem1, 86 | ), 87 | ); 88 | 89 | // putItem の呼び出しが期待通り行われること 90 | // NOTE: Promise を rejects でアサーションする場合は、実行が前後する可能性があるため、呼び出しのアサーションも rejects と同じ test ブロック内で行う必要がある。 91 | expect(putItem).toBeCalledTimes(1); 92 | expect(putItem).toHaveBeenCalledWith(dummyCompanyItem1); 93 | }); 94 | }); 95 | 96 | describe("CompaniesTableUnknownError をキャッチした場合", () => { 97 | let result: unknown = undefined; 98 | 99 | beforeAll(async () => { 100 | v4Mock.mockReturnValue(dummyCompanyId1); 101 | putItemMock.mockRejectedValue(CompaniesTableUnknownError); 102 | 103 | result = createCompanyService({ name: dummyCompanyName1 }); // Promise を作成 104 | }); 105 | 106 | test("CompanyServiceUnknownError がスローされること", async () => { 107 | await expect(result).rejects.toThrowError(CompanyServiceUnknownError); 108 | 109 | // putItem の呼び出しが期待通り行われること 110 | expect(putItem).toBeCalledTimes(1); 111 | expect(putItem).toHaveBeenCalledWith(dummyCompanyItem1); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/create-company.ts: -------------------------------------------------------------------------------- 1 | import { Company } from "./schemas"; 2 | import { CompanyServiceUnknownError } from "@/lambda/domains/errors/company-service"; 3 | import { putItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 4 | import { CompaniesTableAlreadyExistsError } from "@/lambda/infrastructures/errors/companies-table"; 5 | import { getCurrentUnixTimestampMillis } from "@/utils/datetime"; 6 | import { generateUuidV4 } from "@/utils/uuid"; 7 | 8 | export interface CreateCompanyProps { 9 | name: string; 10 | } 11 | 12 | /** 13 | * 会社作成サービス 14 | * @param createCompanyProps 作成する会社のプロパティ 15 | * @throws CompaniesTableAlreadyExistsError (作成を試みた会社が既に存在する場合) 16 | * @throws CompanyServiceUnknownError (未知のエラーが発生した場合) 17 | * @return 作成した会社 18 | */ 19 | export const createCompanyService = async ( 20 | createCompanyProps: CreateCompanyProps, 21 | ): Promise => { 22 | const id = generateUuidV4(); 23 | const createdAt = getCurrentUnixTimestampMillis(); 24 | 25 | try { 26 | const createdCompany = { id, createdAt, ...createCompanyProps }; 27 | 28 | await putItem(createdCompany); 29 | 30 | return createdCompany; 31 | } catch (e) { 32 | if (e instanceof CompaniesTableAlreadyExistsError) { 33 | throw e; 34 | } 35 | 36 | throw new CompanyServiceUnknownError(e); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/delete-company.test.ts: -------------------------------------------------------------------------------- 1 | import { deleteCompanyService } from "./delete-company"; 2 | import { 3 | CompanyServiceNotExistsError, 4 | CompanyServiceUnknownError, 5 | } from "@/lambda/domains/errors/company-service"; 6 | import { deleteItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 7 | import { 8 | CompaniesTableNotExistsError, 9 | CompaniesTableUnknownError, 10 | } from "@/lambda/infrastructures/errors/companies-table"; 11 | 12 | /** 13 | * すべての vi.mock がファイルの先頭で実行される(最後のモックが優先される)仕様のワークアラウンド 14 | * @see https://zenn.dev/you_5805/articles/vitest-mock-hoisting https://vitest.dev/api/vi#vi-hoisted-0-31-0 15 | */ 16 | const { deleteItemMock } = vi.hoisted(() => { 17 | return { 18 | deleteItemMock: vi.fn(), 19 | }; 20 | }); 21 | vi.mock("@/lambda/infrastructures/dynamodb/companies-table", () => { 22 | return { 23 | deleteItem: deleteItemMock, 24 | }; 25 | }); 26 | 27 | describe("deleteCompanyService", () => { 28 | afterEach(() => { 29 | vi.resetAllMocks(); 30 | }); 31 | 32 | const dummyCompanyId1 = "e3162725-4b5b-4779-bf13-14d55d63a584"; 33 | 34 | describe("実行が正常に行われた場合", () => { 35 | let result: unknown = undefined; 36 | 37 | beforeAll(async () => { 38 | deleteItemMock.mockResolvedValue(undefined); 39 | 40 | await deleteCompanyService(dummyCompanyId1); 41 | }); 42 | 43 | test("deleteItem の呼び出しが期待通り行われること", () => { 44 | expect(deleteItem).toBeCalledTimes(1); 45 | expect(deleteItem).toHaveBeenCalledWith(dummyCompanyId1); 46 | }); 47 | 48 | test("何も返されないこと", () => { 49 | expect(result).toBeUndefined(); 50 | }); 51 | }); 52 | 53 | describe("CompaniesTableNotExistsError をキャッチした場合", () => { 54 | let result: unknown = undefined; 55 | 56 | beforeAll(async () => { 57 | deleteItemMock.mockRejectedValue(CompaniesTableNotExistsError); 58 | 59 | result = deleteCompanyService(dummyCompanyId1); // Promise を作成 60 | }); 61 | 62 | test("CompanyServiceNotExistsError が throw されること", async () => { 63 | // NOTE: 非同期関数の rejects をアサーションする場合は Promise を指定して await で待機する。(待機しない場合は偽陽性となる可能性がある) 64 | // @see https://vitest.dev/api/expect#rejects 65 | await expect(result).rejects.toThrowError( 66 | new CompanyServiceNotExistsError(dummyCompanyId1), 67 | ); 68 | 69 | // getItem の呼び出しが期待通り行われること 70 | // NOTE: Promise を rejects でアサーションする場合は、実行が前後する可能性があるため、呼び出しのアサーションも rejects と同じ test ブロック内で行う必要がある。 71 | expect(deleteItem).toBeCalledTimes(1); 72 | expect(deleteItem).toHaveBeenCalledWith(dummyCompanyId1); 73 | }); 74 | }); 75 | 76 | describe("CompaniesTableUnknownError をキャッチした場合", () => { 77 | let result: unknown = undefined; 78 | 79 | beforeAll(async () => { 80 | deleteItemMock.mockRejectedValue(CompaniesTableUnknownError); 81 | 82 | result = deleteCompanyService(dummyCompanyId1); // Promise を作成 83 | }); 84 | 85 | test("CompanyServiceUnknownError が throw されること", async () => { 86 | // NOTE: 非同期関数の rejects をアサーションする場合は Promise を指定して await で待機する。(待機しない場合は偽陽性となる可能性がある) 87 | // @see https://vitest.dev/api/expect#rejects 88 | await expect(result).rejects.toThrowError(CompanyServiceUnknownError); 89 | 90 | // getItem の呼び出しが期待通り行われること 91 | // NOTE: Promise を rejects でアサーションする場合は、実行が前後する可能性があるため、呼び出しのアサーションも rejects と同じ test ブロック内で行う必要がある。 92 | expect(deleteItem).toBeCalledTimes(1); 93 | expect(deleteItem).toHaveBeenCalledWith(dummyCompanyId1); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/delete-company.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompanyServiceNotExistsError, 3 | CompanyServiceUnknownError, 4 | } from "@/lambda/domains/errors/company-service"; 5 | import { deleteItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 6 | import { CompaniesTableNotExistsError } from "@/lambda/infrastructures/errors/companies-table"; 7 | 8 | /** 9 | * 会社削除サービス 10 | * @param id 会社 ID 11 | * @throws CompanyServiceNotExistsError (指定した ID の会社が存在しない場合) 12 | * @throws CompanyServiceUnknownError (未知のエラーが発生した場合) 13 | * @return なし 14 | */ 15 | export const deleteCompanyService = async (id: string): Promise => { 16 | try { 17 | await deleteItem(id); 18 | } catch (e) { 19 | if (e instanceof CompaniesTableNotExistsError) { 20 | throw new CompanyServiceNotExistsError(id); 21 | } 22 | throw new CompanyServiceUnknownError(e); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/get-company.test.ts: -------------------------------------------------------------------------------- 1 | import { getCompanyService } from "./get-company"; 2 | import { 3 | CompanyServiceNotExistsError, 4 | CompanyServiceUnknownError, 5 | } from "@/lambda/domains/errors/company-service"; 6 | import { getItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 7 | import { CompaniesTableUnknownError } from "@/lambda/infrastructures/errors/companies-table"; 8 | 9 | /** 10 | * すべての vi.mock がファイルの先頭で実行される(最後のモックが優先される)仕様のワークアラウンド 11 | * @see https://zenn.dev/you_5805/articles/vitest-mock-hoisting https://vitest.dev/api/vi#vi-hoisted-0-31-0 12 | */ 13 | const { getItemMock } = vi.hoisted(() => { 14 | return { 15 | getItemMock: vi.fn(), 16 | }; 17 | }); 18 | vi.mock("@/lambda/infrastructures/dynamodb/companies-table", () => { 19 | return { 20 | getItem: getItemMock, 21 | }; 22 | }); 23 | 24 | describe("getCompanyService", () => { 25 | afterEach(() => { 26 | vi.resetAllMocks(); 27 | }); 28 | 29 | const dummyCompanyId1 = "e3162725-4b5b-4779-bf13-14d55d63a584"; 30 | const dummyTimestamp1 = new Date("2024-01-01T00:00:00+09:00").getTime(); 31 | const dummyCompanyItem1 = { 32 | id: dummyCompanyId1, 33 | createdAt: dummyTimestamp1, 34 | name: "dummy-name-1", 35 | }; 36 | 37 | describe("実行が正常に行われた場合", () => { 38 | let result: unknown = undefined; 39 | 40 | beforeAll(async () => { 41 | getItemMock.mockResolvedValue(dummyCompanyItem1); 42 | 43 | result = await getCompanyService(dummyCompanyId1); 44 | }); 45 | 46 | test("getItem の呼び出しが期待通り行われること", () => { 47 | expect(getItem).toBeCalledTimes(1); 48 | expect(getItem).toHaveBeenCalledWith(dummyCompanyId1); 49 | }); 50 | 51 | test("取得された会社データが返されること", () => { 52 | expect(result).toEqual(dummyCompanyItem1); 53 | }); 54 | }); 55 | 56 | describe("CompaniesTableNotExistsError をキャッチした場合", () => { 57 | let result: unknown = undefined; 58 | 59 | beforeAll(async () => { 60 | getItemMock.mockResolvedValue(null); 61 | 62 | result = getCompanyService(dummyCompanyId1); // Promise を作成 63 | }); 64 | 65 | test("CompanyServiceNotExistsError がスローされること", async () => { 66 | // NOTE: 非同期関数の rejects をアサーションする場合は Promise を指定して await で待機する。(待機しない場合は偽陽性となる可能性がある) 67 | // @see https://vitest.dev/api/expect#rejects 68 | await expect(result).rejects.toThrowError( 69 | new CompanyServiceNotExistsError(dummyCompanyId1), 70 | ); 71 | 72 | // getItem の呼び出しが期待通り行われること 73 | // NOTE: Promise を rejects でアサーションする場合は、実行が前後する可能性があるため、呼び出しのアサーションも rejects と同じ test ブロック内で行う必要がある。 74 | expect(getItem).toBeCalledTimes(1); 75 | expect(getItem).toHaveBeenCalledWith(dummyCompanyId1); 76 | }); 77 | }); 78 | 79 | describe("CompaniesTableUnknownError をキャッチした場合", () => { 80 | let result: unknown = undefined; 81 | 82 | beforeAll(async () => { 83 | getItemMock.mockRejectedValue(CompaniesTableUnknownError); 84 | 85 | result = getCompanyService(dummyCompanyId1); // Promise を作成 86 | }); 87 | 88 | test("CompanyServiceUnknownError がスローされること", async () => { 89 | // NOTE: 非同期関数の rejects をアサーションする場合は Promise を指定して await で待機する。(待機しない場合は偽陽性となる可能性がある) 90 | // @see https://vitest.dev/api/expect#rejects 91 | await expect(result).rejects.toThrowError(CompanyServiceUnknownError); 92 | 93 | // getItem の呼び出しが期待通り行われること 94 | // NOTE: Promise を rejects でアサーションする場合は、実行が前後する可能性があるため、呼び出しのアサーションも rejects と同じ test ブロック内で行う必要がある。 95 | expect(getItem).toBeCalledTimes(1); 96 | expect(getItem).toHaveBeenCalledWith(dummyCompanyId1); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/get-company.ts: -------------------------------------------------------------------------------- 1 | import { Company } from "./schemas"; 2 | import { 3 | CompanyServiceNotExistsError, 4 | CompanyServiceUnknownError, 5 | } from "@/lambda/domains/errors/company-service"; 6 | import { getItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 7 | 8 | /** 9 | * 会社取得サービス 10 | * @param id 会社 ID 11 | * @throws CompanyServiceNotExistsError (指定した ID の会社が存在しない場合) 12 | * @throws CompanyServiceUnknownError (未知のエラーが発生した場合) 13 | * @returns 会社 14 | */ 15 | export const getCompanyService = async (id: string): Promise => { 16 | try { 17 | const company = await getItem(id); 18 | 19 | if (company === null) { 20 | throw new CompanyServiceNotExistsError(id); 21 | } 22 | 23 | return company; 24 | } catch (e) { 25 | if (e instanceof CompanyServiceNotExistsError) { 26 | throw e; 27 | } 28 | throw new CompanyServiceUnknownError(e); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/query-companies.test.ts: -------------------------------------------------------------------------------- 1 | import { queryCompaniesService } from "./query-companies"; 2 | import { CompanyServiceUnknownError } from "@/lambda/domains/errors/company-service"; 3 | import { paginateQueryItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 4 | import { CompaniesTableUnknownError } from "@/lambda/infrastructures/errors/companies-table"; 5 | 6 | const { paginateQueryItemMock } = vi.hoisted(() => { 7 | return { 8 | paginateQueryItemMock: vi.fn(), 9 | }; 10 | }); 11 | vi.mock("@/lambda/infrastructures/dynamodb/companies-table", () => { 12 | return { 13 | paginateQueryItem: paginateQueryItemMock, 14 | }; 15 | }); 16 | 17 | describe("queryCompaniesService", () => { 18 | afterEach(() => { 19 | vi.resetAllMocks(); 20 | }); 21 | 22 | const dummyTimestamp1 = new Date("2024-01-01T00:00:00+09:00").getTime(); 23 | const dummyTimestamp2 = new Date("2024-07-07T00:00:00+09:00").getTime(); 24 | const dummyTimestamp3 = new Date("2025-03-31T00:00:00+09:00").getTime(); 25 | 26 | describe("実行が正常に行われた場合", () => { 27 | const dummyCompany1 = { 28 | id: "e3162725-4b5b-4779-bf13-14d55d63a584", 29 | createdAt: dummyTimestamp1, 30 | name: "dummy-name-1", 31 | industry: "IT", 32 | }; 33 | const dummyCompany2 = { 34 | id: "122db705-3791-289e-042c-740bec3add55", 35 | createdAt: dummyTimestamp2, 36 | name: "dummy-name-2", 37 | industry: "Manufacturing", 38 | }; 39 | const dummyCompany3 = { 40 | id: "e3162725-4b5b-4779-bf13-14d55d63a585", 41 | createdAt: dummyTimestamp3, 42 | name: "dummy-name-3", 43 | industry: "Other", 44 | }; 45 | 46 | describe("引数で createdAfter と createdBefore が指定されている場合", () => { 47 | let result: unknown = undefined; 48 | 49 | beforeAll(async () => { 50 | paginateQueryItemMock.mockResolvedValue([ 51 | dummyCompany1, 52 | dummyCompany2, 53 | dummyCompany3, 54 | ]); 55 | 56 | result = await queryCompaniesService( 57 | "IT", 58 | dummyTimestamp1, 59 | dummyTimestamp3, 60 | ); 61 | }); 62 | 63 | test("paginateQueryItem の呼び出しが期待通り行われること", () => { 64 | expect(paginateQueryItem).toBeCalledTimes(1); 65 | expect(paginateQueryItem).toHaveBeenCalledWith( 66 | "IT", 67 | dummyTimestamp1, 68 | dummyTimestamp3, 69 | ); 70 | }); 71 | 72 | test("取得された会社データが返されること", () => { 73 | expect(result).toEqual([dummyCompany1, dummyCompany2, dummyCompany3]); 74 | }); 75 | }); 76 | 77 | describe("引数で maxItems が指定されていない場合", () => { 78 | let result: unknown = undefined; 79 | 80 | beforeAll(async () => { 81 | paginateQueryItemMock.mockResolvedValue([ 82 | dummyCompany1, 83 | dummyCompany2, 84 | dummyCompany3, 85 | ]); 86 | 87 | result = await queryCompaniesService("IT"); 88 | }); 89 | 90 | test("paginateQueryItem の呼び出しが期待通り行われること", () => { 91 | expect(paginateQueryItem).toBeCalledTimes(1); 92 | expect(paginateQueryItem).toHaveBeenCalledWith( 93 | "IT", 94 | undefined, 95 | undefined, 96 | ); 97 | }); 98 | 99 | test("取得された会社データが返されること", () => { 100 | expect(result).toEqual([dummyCompany1, dummyCompany2, dummyCompany3]); 101 | }); 102 | }); 103 | 104 | describe("引数で maxItems が指定されている場合", () => { 105 | describe("取得されたアイテム数が maxItems より大きい場合", () => { 106 | let result: unknown = undefined; 107 | 108 | beforeAll(async () => { 109 | paginateQueryItemMock.mockResolvedValue([ 110 | dummyCompany1, 111 | dummyCompany2, 112 | dummyCompany3, 113 | ]); 114 | 115 | result = await queryCompaniesService("IT", undefined, undefined, 2); 116 | }); 117 | 118 | test("paginateQueryItem の呼び出しが期待通り行われること", () => { 119 | expect(paginateQueryItem).toBeCalledTimes(1); 120 | expect(paginateQueryItem).toHaveBeenCalledWith( 121 | "IT", 122 | undefined, 123 | undefined, 124 | ); 125 | }); 126 | 127 | test("maxItems を最大とした一部の会社データが返されること", () => { 128 | expect(result).toEqual([dummyCompany1, dummyCompany2]); 129 | }); 130 | }); 131 | 132 | describe("取得されたアイテム数が maxItems より小さい場合", () => { 133 | let result: unknown = undefined; 134 | 135 | beforeAll(async () => { 136 | paginateQueryItemMock.mockResolvedValue([dummyCompany1]); 137 | 138 | result = await queryCompaniesService("IT", undefined, undefined, 2); 139 | }); 140 | 141 | test("paginateQueryItem の呼び出しが期待通り行われること", () => { 142 | expect(paginateQueryItem).toBeCalledTimes(1); 143 | expect(paginateQueryItem).toHaveBeenCalledWith( 144 | "IT", 145 | undefined, 146 | undefined, 147 | ); 148 | }); 149 | 150 | test("すべての会社データが返されること", () => { 151 | expect(result).toEqual([dummyCompany1]); 152 | }); 153 | }); 154 | }); 155 | }); 156 | 157 | describe("CompaniesTableUnknownError をキャッチした場合", () => { 158 | let result: unknown = undefined; 159 | 160 | beforeAll(async () => { 161 | paginateQueryItemMock.mockRejectedValue(CompaniesTableUnknownError); 162 | 163 | result = queryCompaniesService("IT"); // Promise を作成 164 | }); 165 | 166 | test("CompanyServiceUnknownError がスローされること", async () => { 167 | await expect(result).rejects.toThrowError(CompanyServiceUnknownError); 168 | 169 | expect(paginateQueryItem).toBeCalledTimes(1); 170 | expect(paginateQueryItem).toHaveBeenCalledWith( 171 | "IT", 172 | undefined, 173 | undefined, 174 | ); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/query-companies.ts: -------------------------------------------------------------------------------- 1 | import { Company, Industry } from "./schemas"; 2 | import { CompanyServiceUnknownError } from "@/lambda/domains/errors/company-service"; 3 | import { paginateQueryItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 4 | 5 | /** 6 | * 会社クエリサービス 7 | * @param industry 業種 8 | * @param createdAfter 作成日時の開始 9 | * @param createdBefore 作成日時の終了 10 | * @param maxItems 最大アイテム数 11 | * @throws CompanyServiceUnknownError (未知のエラーが発生した場合) 12 | * @return 会社一覧 13 | */ 14 | export const queryCompaniesService = async ( 15 | industry: Industry, 16 | createdAfter?: number, 17 | createdBefore?: number, 18 | maxItems?: number, 19 | ): Promise => { 20 | try { 21 | const companies = await paginateQueryItem( 22 | industry, 23 | createdAfter, 24 | createdBefore, 25 | ); 26 | 27 | if (maxItems !== undefined) { 28 | return companies.slice(0, maxItems); 29 | } 30 | 31 | return companies; 32 | } catch (e) { 33 | throw new CompanyServiceUnknownError(e); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/scan-companies.test.ts: -------------------------------------------------------------------------------- 1 | import { scanCompaniesService } from "./scan-companies"; 2 | import { CompanyServiceUnknownError } from "@/lambda/domains/errors/company-service"; 3 | import { paginateScanItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 4 | import { CompaniesTableUnknownError } from "@/lambda/infrastructures/errors/companies-table"; 5 | 6 | /** 7 | * すべての vi.mock がファイルの先頭で実行される(最後のモックが優先される)仕様のワークアラウンド 8 | * @see https://zenn.dev/you_5805/articles/vitest-mock-hoisting https://vitest.dev/api/vi#vi-hoisted-0-31-0 9 | */ 10 | const { paginateScanItemMock } = vi.hoisted(() => { 11 | return { 12 | paginateScanItemMock: vi.fn(), 13 | }; 14 | }); 15 | vi.mock("@/lambda/infrastructures/dynamodb/companies-table", () => { 16 | return { 17 | paginateScanItem: paginateScanItemMock, 18 | }; 19 | }); 20 | 21 | describe("scanCompaniesService", () => { 22 | afterEach(() => { 23 | vi.resetAllMocks(); 24 | }); 25 | 26 | const dummyTimestamp1 = new Date("2024-01-01T00:00:00+09:00").getTime(); 27 | const dummyTimestamp2 = new Date("2024-07-07T00:00:00+09:00").getTime(); 28 | const dummyTimestamp3 = new Date("2025-03-31T00:00:00+09:00").getTime(); 29 | 30 | describe("実行が正常に行われた場合", () => { 31 | const dummyCompany1 = { 32 | id: "e3162725-4b5b-4779-bf13-14d55d63a584", 33 | createdAt: dummyTimestamp1, 34 | name: "dummy-name-1", 35 | }; 36 | const dummyCompany2 = { 37 | id: "122db705-3791-289e-042c-740bec3add55", 38 | createdAt: dummyTimestamp2, 39 | name: "dummy-name-2", 40 | }; 41 | const dummyCompany3 = { 42 | id: "e3162725-4b5b-4779-bf13-14d55d63a585", 43 | createdAt: dummyTimestamp3, 44 | name: "dummy-name-3", 45 | }; 46 | 47 | describe("引数で maxItems が指定されていない場合", () => { 48 | let result: unknown = undefined; 49 | 50 | beforeAll(async () => { 51 | paginateScanItemMock.mockResolvedValue([ 52 | dummyCompany1, 53 | dummyCompany2, 54 | dummyCompany3, 55 | ]); 56 | 57 | result = await scanCompaniesService(); 58 | }); 59 | 60 | test("paginateScanItem の呼び出しが期待通り行われること", () => { 61 | expect(paginateScanItem).toBeCalledTimes(1); 62 | expect(paginateScanItem).toHaveBeenCalledWith(); 63 | }); 64 | 65 | test("取得された会社データが返されること", () => { 66 | expect(result).toEqual([dummyCompany1, dummyCompany2, dummyCompany3]); 67 | }); 68 | }); 69 | 70 | describe("引数で maxItems が指定されている場合", () => { 71 | describe("取得されたアイテム数が maxItems より大きい場合", () => { 72 | let result: unknown = undefined; 73 | 74 | beforeAll(async () => { 75 | paginateScanItemMock.mockResolvedValue([ 76 | dummyCompany1, 77 | dummyCompany2, 78 | dummyCompany3, 79 | ]); 80 | 81 | result = await scanCompaniesService({ maxItems: 2 }); 82 | }); 83 | 84 | test("paginateScanItem の呼び出しが期待通り行われること", () => { 85 | expect(paginateScanItem).toBeCalledTimes(1); 86 | expect(paginateScanItem).toHaveBeenCalledWith(); 87 | }); 88 | 89 | test("maxItems を最大とした一部の会社データが返されること", () => { 90 | expect(result).toEqual([dummyCompany1, dummyCompany2]); 91 | }); 92 | }); 93 | 94 | describe("取得されたアイテム数が maxItems より小さい場合", () => { 95 | let result: unknown = undefined; 96 | 97 | beforeAll(async () => { 98 | paginateScanItemMock.mockResolvedValue([dummyCompany1]); 99 | 100 | result = await scanCompaniesService({ maxItems: 2 }); 101 | }); 102 | 103 | test("paginateScanItem の呼び出しが期待通り行われること", () => { 104 | expect(paginateScanItem).toBeCalledTimes(1); 105 | expect(paginateScanItem).toHaveBeenCalledWith(); 106 | }); 107 | 108 | test("すべての会社データが返されること", () => { 109 | expect(result).toEqual([dummyCompany1]); 110 | }); 111 | }); 112 | }); 113 | }); 114 | 115 | describe("CompaniesTableUnknownError をキャッチした場合", () => { 116 | let result: unknown = undefined; 117 | 118 | beforeAll(async () => { 119 | paginateScanItemMock.mockRejectedValue(CompaniesTableUnknownError); 120 | 121 | result = scanCompaniesService(); // Promise を作成 122 | }); 123 | 124 | test("CompanyServiceUnknownError がスローされること", async () => { 125 | // NOTE: 非同期関数の rejects をアサーションする場合は Promise を指定して await で待機する。(待機しない場合は偽陽性となる可能性がある) 126 | // @see https://vitest.dev/api/expect#rejects 127 | await expect(result).rejects.toThrowError(CompanyServiceUnknownError); 128 | 129 | // paginateScanItem の呼び出しが期待通り行われること 130 | // NOTE: Promise を rejects でアサーションする場合は、実行が前後する可能性があるため、呼び出しのアサーションも rejects と同じ test ブロック内で行う必要がある。 131 | expect(paginateScanItem).toBeCalledTimes(1); 132 | expect(paginateScanItem).toHaveBeenCalledWith(); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/scan-companies.ts: -------------------------------------------------------------------------------- 1 | import { Company } from "./schemas"; 2 | import { CompanyServiceUnknownError } from "@/lambda/domains/errors/company-service"; 3 | import { paginateScanItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 4 | 5 | interface ScanCompaniesParams { 6 | maxItems?: number; 7 | } 8 | 9 | /** 10 | * 会社スキャンサービス 11 | * @param params 12 | * @returns 会社一覧 13 | */ 14 | export const scanCompaniesService = async ( 15 | params?: ScanCompaniesParams, 16 | ): Promise => { 17 | try { 18 | const companies = await paginateScanItem(); 19 | 20 | // NOTE: 必要に応じてソート処理を追加する 21 | 22 | if (params !== undefined && params.maxItems !== undefined) { 23 | return companies.slice(0, params.maxItems); 24 | } 25 | 26 | return companies; 27 | } catch (e) { 28 | throw new CompanyServiceUnknownError(e); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /packages/server/src/lambda/domains/services/schemas.ts: -------------------------------------------------------------------------------- 1 | export type Industry = "IT" | "Manufacturing" | "Finance" | "Medical" | "Other"; 2 | 3 | export interface Company { 4 | id: string; 5 | createdAt: number; 6 | name: string; 7 | industry?: Industry; 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/companies-get.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import * as zod from "zod"; 3 | 4 | import { queryCompaniesService } from "@/lambda/domains/services/query-companies"; 5 | import { scanCompaniesService } from "@/lambda/domains/services/scan-companies"; 6 | import * as HttpUtil from "@/utils/http-response"; 7 | import { logger } from "@/utils/logger"; 8 | 9 | /** 10 | * GET /companies のクエリパラメータの zod スキーマ 11 | */ 12 | const queryScheme = zod 13 | .object({ 14 | industry: zod.union([ 15 | zod.literal("IT"), 16 | zod.literal("Manufacturing"), 17 | zod.literal("Finance"), 18 | zod.literal("Medical"), 19 | zod.literal("Other"), 20 | zod.undefined(), 21 | ]), 22 | created_after: zod 23 | .union([zod.string(), zod.undefined()]) 24 | .transform((val) => (val === undefined ? undefined : Number(val))) 25 | .refine((num) => num === undefined || num > 0, { 26 | message: "正の数値を指定してください", 27 | }) 28 | .refine((num) => num === undefined || num.toString().length === 13, { 29 | message: "13桁の整数(エポックミリ秒)を指定してください", 30 | }), 31 | created_before: zod 32 | .union([zod.string(), zod.undefined()]) 33 | .transform((val) => (val === undefined ? undefined : Number(val))) 34 | .refine((num) => num === undefined || num > 0, { 35 | message: "正の数値を指定してください", 36 | }) 37 | .refine((num) => num === undefined || num.toString().length === 13, { 38 | message: "13桁の整数(エポックミリ秒)を指定してください", 39 | }), 40 | max_items: zod 41 | .union([zod.string(), zod.undefined()]) 42 | .default("3") 43 | .refine((val) => val === undefined || /^(?!0)\d+$/.test(val), { 44 | message: "正の整数の文字列を指定してください", 45 | }) 46 | .transform((val) => (val === undefined ? undefined : Number(val))) 47 | .refine((num) => num === undefined || num <= 5, { 48 | message: "1以上5以下の数値を指定してください", 49 | }), 50 | }) 51 | .refine( 52 | (data) => { 53 | if ((data.created_after || data.created_before) && !data.industry) { 54 | return false; 55 | } 56 | return true; 57 | }, 58 | { 59 | message: 60 | "created_after または created_before を指定している場合は、industry を指定してください", 61 | }, 62 | ) 63 | .refine( 64 | (data) => { 65 | if ( 66 | data.created_after && 67 | data.created_before && 68 | data.created_after >= data.created_before 69 | ) { 70 | return false; 71 | } 72 | return true; 73 | }, 74 | { 75 | message: 76 | "created_after および created_before の両方を指定している場合は、created_after が created_before より小さくなるようにしてください", 77 | }, 78 | ); 79 | 80 | /** 81 | * API パスパラメーター /companies の GET メソッドに対応するハンドラー関数 82 | * @param event イベント 83 | * @return 正常系:取得した会社の配列を本文に含む HTTP 200 OK レスポンス 84 | * @return 異常系:HTTP 400 BadRequest レスポンス (リクエストパラメータが不正な場合) 85 | * @return 異常系:HTTP 500 InternalServerError レスポンス (サービス内部エラーが発生した場合) 86 | */ 87 | export const companiesGetHandler = async ( 88 | event: Request, 89 | ): Promise => { 90 | logger.info("event", { event }); 91 | 92 | try { 93 | const query = queryScheme.safeParse(event.query); 94 | 95 | if (!query.success) { 96 | logger.info("Validation error.", query.error); 97 | 98 | return HttpUtil.badRequest(); 99 | } 100 | 101 | const { industry, created_after, created_before, max_items } = query.data; 102 | 103 | let companies; 104 | 105 | if (industry) { 106 | companies = await queryCompaniesService( 107 | industry, 108 | created_after, 109 | created_before, 110 | max_items, 111 | ); 112 | } else { 113 | companies = await scanCompaniesService({ maxItems: max_items }); // TODO: オブジェクト引数ではなく、queryCompaniesService と同様に位置引数で渡すように修正。引数の情報は JSDoc で補完する 114 | } 115 | 116 | return HttpUtil.ok(JSON.stringify(companies), { 117 | "Content-Type": "application/json", 118 | }); 119 | } catch (e) { 120 | logger.error("error", e as Error); 121 | 122 | return HttpUtil.internalServerError(); 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/companies-id-delete.test.ts: -------------------------------------------------------------------------------- 1 | import { mockReq } from "sinon-express-mock"; 2 | import { companiesIdDeleteHandler } from "./companies-id-delete"; 3 | import { 4 | CompanyServiceNotExistsError, 5 | CompanyServiceUnknownError, 6 | } from "@/lambda/domains/errors/company-service"; 7 | import { deleteCompanyService } from "@/lambda/domains/services/delete-company"; 8 | 9 | /** 10 | * すべての vi.mock がファイルの先頭で実行される(最後のモックが優先される)仕様のワークアラウンド 11 | * @see https://zenn.dev/you_5805/articles/vitest-mock-hoisting https://vitest.dev/api/vi#vi-hoisted-0-31-0 12 | */ 13 | const { deleteCompanyServiceMock } = vi.hoisted(() => { 14 | return { 15 | deleteCompanyServiceMock: vi.fn(), 16 | }; 17 | }); 18 | vi.mock("@/lambda/domains/services/delete-company", () => { 19 | return { 20 | deleteCompanyService: deleteCompanyServiceMock, 21 | }; 22 | }); 23 | 24 | describe("companiesIdDeleteHandler", () => { 25 | afterEach(() => { 26 | vi.resetAllMocks(); 27 | }); 28 | 29 | const dummyCompanyId1 = "e3162725-4b5b-4779-bf13-14d55d63a584"; 30 | 31 | describe("正常な ID がパスパラメーターで渡された場合", () => { 32 | describe("実行が正常に行われた場合", () => { 33 | let result: unknown = undefined; 34 | 35 | beforeAll(async () => { 36 | deleteCompanyServiceMock.mockResolvedValue(void 0); 37 | 38 | result = await companiesIdDeleteHandler( 39 | mockReq({ 40 | params: { id: dummyCompanyId1 }, 41 | }), 42 | ); 43 | }); 44 | 45 | test("deleteCompanyService の呼び出しが期待通り行われること", () => { 46 | expect(deleteCompanyService).toBeCalledTimes(1); 47 | expect(deleteCompanyService).toHaveBeenCalledWith(dummyCompanyId1); 48 | }); 49 | 50 | test("ステータスコード 204 が返ること", () => { 51 | expect(result).toEqual({ 52 | statusCode: 204, 53 | headers: { 54 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 55 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 56 | "Access-Control-Allow-Origin": "*", 57 | }, 58 | }); 59 | }); 60 | }); 61 | 62 | describe("CompanyServiceNotExistsError をキャッチした場合", () => { 63 | let result: unknown = undefined; 64 | 65 | beforeAll(async () => { 66 | deleteCompanyServiceMock.mockRejectedValue( 67 | new CompanyServiceNotExistsError(dummyCompanyId1), 68 | ); 69 | 70 | result = await companiesIdDeleteHandler( 71 | mockReq({ 72 | params: { id: dummyCompanyId1 }, 73 | }), 74 | ); // Promise を作成 75 | }); 76 | 77 | test("deleteCompanyService の呼び出しが期待通り行われること", () => { 78 | expect(deleteCompanyService).toBeCalledTimes(1); 79 | expect(deleteCompanyService).toHaveBeenCalledWith(dummyCompanyId1); 80 | }); 81 | 82 | test("ステータスコード 404 が返ること", () => { 83 | expect(result).toEqual({ 84 | statusCode: 404, 85 | headers: { 86 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 87 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 88 | "Access-Control-Allow-Origin": "*", 89 | }, 90 | }); 91 | }); 92 | }); 93 | 94 | describe("CompanyServiceUnknownError をキャッチした場合", () => { 95 | let result: unknown = undefined; 96 | 97 | beforeAll(async () => { 98 | deleteCompanyServiceMock.mockRejectedValue(CompanyServiceUnknownError); 99 | 100 | result = await companiesIdDeleteHandler( 101 | mockReq({ 102 | params: { id: dummyCompanyId1 }, 103 | }), 104 | ); // Promise を作成 105 | }); 106 | 107 | test("deleteCompanyService の呼び出しが期待通り行われること", () => { 108 | expect(deleteCompanyService).toBeCalledTimes(1); 109 | expect(deleteCompanyService).toHaveBeenCalledWith(dummyCompanyId1); 110 | }); 111 | 112 | test("ステータスコード 500 が返ること", () => { 113 | expect(result).toEqual({ 114 | statusCode: 500, 115 | headers: { 116 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 117 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 118 | "Access-Control-Allow-Origin": "*", 119 | }, 120 | }); 121 | }); 122 | }); 123 | }); 124 | 125 | describe("不正な ID がパスパラメーターで渡された場合", () => { 126 | let result: unknown = undefined; 127 | const invalidFormatId = "invalid_id"; 128 | 129 | beforeAll(async () => { 130 | result = await companiesIdDeleteHandler( 131 | mockReq({ 132 | params: { id: invalidFormatId }, 133 | }), 134 | ); 135 | }); 136 | 137 | test("deleteCompanyService が呼び出されないこと", () => { 138 | expect(deleteCompanyService).toBeCalledTimes(0); 139 | }); 140 | 141 | test("ステータスコード 400 が返ること", () => { 142 | expect(result).toEqual({ 143 | statusCode: 400, 144 | headers: { 145 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 146 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 147 | "Access-Control-Allow-Origin": "*", 148 | }, 149 | }); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/companies-id-delete.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import * as zod from "zod"; 3 | 4 | import { CompanyServiceNotExistsError } from "@/lambda/domains/errors/company-service"; 5 | import { deleteCompanyService } from "@/lambda/domains/services/delete-company"; 6 | import * as HttpUtil from "@/utils/http-response"; 7 | import { logger } from "@/utils/logger"; 8 | 9 | /** 10 | * DELETE /companies/{id} のパスパラメータの zod スキーマ 11 | */ 12 | const pathScheme = zod.object({ 13 | id: zod.string().uuid(), 14 | }); 15 | 16 | /** 17 | * API パスパラメーター /companies/{id} の DELETE メソッドに対応するハンドラー関数 18 | * @param event イベント 19 | * @return 正常系:HTTP 204 NoContent レスポンス 20 | * @return 異常系:HTTP 400 BadRequest レスポンス (リクエストパラメータが不正な場合) 21 | * @return 異常系:HTTP 404 NotFound レスポンス (指定した ID の会社が存在しない場合) 22 | * @return 異常系:HTTP 500 InternalServerError レスポンス (サービス内部エラーが発生した場合) 23 | */ 24 | export const companiesIdDeleteHandler = async ( 25 | event: Request, 26 | ): Promise => { 27 | logger.info("event", { event }); 28 | 29 | try { 30 | const path = pathScheme.safeParse(event.params); 31 | 32 | if (!path.success) { 33 | logger.info("Validation error.", path.error); 34 | 35 | return HttpUtil.badRequest(); 36 | } 37 | 38 | const { id } = path.data; 39 | 40 | await deleteCompanyService(id); 41 | 42 | return HttpUtil.noContent(); 43 | } catch (e) { 44 | if (e instanceof CompanyServiceNotExistsError) { 45 | logger.info("Not exists error."); 46 | 47 | return HttpUtil.notFound(); 48 | } 49 | logger.error("error", e as Error); 50 | 51 | return HttpUtil.internalServerError(); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/companies-id-get.test.ts: -------------------------------------------------------------------------------- 1 | import { mockReq } from "sinon-express-mock"; 2 | import { companiesIdGetHandler } from "./companies-id-get"; 3 | import { 4 | CompanyServiceNotExistsError, 5 | CompanyServiceUnknownError, 6 | } from "@/lambda/domains/errors/company-service"; 7 | import { getCompanyService } from "@/lambda/domains/services/get-company"; 8 | 9 | const { getCompanyServiceMock } = vi.hoisted(() => { 10 | return { 11 | getCompanyServiceMock: vi.fn(), 12 | }; 13 | }); 14 | vi.mock("@/lambda/domains/services/get-company", () => { 15 | return { 16 | getCompanyService: getCompanyServiceMock, 17 | }; 18 | }); 19 | 20 | describe("companiesIdGetHandler", () => { 21 | afterEach(() => { 22 | vi.resetAllMocks(); 23 | }); 24 | 25 | const dummyCompanyId1 = "e3162725-4b5b-4779-bf13-14d55d63a584"; 26 | const dummyTimestamp1 = new Date("2024-01-01T00:00:00+09:00").getTime(); 27 | 28 | const dummyCompanyItem1 = { 29 | id: dummyCompanyId1, 30 | createdAt: dummyTimestamp1, 31 | name: "dummy-name-1", 32 | }; 33 | 34 | describe("正常な ID がパスパラメーターで渡された場合", () => { 35 | describe("実行が正常に行われた場合", () => { 36 | let result: unknown = undefined; 37 | 38 | beforeAll(async () => { 39 | getCompanyServiceMock.mockResolvedValue(dummyCompanyItem1); 40 | 41 | result = await companiesIdGetHandler( 42 | mockReq({ 43 | params: { id: dummyCompanyId1 }, 44 | }), 45 | ); 46 | }); 47 | 48 | test("getCompanyService の呼び出しが期待通り行われること", () => { 49 | expect(getCompanyService).toBeCalledTimes(1); 50 | expect(getCompanyService).toHaveBeenCalledWith(dummyCompanyId1); 51 | }); 52 | 53 | test("ステータスコード 200 となり、データが返ること", () => { 54 | expect(result).toEqual({ 55 | statusCode: 200, 56 | body: JSON.stringify(dummyCompanyItem1), 57 | headers: { 58 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 59 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 60 | "Access-Control-Allow-Origin": "*", 61 | "Content-Type": "application/json", 62 | }, 63 | }); 64 | }); 65 | }); 66 | 67 | describe("CompanyServiceNotExistsError をキャッチした場合", () => { 68 | let result: unknown = undefined; 69 | 70 | beforeAll(async () => { 71 | getCompanyServiceMock.mockRejectedValue( 72 | new CompanyServiceNotExistsError(dummyCompanyId1), 73 | ); 74 | 75 | result = await companiesIdGetHandler( 76 | mockReq({ 77 | params: { id: dummyCompanyId1 }, 78 | }), 79 | ); 80 | }); 81 | 82 | test("getCompanyService の呼び出しが期待通り行われること", () => { 83 | expect(getCompanyService).toBeCalledTimes(1); 84 | expect(getCompanyService).toHaveBeenCalledWith(dummyCompanyId1); 85 | }); 86 | 87 | test("ステータスコード 404 が返ること", () => { 88 | expect(result).toEqual({ 89 | statusCode: 404, 90 | headers: { 91 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 92 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 93 | "Access-Control-Allow-Origin": "*", 94 | }, 95 | }); 96 | }); 97 | }); 98 | 99 | describe("CompanyServiceUnknownError をキャッチした場合", () => { 100 | let result: unknown = undefined; 101 | 102 | beforeAll(async () => { 103 | getCompanyServiceMock.mockRejectedValue(CompanyServiceUnknownError); 104 | 105 | result = await companiesIdGetHandler( 106 | mockReq({ 107 | params: { id: dummyCompanyId1 }, 108 | }), 109 | ); 110 | }); 111 | 112 | test("getCompanyService の呼び出しが期待通り行われること", () => { 113 | expect(getCompanyService).toBeCalledTimes(1); 114 | expect(getCompanyService).toHaveBeenCalledWith(dummyCompanyId1); 115 | }); 116 | 117 | test("ステータスコード 500 が返ること", () => { 118 | expect(result).toEqual({ 119 | statusCode: 500, 120 | headers: { 121 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 122 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 123 | "Access-Control-Allow-Origin": "*", 124 | }, 125 | }); 126 | }); 127 | }); 128 | }); 129 | 130 | describe("不正な ID がパスパラメーターで渡された場合", () => { 131 | let result: unknown = undefined; 132 | 133 | beforeAll(async () => { 134 | result = await companiesIdGetHandler( 135 | mockReq({ 136 | params: { id: "invalid_id" }, 137 | }), 138 | ); 139 | }); 140 | 141 | test("ステータスコード 400 が返ること", () => { 142 | expect(getCompanyService).toBeCalledTimes(0); 143 | 144 | expect(result).toEqual({ 145 | statusCode: 400, 146 | headers: { 147 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 148 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 149 | "Access-Control-Allow-Origin": "*", 150 | }, 151 | }); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/companies-id-get.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import * as zod from "zod"; 3 | 4 | import { CompanyServiceNotExistsError } from "@/lambda/domains/errors/company-service"; 5 | import { getCompanyService } from "@/lambda/domains/services/get-company"; 6 | import * as HttpUtil from "@/utils/http-response"; 7 | import { logger } from "@/utils/logger"; 8 | 9 | /** 10 | * GET /companies/{id} のパスパラメータの zod スキーマ 11 | */ 12 | const pathScheme = zod.object({ 13 | id: zod.string().uuid(), 14 | }); 15 | 16 | /** 17 | * API パスパラメーター /companies/{id} の GET メソッドに対応するハンドラー関数 18 | * @param event イベント 19 | * @return 正常系:作成した会社を本文に含む HTTP 200 OK レスポンス 20 | * @return 異常系:HTTP 400 BadRequest レスポンス (リクエストパラメータが不正な場合) 21 | * @return 異常系:HTTP 404 NotFound レスポンス (指定した ID の会社が存在しない場合) 22 | * @return 異常系:HTTP 500 InternalServerError レスポンス (サービス内部エラーが発生した場合) 23 | */ 24 | export const companiesIdGetHandler = async ( 25 | event: Request, 26 | ): Promise => { 27 | logger.info("event", { event }); 28 | 29 | try { 30 | const path = pathScheme.safeParse(event.params); 31 | 32 | if (!path.success) { 33 | logger.info("Validation error.", path.error); 34 | 35 | return HttpUtil.badRequest(); 36 | } 37 | 38 | const { id } = path.data; 39 | 40 | const company = await getCompanyService(id); 41 | 42 | return HttpUtil.ok(JSON.stringify(company), { 43 | "Content-Type": "application/json", 44 | }); 45 | } catch (e) { 46 | if (e instanceof CompanyServiceNotExistsError) { 47 | logger.info("Not exists error."); 48 | 49 | return HttpUtil.notFound(); 50 | } 51 | 52 | logger.error("error", e as Error); 53 | 54 | return HttpUtil.internalServerError(); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/companies-post.test.ts: -------------------------------------------------------------------------------- 1 | import { mockReq } from "sinon-express-mock"; 2 | import { companiesIdPostHandler } from "./companies-post"; 3 | import { CompanyServiceUnknownError } from "@/lambda/domains/errors/company-service"; 4 | import { createCompanyService } from "@/lambda/domains/services/create-company"; 5 | import { CompaniesTableAlreadyExistsError } from "@/lambda/infrastructures/errors/companies-table"; 6 | 7 | const { createCompanyServiceMock } = vi.hoisted(() => { 8 | return { 9 | createCompanyServiceMock: vi.fn(), 10 | }; 11 | }); 12 | vi.mock("@/lambda/domains/services/create-company", () => { 13 | return { 14 | createCompanyService: createCompanyServiceMock, 15 | }; 16 | }); 17 | 18 | describe("companiesIdPostHandler", () => { 19 | afterEach(() => { 20 | vi.resetAllMocks(); 21 | }); 22 | 23 | const dummyCompanyId1 = "e3162725-4b5b-4779-bf13-14d55d63a584"; 24 | const dummyTimestamp1 = new Date("2024-01-01T00:00:00+09:00").getTime(); 25 | const dummyCompanyName1 = "dummy-name-1"; 26 | 27 | const dummyCompanyItem1 = { 28 | id: dummyCompanyId1, 29 | createdAt: dummyTimestamp1, 30 | name: dummyCompanyName1, 31 | }; 32 | 33 | describe("正常な Event データが渡された場合", () => { 34 | describe("実行が正常に行われた場合", () => { 35 | let result: unknown = undefined; 36 | 37 | beforeAll(async () => { 38 | createCompanyServiceMock.mockResolvedValue(dummyCompanyItem1); 39 | 40 | result = await companiesIdPostHandler( 41 | mockReq({ 42 | body: { name: dummyCompanyName1 }, 43 | }), 44 | ); 45 | }); 46 | 47 | test("createCompanyService の呼び出しが期待通り行われること", () => { 48 | expect(createCompanyService).toBeCalledTimes(1); 49 | expect(createCompanyService).toHaveBeenCalledWith({ 50 | name: dummyCompanyName1, 51 | }); 52 | }); 53 | 54 | test("ステータスコード 201 となり、作成されたデータが返ること", () => { 55 | expect(result).toEqual({ 56 | statusCode: 201, 57 | body: JSON.stringify(dummyCompanyItem1), 58 | headers: { 59 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 60 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 61 | "Access-Control-Allow-Origin": "*", 62 | "Content-Type": "application/json", 63 | Location: `/companies/${dummyCompanyId1}`, 64 | }, 65 | }); 66 | }); 67 | }); 68 | 69 | describe("CompaniesTableAlreadyExistsError をキャッチした場合", () => { 70 | let result: unknown = undefined; 71 | 72 | beforeAll(async () => { 73 | createCompanyServiceMock.mockRejectedValue( 74 | new CompaniesTableAlreadyExistsError( 75 | "dummy-tableName", 76 | dummyCompanyItem1, 77 | ), 78 | ); 79 | 80 | result = await companiesIdPostHandler( 81 | mockReq({ 82 | body: { name: dummyCompanyName1 }, 83 | }), 84 | ); 85 | }); 86 | 87 | test("createCompanyService の呼び出しが期待通り行われること", () => { 88 | expect(createCompanyService).toBeCalledTimes(1); 89 | expect(createCompanyService).toHaveBeenCalledWith({ 90 | name: dummyCompanyName1, 91 | }); 92 | }); 93 | 94 | test("ステータスコード 409 が返ること", () => { 95 | expect(result).toEqual({ 96 | statusCode: 409, 97 | headers: { 98 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 99 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 100 | "Access-Control-Allow-Origin": "*", 101 | }, 102 | }); 103 | }); 104 | }); 105 | 106 | describe("CompanyServiceUnknownError をキャッチした場合", () => { 107 | let result: unknown = undefined; 108 | 109 | beforeAll(async () => { 110 | createCompanyServiceMock.mockRejectedValue(CompanyServiceUnknownError); 111 | 112 | result = await companiesIdPostHandler( 113 | mockReq({ 114 | body: { name: dummyCompanyName1 }, 115 | }), 116 | ); 117 | }); 118 | 119 | test("createCompanyService の呼び出しが期待通り行われること", () => { 120 | expect(createCompanyService).toBeCalledTimes(1); 121 | expect(createCompanyService).toHaveBeenCalledWith({ 122 | name: dummyCompanyName1, 123 | }); 124 | }); 125 | 126 | test("ステータスコード 500 が返ること", () => { 127 | expect(result).toEqual({ 128 | statusCode: 500, 129 | headers: { 130 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 131 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 132 | "Access-Control-Allow-Origin": "*", 133 | }, 134 | }); 135 | }); 136 | }); 137 | }); 138 | 139 | describe("Event データの body プロパティが undefined の場合", () => { 140 | test("ステータスコード 400 が返ること", async () => { 141 | const result = await companiesIdPostHandler(mockReq({})); 142 | 143 | expect(createCompanyService).toBeCalledTimes(0); 144 | 145 | expect(result).toEqual({ 146 | statusCode: 400, 147 | headers: { 148 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 149 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 150 | "Access-Control-Allow-Origin": "*", 151 | }, 152 | }); 153 | }); 154 | }); 155 | 156 | describe("Event データの body プロパティが null の場合", () => { 157 | test("ステータスコード 400 が返ること", async () => { 158 | const result = await companiesIdPostHandler( 159 | mockReq({ 160 | body: null, 161 | }), 162 | ); 163 | 164 | expect(createCompanyService).toBeCalledTimes(0); 165 | 166 | expect(result).toEqual({ 167 | statusCode: 400, 168 | headers: { 169 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 170 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 171 | "Access-Control-Allow-Origin": "*", 172 | }, 173 | }); 174 | }); 175 | }); 176 | 177 | describe("Event データの body プロパティがスキーマ不正の場合", () => { 178 | test("ステータスコード 400 が返ること", async () => { 179 | const result = await companiesIdPostHandler( 180 | mockReq({ 181 | body: JSON.stringify({}), 182 | }), 183 | ); 184 | 185 | expect(createCompanyService).toBeCalledTimes(0); 186 | 187 | expect(result).toEqual({ 188 | statusCode: 400, 189 | headers: { 190 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 191 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 192 | "Access-Control-Allow-Origin": "*", 193 | }, 194 | }); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/companies-post.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import * as zod from "zod"; 3 | 4 | import { createCompanyService } from "@/lambda/domains/services/create-company"; 5 | import { CompaniesTableAlreadyExistsError } from "@/lambda/infrastructures/errors/companies-table"; 6 | import * as HttpUtil from "@/utils/http-response"; 7 | import { logger } from "@/utils/logger"; 8 | 9 | /** 10 | * POST /companies のリクエストボディの zod スキーマ 11 | */ 12 | const eventBodySchema = zod.object({ 13 | name: zod.string(), 14 | }); 15 | 16 | /** 17 | * API パスパラメーター /companies の POST メソッドに対応するハンドラー関数 18 | * @param event イベント 19 | * @return 正常系:作成した会社の Location をヘッダーにを含む HTTP 201 Created レスポンス 20 | * @return 異常系:HTTP 400 BadRequest レスポンス (リクエストパラメータが不正な場合) 21 | * @return 異常系:HTTP 409 Conflict レスポンス (作成を試みた ID の会社が既に存在する場合) 22 | * @return 異常系:HTTP 500 InternalServerError レスポンス (サービス内部エラーが発生した場合) 23 | */ 24 | export const companiesIdPostHandler = async ( 25 | event: Request, 26 | ): Promise => { 27 | logger.info("event", { event }); 28 | 29 | try { 30 | if (!event.body) { 31 | logger.info("Validation error.", { body: event.body }); 32 | 33 | return HttpUtil.badRequest(); 34 | } 35 | 36 | const body = eventBodySchema.safeParse(event.body); 37 | 38 | if (!body.success) { 39 | logger.info("Validation error.", body.error); 40 | 41 | return HttpUtil.badRequest(); 42 | } 43 | 44 | const { name } = body.data; 45 | 46 | const createdCompany = await createCompanyService({ name: name }); 47 | 48 | return HttpUtil.created(JSON.stringify(createdCompany), { 49 | Location: `/companies/${createdCompany.id}`, 50 | "Content-Type": "application/json", 51 | }); 52 | } catch (e) { 53 | logger.error("error", e as Error); 54 | 55 | if (e instanceof CompaniesTableAlreadyExistsError) { 56 | return HttpUtil.conflict(); 57 | } 58 | 59 | return HttpUtil.internalServerError(); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /packages/server/src/lambda/handlers/api-gateway/rest-api/router.ts: -------------------------------------------------------------------------------- 1 | import { injectLambdaContext } from "@aws-lambda-powertools/logger"; 2 | import serverlessExpress from "@codegenie/serverless-express"; 3 | import middy from "@middy/core"; 4 | import cors from "cors"; 5 | import express, { Request, Response } from "express"; 6 | 7 | import { companiesGetHandler } from "./companies-get"; 8 | import { companiesIdDeleteHandler } from "./companies-id-delete"; 9 | import { companiesIdGetHandler } from "./companies-id-get"; 10 | import { companiesIdPostHandler } from "./companies-post"; 11 | import { logger } from "@/utils/logger"; 12 | 13 | /** 14 | * ICASU_NOTE: ルーティング先の Lambda handler の命名を `<パス>-<メソッド名>` という形式にすることで、 15 | * どの handler がどのルーティングに対応しているのかが一目でわかるようになります。 16 | */ 17 | 18 | const app = express(); 19 | app.use(cors()); 20 | app.use(express.json()); 21 | 22 | /** 23 | * API パスパラメーター /companies で POST メソッドを受け付けるルーティング 24 | * @param req リクエスト 25 | * @param res レスポンス 26 | */ 27 | app.post("/companies", async (req: Request, res: Response): Promise => { 28 | const response = await companiesIdPostHandler(req); 29 | 30 | res.header(response.headers); 31 | res.status(response.statusCode).send(response.body); 32 | }); 33 | 34 | /** 35 | * API パスパラメーター /companies/{id} で GET メソッドを受け付けるルーティング 36 | * @param req リクエスト 37 | * @param res レスポンス 38 | */ 39 | app.get( 40 | "/companies/:id", 41 | async (req: Request, res: Response): Promise => { 42 | const response = await companiesIdGetHandler(req); 43 | 44 | res.header(response.headers); 45 | res.status(response.statusCode).send(response.body); 46 | }, 47 | ); 48 | 49 | /** 50 | * API パスパラメーター /companies/{id} で DELETE メソッドを受け付けるルーティング 51 | * @param req リクエスト 52 | * @param res レスポンス 53 | */ 54 | app.delete( 55 | "/companies/:id", 56 | async (req: Request, res: Response): Promise => { 57 | const response = await companiesIdDeleteHandler(req); 58 | 59 | res.header(response.headers); 60 | res.status(response.statusCode).send(response.body); 61 | }, 62 | ); 63 | 64 | /** 65 | * API パスパラメーター /companies で GET メソッドを受け付けるルーティング 66 | * @param req リクエスト 67 | * @param res レスポンス 68 | */ 69 | app.get("/companies", async (req: Request, res: Response): Promise => { 70 | const response = await companiesGetHandler(req); 71 | 72 | res.header(response.headers); 73 | res.status(response.statusCode).send(response.body); 74 | }); 75 | 76 | /** 77 | * logger の出力に Lambda の context を追加するためのラップ処理 78 | * @see https://aws.amazon.com/jp/blogs/news/simplifying-serverless-best-practices-with-aws-lambda-powertools-for-typescript/ 79 | */ 80 | export const handler = middy(serverlessExpress({ app })).use( 81 | injectLambdaContext(logger), 82 | ); 83 | -------------------------------------------------------------------------------- /packages/server/src/lambda/infrastructures/dynamodb/client.ts: -------------------------------------------------------------------------------- 1 | // ランタイム内でインスタンスが再利用できるように別モジュール化している 2 | import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; 3 | import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"; 4 | import { NodeHttpHandler } from "@smithy/node-http-handler"; 5 | import { captureAWSv3Client } from "aws-xray-sdk"; 6 | 7 | const region = process.env.AWS_REGION || "ap-northeast-1"; 8 | const apiVersion = "2012-08-10"; 9 | 10 | /** 11 | * DynamoDB クライアントの初期化 12 | */ 13 | const dynamodbClient = captureAWSv3Client( 14 | new DynamoDBClient({ 15 | region: region, 16 | apiVersion: apiVersion, 17 | requestHandler: new NodeHttpHandler({ 18 | /** 19 | * DynamoDB クライアントの場合のチューニング例 20 | * @see https://aws.amazon.com/jp/blogs/news/tuning-aws-java-sdk-http-request-settings-for-latency-aware-amazon-dynamodb-applications/ 21 | */ 22 | connectionTimeout: 200, // 初回の TCP 接続(SYN SENT)の試行に十分な値を設定。2回目の TCP Retransmission を待たずにタイムアウトさせる 23 | requestTimeout: 1000, // 1 回あたりの API オペレーションの最大取得サイズ 1 MB の取得に十分な値を設定 24 | }), 25 | }), 26 | ); 27 | 28 | /** 29 | * DynamoDB クライアントをラップした DocumentClient の初期化 30 | */ 31 | export const dynamoDBDocument = DynamoDBDocument.from(dynamodbClient); 32 | -------------------------------------------------------------------------------- /packages/server/src/lambda/infrastructures/dynamodb/companies-table.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MEMO: 3 | * aws-sdk-client-mock で @aws-sdk/lib-dynamodb の v3.576.0 以降をモックすると 4 | * タイプエラーが発生するため、any によるアサーションを行っている。 5 | * TODO: 6 | * Infrastructure Layer のテストは DynamoDB Local などのフェイクサービスを使用するように変更する。 7 | */ 8 | 9 | import { 10 | DynamoDBDocumentClient, 11 | GetCommand, 12 | PutCommand, 13 | DeleteCommand, 14 | ScanCommand, 15 | QueryCommand, 16 | } from "@aws-sdk/lib-dynamodb"; 17 | import { mockClient } from "aws-sdk-client-mock"; 18 | 19 | import { convertIso8601StringToUnixTimestampMillis } from "../../../utils/datetime"; 20 | import { 21 | putItem, 22 | getItem, 23 | deleteItem, 24 | paginateScanItem, 25 | paginateQueryItem, 26 | Industry, 27 | } from "./companies-table"; 28 | 29 | const docClientMock = mockClient(DynamoDBDocumentClient as any); 30 | 31 | beforeEach(() => { 32 | docClientMock.reset(); 33 | }); 34 | 35 | const dummyCompanyId1 = "e3162725-4b5b-4779-bf13-14d55d63a584"; 36 | const dummyCompanyId2 = "122db705-3791-289e-042c-740bec3add55"; 37 | const dummyTimestamp1 = convertIso8601StringToUnixTimestampMillis( 38 | "2024-01-01T00:00:00+09:00", 39 | ); 40 | const dummyTimestamp2 = convertIso8601StringToUnixTimestampMillis( 41 | "2024-07-07T00:00:00+09:00", 42 | ); 43 | 44 | const dummyCompanyItem1 = { 45 | id: dummyCompanyId1, 46 | createdAt: dummyTimestamp1, 47 | name: "dummy-name-1", 48 | industry: "IT" as Industry, 49 | }; 50 | const dummyCompanyItem2 = { 51 | id: dummyCompanyId2, 52 | createdAt: dummyTimestamp2, 53 | name: "dummy-name-2", 54 | industry: "Manufacturing", 55 | }; 56 | 57 | describe("putItem", () => { 58 | test("アイテムを put できること", async () => { 59 | docClientMock.on(PutCommand as any).resolves({}); 60 | 61 | const result = await putItem(dummyCompanyItem1); 62 | 63 | const callsOfGet = docClientMock.commandCalls(PutCommand as any); 64 | 65 | expect(callsOfGet.length).toBe(1); 66 | expect(callsOfGet[0].args[0].input).toEqual({ 67 | TableName: "dummy-companiesTableName", 68 | Item: dummyCompanyItem1, 69 | ConditionExpression: "attribute_not_exists(id)", 70 | }); 71 | 72 | expect(result).toBeUndefined(); 73 | }); 74 | }); 75 | 76 | describe("getItem", () => { 77 | test("アイテムを get できること", async () => { 78 | docClientMock.on(GetCommand as any).resolves({ 79 | Item: dummyCompanyItem1, 80 | } as any); 81 | 82 | const result = await getItem(dummyCompanyId1); 83 | 84 | const callsOfGet = docClientMock.commandCalls(GetCommand as any); 85 | 86 | expect(callsOfGet.length).toBe(1); 87 | expect(callsOfGet[0].args[0].input).toEqual({ 88 | TableName: "dummy-companiesTableName", 89 | Key: { id: dummyCompanyId1 }, 90 | }); 91 | 92 | expect(result).toEqual(dummyCompanyItem1); 93 | }); 94 | }); 95 | 96 | describe("deleteItem", () => { 97 | test("アイテムを delete できること", async () => { 98 | docClientMock.on(DeleteCommand as any).resolves({}); 99 | 100 | const result = await deleteItem(dummyCompanyId1); 101 | 102 | const callsOfGet = docClientMock.commandCalls(DeleteCommand as any); 103 | 104 | expect(callsOfGet.length).toBe(1); 105 | expect(callsOfGet[0].args[0].input).toEqual({ 106 | TableName: "dummy-companiesTableName", 107 | Key: { id: dummyCompanyId1 }, 108 | ConditionExpression: "attribute_exists(id)", 109 | }); 110 | 111 | expect(result).toBeUndefined(); 112 | }); 113 | }); 114 | 115 | describe("paginateScanItem", () => { 116 | test("アイテムを scan できること", async () => { 117 | docClientMock.on(ScanCommand as any).resolvesOnce({ 118 | Items: [dummyCompanyItem1, dummyCompanyItem2], 119 | } as any); 120 | 121 | const result = await paginateScanItem(); 122 | 123 | const callsOfGet = docClientMock.commandCalls(ScanCommand as any); 124 | 125 | expect(callsOfGet.length).toBe(1); 126 | expect(callsOfGet[0].args[0].input).toEqual({ 127 | TableName: "dummy-companiesTableName", 128 | }); 129 | 130 | expect(result).toEqual([dummyCompanyItem1, dummyCompanyItem2]); 131 | }); 132 | }); 133 | 134 | describe("paginateQueryItem", () => { 135 | describe.each([ 136 | [ 137 | "IT", 138 | undefined, 139 | undefined, 140 | "#industry = :industry", 141 | { ":industry": "IT" }, 142 | { "#industry": "industry" }, 143 | ], 144 | [ 145 | "IT", 146 | dummyTimestamp1, 147 | undefined, 148 | "#industry = :industry AND #createdAt >= :createdAfter", 149 | { ":industry": "IT", ":createdAfter": dummyTimestamp1 }, 150 | { "#industry": "industry", "#createdAt": "createdAt" }, 151 | ], 152 | [ 153 | "IT", 154 | undefined, 155 | dummyTimestamp2, 156 | "#industry = :industry AND #createdAt <= :createdBefore", 157 | { ":industry": "IT", ":createdBefore": dummyTimestamp2 }, 158 | { "#industry": "industry", "#createdAt": "createdAt" }, 159 | ], 160 | [ 161 | "IT", 162 | dummyTimestamp1, 163 | dummyTimestamp2, 164 | "#industry = :industry AND (#createdAt BETWEEN :createdAfter AND :createdBefore)", 165 | { 166 | ":industry": "IT", 167 | ":createdAfter": dummyTimestamp1, 168 | ":createdBefore": dummyTimestamp2, 169 | }, 170 | { "#industry": "industry", "#createdAt": "createdAt" }, 171 | ], 172 | ])( 173 | "industry: %s, createdAfter: %s, createdBefore: %s", 174 | async ( 175 | industry, 176 | createdAfter, 177 | createdBefore, 178 | keyConditionExpression, 179 | expressionAttributeValues, 180 | expressionAttributeNames, 181 | ) => { 182 | test("アイテムを query できること", async () => { 183 | docClientMock.on(QueryCommand as any).resolvesOnce({ 184 | Items: [dummyCompanyItem1, dummyCompanyItem2], 185 | } as any); 186 | 187 | const result = await paginateQueryItem( 188 | industry as Industry, 189 | createdAfter, 190 | createdBefore, 191 | ); 192 | 193 | const callsOfGet = docClientMock.commandCalls(QueryCommand as any); 194 | 195 | expect(callsOfGet.length).toBe(1); 196 | expect(callsOfGet[0].args[0].input).toEqual({ 197 | TableName: "dummy-companiesTableName", 198 | IndexName: "dummy-companiesTableIndustryCreatedAtIndexName", 199 | KeyConditionExpression: keyConditionExpression, 200 | ExpressionAttributeValues: expressionAttributeValues, 201 | ExpressionAttributeNames: expressionAttributeNames, 202 | }); 203 | 204 | expect(result).toEqual([dummyCompanyItem1, dummyCompanyItem2]); 205 | }); 206 | }, 207 | ); 208 | }); 209 | -------------------------------------------------------------------------------- /packages/server/src/lambda/infrastructures/dynamodb/companies-table.ts: -------------------------------------------------------------------------------- 1 | import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb"; 2 | import { paginateQuery, paginateScan } from "@aws-sdk/lib-dynamodb"; 3 | 4 | import { dynamoDBDocument } from "@/lambda/infrastructures/dynamodb/client"; 5 | import { 6 | CompaniesTableAlreadyExistsError, 7 | CompaniesTableNotExistsError, 8 | CompaniesTableUnknownError, 9 | } from "@/lambda/infrastructures/errors/companies-table"; 10 | 11 | const COMPANIES_TABLE_NAME = process.env.COMPANIES_TABLE_NAME || ""; 12 | const COMPANIES_TABLE_INDUSTRY_CREATED_AT_INDEX_NAME = 13 | process.env.COMPANIES_TABLE_INDUSTRY_CREATED_AT_INDEX_NAME || ""; 14 | 15 | export type Industry = "IT" | "Manufacturing" | "Finance" | "Medical" | "Other"; 16 | 17 | export interface CompanyItem { 18 | id: string; 19 | createdAt: number; 20 | name: string; 21 | industry?: Industry; 22 | } 23 | 24 | /** 25 | * 会社テーブルにアイテムをプットする 26 | * @param company 会社アイテム 27 | * @throws CompaniesTableAlreadyExistsError (会社アイテムが既に存在する場合) 28 | * @throws CompaniesTableUnknownError (未知のエラーが発生した場合) 29 | * @return なし 30 | */ 31 | export const putItem = async (company: CompanyItem): Promise => { 32 | try { 33 | await dynamoDBDocument.put({ 34 | TableName: COMPANIES_TABLE_NAME, 35 | Item: company, 36 | ConditionExpression: "attribute_not_exists(id)", 37 | }); 38 | } catch (e) { 39 | if (e instanceof ConditionalCheckFailedException) { 40 | throw new CompaniesTableAlreadyExistsError(COMPANIES_TABLE_NAME, company); 41 | } 42 | 43 | throw new CompaniesTableUnknownError(COMPANIES_TABLE_NAME, e); 44 | } 45 | }; 46 | 47 | /** 48 | * 会社テーブルからアイテムを取得する 49 | * @param id 会社アイテムの ID 50 | * @throws CompaniesTableNotExistsError (会社アイテムが存在しない場合) 51 | * @throws CompaniesTableUnknownError (未知のエラーが発生した場合) 52 | * @return 会社アイテム 53 | */ 54 | export const getItem = async (id: string): Promise => { 55 | try { 56 | const result = await dynamoDBDocument.get({ 57 | TableName: COMPANIES_TABLE_NAME, 58 | Key: { id }, 59 | }); 60 | 61 | if (result.Item === undefined) { 62 | return null; 63 | } 64 | 65 | return result.Item as CompanyItem; 66 | } catch (e) { 67 | throw new CompaniesTableUnknownError(COMPANIES_TABLE_NAME, e); 68 | } 69 | }; 70 | 71 | /** 72 | * 会社テーブルからアイテムを削除する 73 | * @param id 会社アイテムの ID 74 | * @throws CompaniesTableNotExistsError (会社アイテムが存在しない場合) 75 | * @throws CompaniesTableUnknownError (未知のエラーが発生した場合) 76 | * @return なし 77 | */ 78 | export const deleteItem = async (id: string): Promise => { 79 | try { 80 | await dynamoDBDocument.delete({ 81 | TableName: COMPANIES_TABLE_NAME, 82 | Key: { id }, 83 | ConditionExpression: "attribute_exists(id)", 84 | }); 85 | } catch (e) { 86 | if (e instanceof ConditionalCheckFailedException) { 87 | throw new CompaniesTableNotExistsError(COMPANIES_TABLE_NAME, id); 88 | } 89 | 90 | throw new CompaniesTableUnknownError(COMPANIES_TABLE_NAME, e); 91 | } 92 | }; 93 | 94 | /** 95 | * 会社テーブルをページネーションスキャンする 96 | * @see https://github.com/aws/aws-sdk-js-v3#paginators 97 | * @throws CompaniesTableUnknownError (未知のエラーが発生した場合) 98 | * @return 会社アイテムの配列 99 | */ 100 | export const paginateScanItem = async (): Promise => { 101 | const paginator = paginateScan( 102 | { 103 | client: dynamoDBDocument, 104 | }, 105 | { 106 | TableName: COMPANIES_TABLE_NAME, 107 | }, 108 | ); 109 | const companies: CompanyItem[] = []; 110 | 111 | try { 112 | for await (const page of paginator) { 113 | companies.push(...(page.Items as CompanyItem[])); 114 | } 115 | 116 | return companies; 117 | } catch (e) { 118 | throw new CompaniesTableUnknownError(COMPANIES_TABLE_NAME, e as Error); 119 | } 120 | }; 121 | 122 | /** 123 | * 会社テーブルをページネーションクエリする 124 | * @see https://github.com/aws/aws-sdk-js-v3#paginators 125 | * @param industry 業種 126 | * @param createdAfter 作成日時の開始 127 | * @param createdBefore 作成日時の終了 128 | * @throws CompaniesTableUnknownError (未知のエラーが発生した場合) 129 | * @return 会社アイテムの配列 130 | */ 131 | export const paginateQueryItem = async ( 132 | industry: Industry, 133 | createdAfter?: number, 134 | createdBefore?: number, 135 | // ScanIndexForward?: boolean, // TODO: ソート機能を別途実装 136 | ): Promise => { 137 | const [ 138 | addKeyConditionExpression, 139 | addExpressionAttributeValues, 140 | addExpressionAttributeNames, 141 | ] = ((_createdAfter, _createdBefore) => { 142 | if (_createdAfter && _createdBefore) { 143 | return [ 144 | " AND (#createdAt BETWEEN :createdAfter AND :createdBefore)", 145 | { 146 | ":createdAfter": _createdAfter, 147 | ":createdBefore": _createdBefore, 148 | }, 149 | { "#createdAt": "createdAt" }, 150 | ]; 151 | } else if (_createdAfter) { 152 | return [ 153 | " AND #createdAt >= :createdAfter", 154 | { ":createdAfter": _createdAfter }, 155 | { "#createdAt": "createdAt" }, 156 | ]; 157 | } else if (_createdBefore) { 158 | return [ 159 | " AND #createdAt <= :createdBefore", 160 | { ":createdBefore": _createdBefore }, 161 | { "#createdAt": "createdAt" }, 162 | ]; 163 | } else { 164 | return ["", {}, {}]; 165 | } 166 | })(createdAfter, createdBefore); 167 | 168 | const paginator = paginateQuery( 169 | { 170 | client: dynamoDBDocument, 171 | }, 172 | { 173 | TableName: COMPANIES_TABLE_NAME, 174 | IndexName: COMPANIES_TABLE_INDUSTRY_CREATED_AT_INDEX_NAME, 175 | KeyConditionExpression: `#industry = :industry${addKeyConditionExpression}`, 176 | ExpressionAttributeValues: { 177 | ":industry": industry, 178 | ...addExpressionAttributeValues, 179 | }, 180 | ExpressionAttributeNames: { 181 | "#industry": "industry", 182 | ...addExpressionAttributeNames, 183 | }, 184 | }, 185 | ); 186 | const companies: CompanyItem[] = []; 187 | 188 | try { 189 | for await (const page of paginator) { 190 | companies.push(...(page.Items as CompanyItem[])); 191 | } 192 | 193 | return companies; 194 | } catch (e) { 195 | throw new CompaniesTableUnknownError(COMPANIES_TABLE_NAME, e as Error); 196 | } 197 | }; 198 | -------------------------------------------------------------------------------- /packages/server/src/lambda/infrastructures/errors/companies-table.ts: -------------------------------------------------------------------------------- 1 | import { CompanyItem } from "@/lambda/infrastructures/dynamodb/companies-table"; 2 | 3 | /** 4 | * 会社テーブルで発生したエラーを表す基底クラス 5 | */ 6 | class CompaniesTableError extends Error { 7 | public tableName: string; 8 | 9 | public constructor(tableName: string) { 10 | super(); 11 | 12 | if (Error.captureStackTrace) { 13 | Error.captureStackTrace(this, CompaniesTableError); 14 | } 15 | 16 | this.name = this.constructor.name; 17 | this.tableName = tableName; 18 | } 19 | } 20 | 21 | /** 22 | * 会社アイテムがテーブル上に既に存在するエラーを表すクラス 23 | */ 24 | export class CompaniesTableAlreadyExistsError extends CompaniesTableError { 25 | public company: CompanyItem; 26 | 27 | public constructor(tableName: string, company: CompanyItem) { 28 | super(tableName); 29 | 30 | this.name = this.constructor.name; 31 | this.company = company; 32 | } 33 | } 34 | 35 | /** 36 | * 会社アイテムがテーブル上に存在しないエラーを表すクラス 37 | */ 38 | export class CompaniesTableNotExistsError extends CompaniesTableError { 39 | public id: string; 40 | 41 | public constructor(tableName: string, id: string) { 42 | super(tableName); 43 | 44 | this.name = this.constructor.name; 45 | this.id = id; 46 | } 47 | } 48 | 49 | /** 50 | * 会社テーブルで発生した未知のエラーを表すクラス 51 | */ 52 | export class CompaniesTableUnknownError extends CompaniesTableError { 53 | public error: unknown; 54 | 55 | public constructor(tableName: string, error: unknown) { 56 | super(tableName); 57 | 58 | this.name = this.constructor.name; 59 | this.error = error; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/server/src/utils/datetime.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | /** 4 | * ICASU_NOTE: JavaScript 標準の Date オブジェクトはフォーマットや日時操作が特殊なため、日付ライブラリは次のいずれかを採用することを推奨します。状況に応じて cdate や Temporal などの使用を検討してください。 5 | * 6 | * Luxon: 北米で高シェアの日時ライブラリ 7 | * @see https://moment.github.io/luxon 8 | * 9 | * Day.js: 日本を含むアジアで高シェアの日時ライブラリ 10 | * @see https://day.js.org/ 11 | * 12 | * 本プロジェクトでは Day.js を採用しています。 13 | */ 14 | 15 | /** 16 | * 現在日時の エポックミリ秒を取得する 17 | * @return エポックミリ秒 18 | */ 19 | export const getCurrentUnixTimestampMillis = (): number => dayjs().valueOf(); 20 | 21 | /** 22 | * 指定の ISO 8601 形式の日時文字列を エポックミリ秒に変換する 23 | * @param iso8601String - ISO 8601 形式の日時文字列 24 | * @return エポックミリ秒 25 | */ 26 | export const convertIso8601StringToUnixTimestampMillis = ( 27 | iso8601String: string, 28 | ): number => dayjs(iso8601String).valueOf(); 29 | -------------------------------------------------------------------------------- /packages/server/src/utils/http-response.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_HEADERS = { 2 | "Access-Control-Allow-Headers": "Content-Type,Authorization", 3 | "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET,DELETE", 4 | "Access-Control-Allow-Origin": "*", 5 | }; 6 | 7 | export interface HttpResponseHeader { 8 | [key: string]: string; 9 | } 10 | 11 | export interface HttpResponse { 12 | statusCode: number; 13 | headers: HttpResponseHeader; 14 | body?: string; 15 | } 16 | 17 | /** 18 | * HTTP 200 OK レスポンスを生成する 19 | * @param body 本文 20 | * @param headers ヘッダー 21 | * @return HTTP 200 OK 22 | */ 23 | export const ok = ( 24 | body?: string, 25 | headers?: HttpResponseHeader, 26 | ): HttpResponse => { 27 | const httpResponse: HttpResponse = { 28 | statusCode: 200, 29 | headers: { ...DEFAULT_HEADERS, ...headers }, 30 | }; 31 | 32 | if (body) { 33 | httpResponse.body = body; 34 | } 35 | 36 | return httpResponse; 37 | }; 38 | 39 | /** 40 | * HTTP 201 Created レスポンスを生成する 41 | * @param body 本文 42 | * @param headers ヘッダー 43 | * @return HTTP 201 Created 44 | */ 45 | export const created = ( 46 | body?: string, 47 | headers?: HttpResponseHeader, 48 | ): HttpResponse => { 49 | const httpResponse: HttpResponse = { 50 | statusCode: 201, 51 | headers: { ...DEFAULT_HEADERS, ...headers }, 52 | }; 53 | 54 | if (body) { 55 | httpResponse.body = body; 56 | } 57 | 58 | return httpResponse; 59 | }; 60 | 61 | /** 62 | * HTTP 204 NoContent レスポンスを生成する 63 | * @param body 本文 64 | * @param headers ヘッダー 65 | * @return HTTP 204 NoContent 66 | */ 67 | export const noContent = ( 68 | body?: string, 69 | headers?: HttpResponseHeader, 70 | ): HttpResponse => { 71 | const httpResponse: HttpResponse = { 72 | statusCode: 204, 73 | headers: { ...DEFAULT_HEADERS, ...headers }, 74 | }; 75 | 76 | if (body) { 77 | httpResponse.body = body; 78 | } 79 | 80 | return httpResponse; 81 | }; 82 | 83 | /** 84 | * HTTP 400 BadRequest レスポンスを生成する 85 | * @param body 本文 86 | * @param headers ヘッダー 87 | * @return HTTP 400 BadRequest 88 | */ 89 | export const badRequest = ( 90 | body?: string, 91 | headers?: HttpResponseHeader, 92 | ): HttpResponse => { 93 | const httpResponse: HttpResponse = { 94 | statusCode: 400, 95 | headers: { ...DEFAULT_HEADERS, ...headers }, 96 | }; 97 | 98 | if (body) { 99 | httpResponse.body = body; 100 | } 101 | 102 | return httpResponse; 103 | }; 104 | 105 | /** 106 | * HTTP 403 Forbidden レスポンスを生成する 107 | * @param body 本文 108 | * @param headers ヘッダー 109 | * @return HTTP 403 Forbidden 110 | */ 111 | export const forbidden = ( 112 | body?: string, 113 | headers?: HttpResponseHeader, 114 | ): HttpResponse => { 115 | const httpResponse: HttpResponse = { 116 | statusCode: 403, 117 | headers: { ...DEFAULT_HEADERS, ...headers }, 118 | }; 119 | 120 | if (body) { 121 | httpResponse.body = body; 122 | } 123 | 124 | return httpResponse; 125 | }; 126 | 127 | /** 128 | * HTTP 404 NotFound レスポンスを生成する 129 | * @param body 本文 130 | * @param headers ヘッダー 131 | * @return HTTP 404 NotFound 132 | */ 133 | export const notFound = ( 134 | body?: string, 135 | headers?: HttpResponseHeader, 136 | ): HttpResponse => { 137 | const httpResponse: HttpResponse = { 138 | statusCode: 404, 139 | headers: { ...DEFAULT_HEADERS, ...headers }, 140 | }; 141 | 142 | if (body) { 143 | httpResponse.body = body; 144 | } 145 | 146 | return httpResponse; 147 | }; 148 | 149 | /** 150 | * HTTP 409 Conflict レスポンスを生成する 151 | * @param body 本文 152 | * @param headers ヘッダー 153 | * @return HTTP 409 Conflict 154 | */ 155 | export const conflict = ( 156 | body?: string, 157 | headers?: HttpResponseHeader, 158 | ): HttpResponse => { 159 | const httpResponse: HttpResponse = { 160 | statusCode: 409, 161 | headers: { ...DEFAULT_HEADERS, ...headers }, 162 | }; 163 | 164 | if (body) { 165 | httpResponse.body = body; 166 | } 167 | 168 | return httpResponse; 169 | }; 170 | 171 | /** 172 | * HTTP 500 InternalServerError レスポンスを生成する 173 | * @param body 本文 174 | * @param headers ヘッダー 175 | * @return HTTP 500 InternalServerError 176 | */ 177 | export const internalServerError = ( 178 | body?: string, 179 | headers?: HttpResponseHeader, 180 | ): HttpResponse => { 181 | const httpResponse: HttpResponse = { 182 | statusCode: 500, 183 | headers: { ...DEFAULT_HEADERS, ...headers }, 184 | }; 185 | 186 | if (body) { 187 | httpResponse.body = body; 188 | } 189 | 190 | return httpResponse; 191 | }; 192 | -------------------------------------------------------------------------------- /packages/server/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { LogFormatter, Logger } from "@aws-lambda-powertools/logger"; 2 | import { 3 | LogAttributes, 4 | UnformattedAttributes, 5 | } from "@aws-lambda-powertools/logger/lib/types"; 6 | 7 | /** 8 | * ログフォーマットをカスタマイズするクラス 9 | * 10 | * TODO: Serverless Express の利用に適したログフォーマットを実装する 11 | */ 12 | class MyLogFormatter extends LogFormatter { 13 | public formatAttributes(attributes: UnformattedAttributes): LogAttributes { 14 | return attributes; 15 | } 16 | 17 | public formatError(error: Error): LogAttributes { 18 | return { 19 | ...{ 20 | name: error.name, 21 | location: this.getCodeLocation(error.stack), 22 | stack: error.stack, 23 | }, 24 | ...error, 25 | }; 26 | } 27 | } 28 | 29 | /** 30 | * ロガーのインスタンスを生成 31 | */ 32 | export const logger = new Logger({ 33 | logFormatter: new MyLogFormatter(), 34 | }); 35 | -------------------------------------------------------------------------------- /packages/server/src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | import * as Uuid from "uuid"; 2 | 3 | /** 4 | * UUID v4 の生成 5 | * @return UUID v4 6 | */ 7 | export const generateUuidV4 = (): string => Uuid.v4(); 8 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "@/*": ["src/*"] 7 | }, 8 | "esModuleInterop": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | alias: { 7 | "@": path.resolve(__dirname, "./src"), 8 | }, 9 | exclude: ["./tsc-cache"], 10 | globals: true, 11 | env: { 12 | COMPANIES_TABLE_NAME: "dummy-companiesTableName", 13 | COMPANIES_TABLE_INDUSTRY_CREATED_AT_INDEX_NAME: 14 | "dummy-companiesTableIndustryCreatedAtIndexName", 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["es2020", "dom"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "types": ["vitest/globals"], 21 | "incremental": true, 22 | // Vitest v1 導入に伴うワークアラウンド 23 | // ESM 対応ではなく d.ts ファイルの型チェックのスキップによりエラーを回避する 24 | // @see https://zenn.dev/sa2knight/scraps/636bedb1f9b019 25 | // 型チェックの精度が下がるデメリットがあるため、今後の Vitest のアップデートで不要になったら削除する 26 | // @see https://t-yng.jp/post/skiplibcheck 27 | "skipLibCheck":true 28 | }, 29 | "exclude": ["node_modules"] 30 | } 31 | --------------------------------------------------------------------------------