Serverless Framework + TypeScript（esbuild）で ローカル開発環境構築と、AWS（API Gateway + Lambda + DynamoDB）へのデプロイをしてみる

Serverless Frameworkを使ったローカル環境構築と、AWSへのデプロイ方法をまとめました。
言語はTypeScript、バンドラーにはesbuildを使っています。
結構長くなったので、各章ごとに分けました。
1章～4章までやれば、API Gateway + Lambda + DynamoDB を使った簡単なAPI/バッチ/管理画面が作れます。

# 目次
1章. [ローカル開発環境を構築し、AWSにデプロイしてみる](#1章-ローカル開発環境を構築しawsにデプロイしてみる)
2章. [DynamoDBを使って、データの登録/更新/削除 をしてみる](#2章-dynamodbを使ってデータの登録更新削除-をしてみる)
3章. [バッチ処理を作ってみる](#3章-バッチ処理を作ってみる)
4章. [簡易的な管理画面を作ってみる](#4章-簡易的な管理画面を作ってみる)

# 1章. ローカル開発環境を構築し、AWSにデプロイしてみる
## 開発環境
OS
- Windows11上のWSL2で動作するUbuntu

以下がインストールされている事
- node.js（v20.11.0以上）
- npm（v10.2.4以上）
- Java（v11.0.25以上）

## 準備
デプロイ先のAWSでIAMユーザーを作成しておく。その際の権限は「AdministratorAccess」にしておく。
次に以下のようにローカルでAWSプロファイルを作成する。
```shell
$ aws configure --profile sample-profile   # sample-profileとしているがお好みで
AWS Access Key ID [None]: <上記で作成したIAMのアクセスキーを入力>
AWS Secret Access Key [None]: <上記で作成したIAMのシークレットキーを入力>
Default region name [None]: ap-northeast-1
Default output format [None]: json
```

## プロジェクトの作成
任意ディレクトリに移り、以下を実行して「プロジェクト」を作成する
```shell
$ cd 任意のディレクトリ
$ npx serverless  # 入力してEnter

? What do you want to make?
  AWS - Node.js - Starter
  AWS - Node.js - HTTP API
  AWS - Node.js - Scheduled Task
  AWS - Node.js - SQS Worker
❯ AWS - Node.js - Express API  # 選択してEnter
  AWS - Node.js - Express API with DynamoDB
  AWS - Python - Starter
  AWS - Python - HTTP API
  AWS - Python - Scheduled Task
  AWS - Python - SQS Worker
  AWS - Python - Flask API
  AWS - Python - Flask API with DynamoDB
  Other

? What do you want to make? AWS - Node.js - Express API
? What do you want to call this project? sample-api  # プロジェクト名「sample-api」と入力してEnter

✔ Project successfully created in sample-api folder

? Register or Login to Serverless Framework (Y/n)  # Y を入力してEnter（ブラウザで「serverless framework」が立ち上がるので、RegisterまたはLoginしておく）

Logging into the Serverless Framework via the browser
If your browser does not open automatically, please open this URL:
https://app.serverless.com?client=cli&transactionId=Cp1NijbiQh_jggbWDHDvL

✔ You are now logged into the Serverless Framework


✔ Your project is ready to be deployed to Serverless Dashboard (org: "xxxxxx", app: "sample-api")


? Do you want to deploy now? (Y/n) # n を入力してEnter

What next?
Run these commands in the project directory:

serverless deploy    Deploy changes
serverless info      View deployed endpoints and resources
serverless invoke    Invoke deployed functions
serverless --help    Discover more commands
```

## プロジェクトの確認
作成したプロジェクトディレクトリに移動し、treeコマンドでプロジェクトの初期構成を確認する。
```shell
$ cd sample-api  # 入力してEnter
$ tree -L 1　    # 入力してEnter（treeコマンドが無い場合には sudo apt install tree -y でインストールする）

├── README.md
├── index.js
├── node_modules
├── package-lock.json
├── package.json
└── serverless.yml

$cat package.json
# ↓ 初期のpackage.json。ライブラリをインストールする度に、ここに自動で追記されていく。
{
  "name": "sample-api",
  "version": "1.0.0",
  "description": "",
  "dependencies": {
    "express": "^4.18.2",
    "serverless-http": "^3.1.1"
  }
}
```

## 各種ライブラリのインストール
```shell
$ npm i -D serverless@3          # serverless ver3 コマンド
$ npm i -D serverless-offline@13 # serverlessをローカルで使う為(@13はserverless ver3に対応したバージョン13の意味)
$ npm i -D esbuild serverless-esbuild typescript @types/express @types/node # TypeScript化用 
$ npm i @vendia/serverless-express body-parser                              # serverless express用
$ npm i -D @types/aws-lambda     # Lambda関連
$ npm i date-fns                 # 日時関連
$ npm rm serverless-http         # serverless express を使用するので削除
```

## ローカル起動コマンドの追加
```diff_json:json:package.json
{
  "name": "sample-api",
  "version": "1.0.0",
  "description": "",
+   "scripts": {
+   "dev": "serverless offline"
  },
  "dependencies": {
    "@vendia/serverless-express": "^4.12.6",
    "body-parser": "^1.20.3",
    "date-fns": "^4.1.0",
    "express": "^4.18.2"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.147",
    "@types/express": "^5.0.0",
    "@types/node": "^22.10.5",
    "esbuild": "^0.24.2",
    "serverless": "^3.40.0",
    "serverless-esbuild": "^1.54.6",
    "serverless-offline": "^13.9.0",
    "typescript": "^5.7.3"
  }
}

}
```

## esbuildを使ってTypeScript化させる
```diff_yaml:serverless.yml
org: xxxxxx
app: sample-api
service: sample-api
frameworkVersion: "3"

+ package:
+   individually: true
+ custom:
+   defaultStage: local
+   esbuild:
+       bundle: true
+       minify: false
+       sourcemap: false
+       exclude: ["aws-sdk"]
+       target: "node20" # インストールされているnode.jsのバージョン
+       define: { "require.resolve": undefined }
+       platform: "node"
+       concurrency: 10
+       watch:
+           pattern: ["src/**/*.ts"]
+           ignore: ["temp/**/*"]
+   region:
+       local: "local"
+       dev: "ap-northeast-1"

provider:
    name: aws
+   logs:
+       restApi: false
+   region: ${self:custom.region.${opt:stage, 'local'}}
+   stage: ${opt:stage, self:custom.defaultStage}
+   runtime: nodejs20.x # Lambda側はこのバージョンでデプロイされる

functions:
    # API処理
    api:
+       handler: src/api/index.handler
+       events:
+           - http:
+                 method: ANY
+                 path: "/"
+                 cors: true
+           - http:
+                 method: ANY
+                 path: "/{any+}"
+                 cors: true
+ plugins:
+   - serverless-esbuild
+   - serverless-offline
```

## tsconfig.jsonの作成
serverless.ymlと同じディレクトリに、tsconfig.jsonを新規作成する
```json:tsconfig.json
{
    "compilerOptions": {
        "lib": ["ESNext"],
        "moduleResolution": "node",
        "isolatedModules": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "removeComments": true,
        "allowSyntheticDefaultImports": true,
        "esModuleInterop": true,
        "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
        "preserveConstEnums": true,
        "strictNullChecks": true,
        "allowJs": true,
        "sourceMap": false,
        "target": "ES2020",
        "outDir": "lib"
    },
    "include": ["src/**/*.ts", "serverless.ts"],
    "exclude": ["node_modules/**/*", ".serverless/**/*", ".webpack/**/*", "_warmup/**/*", ".vscode/**/*"]
}
```

## APIファイルの作成
serverless.ymlのあるディレクトリから、index.js を削除しておく。
その後、src/api/フォルダを新規作成し、その中に index.ts を新規作成する。
```typescript:src/api/index.ts
import serverlessExpress from "@vendia/serverless-express";
import express, { Request, Response, NextFunction } from "express";
import bodyParser from "body-parser";

const app = express();
const router = express.Router();

router.use(bodyParser.json());
router.use(bodyParser.urlencoded({ extended: true }));

router.get("/user", async (_req: Request, res: Response, _next: NextFunction) => {
    const json = { message: "Hello from path!" };
    res.status(200).json(json);
});

// 404エラーハンドラ（未定義ルート用）
router.use((_req: Request, res: Response) => {
    res.status(404).json({
        error: "404 Not Found",
    });
});

// 500エラーハンドラ（すべてのルートの後に配置）
router.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
    console.error(err.stack);
    res.status(500).send("System Error");
});

app.use("/", router);

export const handler = serverlessExpress({ app });
```

## ローカル環境を起動してみる
```shell
$ npm run dev  # 入力してEnter

> sample-api@1.0.0 dev
> serverless offline


Warning: Invalid configuration encountered
  at 'provider.region': must be equal to one of the allowed values [us-east-1, us-east-2, us-gov-east-1, us-gov-west-1, us-iso-east-1, us-iso-west-1, us-isob-east-1, us-west-1, us-west-2, af-south-1, ap-east-1, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2, ap-southeast-1, ap-southeast-2, ap-southeast-3, ap-southeast-4, ca-central-1, cn-north-1, cn-northwest-1, eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2, eu-west-3, il-central-1, me-central-1, me-south-1, sa-east-1]

Learn more about configuration validation here: http://slss.io/configuration-validation
Starting Offline at stage local (local)

Offline [http for lambda] listening on http://localhost:3002
Function names exposed for local invocation by aws-sdk:
   * api: sample-api-local-api

   ┌───────────────────────────────────────────────────────────────────────┐
   │                                                                       │
   │   ANY  | http://localhost:3000/local                                  │
   │   POST | http://localhost:3000/2015-03-31/functions/api/invocations   │
   │   ANY  | http://localhost:3000/local/{any*}                           │
   │   POST | http://localhost:3000/2015-03-31/functions/api/invocations   │
   │                                                                       │
   └───────────────────────────────────────────────────────────────────────┘

Server ready: http://localhost:3000 🚀
```

## ローカル環境のAPIに接続してみる
```shell
$ curl http://localhost:3000/local/user  # 入力してEnter
{"message":"Hello from path!"}           # 表示されればOK
```

## AWSにdev環境としてデプロイしてみる
```shell
$ serverless deploy --stage dev --aws-profile sample-profile  # 入力してEnter
```
すると、<プロジェクト名>-<環境名>-api という名前のLambda関数ができる。
例）sample-api-dev-api

また、<環境名>-<プロジェクト名> という名前のAPI Gatewayができる。
例）dev-sample-api

## デプロイしたAPIに接続してみる
API Gatewayのコンソールに入り、左ペイン「ステージ」をクリックして、ステージ「dev」の「URLを呼び出す」に記載のURLをコピー。

```shell
curl <コピーしたURL>/user         # 入力してEnter
{"message":"Hello from path!"}   # 表示されればOK
```

## デプロイ用コマンドの作成
AWSへのデプロイもコマンド１つで簡単に出来てしまいますが、それがかえって怖いので、
AWS環境へのデプロイおよび環境削除については、間違ってデプロイしないように、Yes/Noの確認をするロジックを入れたいと思います。

sample-apiディレクトリに入り、まずはデプロイ用スクリプト「confirmDeploy.js」を新規作成する。
```JavaScript:confirmDeploy.js
const readline = require("readline");
const { exec } = require("child_process");

const environment = process.argv[2]; // 第一パラメーターを環境名として取得
const profile = process.argv[3]; // 第二パラメーターをプロファイル名として取得

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
});

rl.question(`${environment}へデプロイしますか? (yes/no) `, (answer) => {
    if (answer.toLowerCase() === "yes") {
        exec(`serverless deploy --stage ${environment} --aws-profile ${profile}`, (error, stdout, stderr) => {
            if (error) {
                console.log(`error: ${error.message}`);
                return;
            }
            if (stderr) {
                console.log(`stderr: ${stderr}`);
                return;
            }
            console.log(`stdout: ${stdout}`);
        });
    } else {
        console.log("キャンセルしました。");
    }
    rl.close();
});
```

次に同じ場所に、環境削除用スクリプト「confirmRemove.js」を新規作成する。
```JavaScript:confirmRemove.js
const readline = require("readline");
const { exec } = require("child_process");

const environment = process.argv[2]; // 第一パラメーターを環境名として取得
const profile = process.argv[3]; // 第二パラメーターをプロファイル名として取得

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
});

rl.question(`${environment}を削除しますか? (yes/no) `, (answer) => {
    if (answer.toLowerCase() === "yes") {
        exec(`serverless remove --stage ${environment} --aws-profile ${profile}`, (error, stdout, stderr) => {
            if (error) {
                console.log(`error: ${error.message}`);
                return;
            }
            if (stderr) {
                console.log(`stderr: ${stderr}`);
                return;
            }
            console.log(`stdout: ${stdout}`);
        });
    } else {
        console.log("キャンセルしました。");
    }
    rl.close();
});
```

package.json に上記２つのコマンドを追加
```diff_json:package.json
{
  "name": "sample-api",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "serverless offline",
+   "deploy:dev": "node confirmDeploy.js dev sample-profile",
+   "remove:dev": "node comfirmRemove.js dev sample-profile"
  },
　　：
　　：
```

これで、デプロイするときには、npm run deploy:dev と入力すれば「デプロイしますか？」と聞いてきて、逆に削除するときは、npm run deploy:remove と入力すれば「削除しますか？」と聞いてきてくれます。

誤ってデプロイor削除しないように事前に確認できます。

## TypeScriptの型チェックコマンド
esbuildはTypeScriptの型チェックをしてくれる訳ではないので、型チェックコマンドも追加します。
package.json に 型チェックコマンドを追加
```diff_json:package.json
{
  "name": "sample-api",
  "version": "1.0.0",
  "description": "",
  "scripts": {
+   "type-check": "tsc --noEmit",
    "dev": "serverless offline",
  },
　　：
　　：
```

これで「npm run type-check」と入力すると型チェックができます。

今のところのディレクトリ構成は以下になります。
```shell
├── README.md
├── confirmDeploy.js
├── confirmRemove.js
├── node_modules
├── package-lock.json
├── package.json
├── serverless.yml
├── src
└── tsconfig.json
```

# 2章. DynamoDBを使って、データの登録/更新/削除 をしてみる
DynamoDBにはRDMSのようなユニーク制約が無く、間違って同じレコードを重複登録しないように、疑似的なユニーク制約も作ってみます。

DynamoDBには以下のようなテーブルを作成します。
また、signatureで検索できるようにするため、GSIも作成します。
テーブル名には環境変数を使用して、環境毎のプレフィックスを指定します。
```json
{
    "id": "<UUID>",　※パーティションキー
    "signature": "<ユーザーを識別する署名>", ※今回は署名の改ざんチェックなどはなく、単純な識別子とします。
    "name": "山田",
    "gender": "男性",
    "created_at": "2024-01-16 01:00:00",
    "updated_at": "2024-01-16 01:00:00"
}
```

## DynamoDBと必要なライブラリをインストール
```shell
$ npm i @aws-sdk/client-dynamodb uuid
$ npm i -D serverless-dynamodb
```

## serverless.yml にプラグインを追加
```diff_yaml:serverless.yml
　：
　：
plugins:
+   - serverless-dynamodb
    - serverless-esbuild
    - serverless-offline
```

## package.jsonにDynamoDB関連コマンドを追加
```diff_json:package.json
　：
　：
    "scripts": {
        "type-check": "tsc --noEmit",
        "dev": "serverless offline",
        "deploy:dev": "node confirmDeploy.js dev sample-profile",
        "remove:dev": "node comfirmRemove.js dev sample-profile",
+       "dynamodb:install": "serverless dynamodb install",
+       "dynamodb:start": "serverless dynamodb start",
+       "dynamodb:migrate": "serverless dynamodb migrate"
    },
　：
　：
```

## DynamoDBの初期化
```shell
$ npm run dynamodb:install  # 入力してEnter

> sample-api@1.0.0 dynamodb:install
> serverless dynamodb install

Installing DynamoDB Local from https://d1ni2b6xgvw0s0.cloudfront.net/dynamodb_local_latest.tar.gz...
Installation of DynamoDB Local complete.
```

## serverless.yml にIAMロールとテーブルの定義
```diff_yaml:serverless.yaml
　：
　：
custom:
    defaultStage: local
    esbuild:
        bundle: true
        minify: false
        sourcemap: false
        exclude: ["aws-sdk"]
        target: "node20"
        define: { "require.resolve": undefined }
        platform: "node"
        concurrency: 10
        watch:
            pattern: ["src/**/*.ts"]
            ignore: ["temp/**/*"]
+   dynamodb:
+       start:
+           port: 8000
+           inMemory: false
+           migrate: false
+           dbPath: "./.dynamodb"
    region:
        local: "local"
        dev: "ap-northeast-1"          

provider:
    name: aws
    logs:
        restApi: false
    region: ${self:custom.region.${opt:stage, 'local'}}
    stage: ${opt:stage, self:custom.defaultStage}
    runtime: nodejs20.x
+   iam:
+       role:
+           statements:
+               - Effect: Allow
+                 Action:
+                     - dynamodb:Query
+                     - dynamodb:Scan
+                     - dynamodb:GetItem
+                     - dynamodb:PutItem
+                     - dynamodb:UpdateItem
+                     - dynamodb:DeleteItem
+                 Resource:
+                     - Fn::GetAtt: [User, Arn]
+                     - !Sub "${User.Arn}/index/*"
+                     - Fn::GetAtt: [Signature, Arn]
    ：
    ：
plugins:
    - serverless-dynamodb
    - serverless-esbuild
    - serverless-offline

+ resources:
+   Resources:
+       User:  # 更新対象のテーブル
+           Type: AWS::DynamoDB::Table
+           Properties:
+               TableName: ${self:provider.stage}-users
+               AttributeDefinitions:
+                   - AttributeName: id
+                     AttributeType: S
+                   - AttributeName: signature
+                     AttributeType: S
+               KeySchema:
+                   - AttributeName: id
+                     KeyType: HASH
+               GlobalSecondaryIndexes:
+                   - IndexName: signature_gsi
+                     KeySchema:
+                         - AttributeName: signature
+                           KeyType: HASH
+                     Projection:
+                         ProjectionType: ALL
+               BillingMode: PAY_PER_REQUEST
+               PointInTimeRecoverySpecification:
+                   PointInTimeRecoveryEnabled: true

+       Signature:  # 疑似的なユニーク制約を実現する為のテーブル
+           Type: AWS::DynamoDB::Table
+           Properties:
+               TableName: ${self:provider.stage}-signatures
+               AttributeDefinitions:
+                   - AttributeName: stext
+                     AttributeType: S
+               KeySchema:
+                   - AttributeName: stext
+                     KeyType: HASH
+               BillingMode: PAY_PER_REQUEST
+               PointInTimeRecoverySpecification:
+                   PointInTimeRecoveryEnabled: true
```

signature はクライアントから送信され、DB内では必ず一意になるようにします。
しかしながら DynamoDBのテーブル仕様の特性上、ユニーク制約が無いため、以下に記す「疑似的なユニーク制約」を実装します。

### 疑似的なユニーク制約とは
具体的には、usersテーブル（idをパーティションキー）と、signaturesテーブル（stextをパーティションキー）としたテーブルをトランザクションで囲って
signatures.stext が同じものが２つ以上登録されたら例外処理ではじいてトランザクションを終了することにより、usersテーブルの更新もはじくようにします。
※textは予約語なので、stextにしました。

## DynamoDBの起動
別ウィンドウを開き以下を入力
```shell
$ npm run dynamodb:start   # 入力してEnter

> sample-api@1.0.0 dynamodb:start
> serverless dynamodb start

Initializing DynamoDB Local with the following configuration:
Port:   8000
InMemory:       false
Version:        1.25.1
DbPath: /home/chinone/xxxxxx/xxxxxx/xxxxxx/sample-api/.dynamodb
SharedDb:       true
shouldDelayTransientStatuses:   true
CorsParams:     *
```

## DynamoDBテーブルの作成（migrate）
別ウィンドウを開き以下を入力
```shell
$ npm run dynamodb:migrate   # 入力してEnter

DynamoDB - created table local-signatures
DynamoDB - created table local-users
```

## DynamoDB-ADMINの起動
別ウィンドウを開き以下を入力
```shell
$ npm install dynamodb-admin   # 入力してEnter（インストール）
$ DYNAMO_ENDPOINT=http://localhost:8000 dynamodb-admin   # 入力してEnter（起動）
```

ブラウザを http://localhost:8001/ で開いて画面表示できたら成功
local-sigunatures と local-users テーブルが出来ていればOK

## DynamoDBローカルの注意点
以下のページでも言われていますが、DynamoDBローカルはパーティションキーを指定しているにも関わらず、稀に重複登録されることがあります。
その際はこちらのページを参考ください。
https://qiita.com/nozaki-amazia/items/c8c58f7647c4fec927ce

## 環境変数の設定
src ディレクトリの下に config ディレクトリを作り、その中に以下環境変数ファイルを作る。
```yaml:src/config/local.yml
ENV: local
TZ: Asia/Tokyo
```
```yaml:src/config/dev.yml
ENV: dev
TZ: Asia/Tokyo
```

serverless.ymlに以下追記
```diff_yaml:serverless.yml
　：
    region:
        local: "local"
        dev: "ap-northeast-1"
+   otherfile:
+       environment:
+           local: ${file(./src/config/local.yml)}
+           dev: ${file(./src/config/dev.yml)}
　：
functions:
    # API処理
    api:
        handler: src/api/index.handler
+       timeout: 30 <===== APIのタイムアウト拡張
+       environment:
+           ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
+           TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
　：
```

## Modelファイルの実装
src ディレクトリの下に model ディレクトリを作り、その中に User.ts ファイルを新規作る。
```TypeScript:src/Model/User.ts
import { DynamoDBClient, QueryCommand, QueryCommandInput, UpdateItemCommand, ReturnValue, DeleteItemCommand, ScanCommand, ScanCommandInput, ScanCommandOutput  } from "@aws-sdk/client-dynamodb";
import { TransactWriteCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { Request } from "express";
import { v4 as uuidv4 } from "uuid";
import { format } from "date-fns";

export class User {
    private userTableName: string = process.env.ENV + "-users";
    private signatureTableName: string = process.env.ENV + "-signatures";
    private indexName: string = "signature_gsi";
    private client: DynamoDBClient;
    private documentClient: DynamoDBDocumentClient;

    constructor() {
        if (global.dynamodbClient) {
            this.client = global.dynamodbClient; // <===== https://qiita.com/chinone0711/items/13d57f9dc6cd13e5a2b8 の対応
        } else {
            this.client = new DynamoDBClient({
                region: process.env.AWS_REGION,
            });
        }
        this.documentClient = DynamoDBDocumentClient.from(this.client);
    }

    /**
     * SignatureからユーザーIDを取得
     * @param signature
     * @returns
     */
    getIdBySignature = async (signature: string) => {
        let ret: string = "";
        const params: QueryCommandInput = {
            TableName: this.userTableName,
            IndexName: this.indexName,
            KeyConditionExpression: "#gsiKey = :gsiValue",
            ExpressionAttributeNames: {
                "#gsiKey": "signature",
            },
            ExpressionAttributeValues: {
                ":gsiValue": { S: signature },
            },
        };
        try {
            const command = new QueryCommand(params);
            const data = await this.client.send(command);
            if (data && data.Items) {
                for (const item of data.Items) {
                    ret = item.id.S?.toString() || "";
                    break;
                }
            }
        } catch (err) {
            console.error("Unable to query. Error users(1):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to query. Error users(1): ${err.message}`);
        }
        return ret;
    };

    /**
     * ユーザーを作成
     * @param signature
     * @param name
     * @param gender
     * @returns
     */
    createUser = async (signature: string, name: string, gender: string) => {
        let ret: string = "";
        const id = uuidv4();
        const date = new Date();
        const now = format(date, "yyyy-MM-dd HH:mm:ss");
        const params = {
            TransactItems: [
                {
                    Put: {
                        TableName: this.userTableName,
                        Item: {
                            id: id,
                            signature: signature,
                            name: name,
                            gender: gender,
                            created_at: now,
                            updated_at: now,
                        },
                        // ユニーク制約：id が存在しないことを確認
                        ConditionExpression: "attribute_not_exists(id)",
                    },
                },
                {
                    Put: {
                        TableName: this.signatureTableName,
                        Item: {
                            stext: signature,
                        },
                        // ユニーク制約：stext が存在しないことを確認
                        ConditionExpression: "attribute_not_exists(stext)",
                    },
                },
            ],
        };
        try {
            const command = new TransactWriteCommand(params);
            await this.documentClient.send(command);
            ret = id;
        } catch (err) {
            console.error("Unable to put item. Error users(2):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to put item. Error users(2): ${err.message}`);
        }
        return ret;
    };

    /**
     * ユーザー名更新
     * @param signature
     * @param name
     */
    updateUserById = async (req) => {
        const userId = req.userId!;
        const postData = req.body!;
        const name = postData.user.name;
        const gender = postData.user.gender;
        const params = {
            TableName: this.userTableName,
            Key: {
                id: { S: userId },
            },
            ExpressionAttributeNames: {
                "#Y1": "name",
                "#Y2": "gender",
                "#Y3": "updated_at",
            },
            ExpressionAttributeValues: {
                ":y1": { S: name },
                ":y2": { S: gender },
                ":y3": { S: format(new Date(), "yyyy-MM-dd HH:mm:ss") },
            },
            UpdateExpression: "SET #Y1 = :y1, #Y2 = :y2, #Y3 = :y3",
            ReturnValues: "UPDATED_NEW" as ReturnValue,
        };

        try {
            const command = new UpdateItemCommand(params);
            this.documentClient.send(command);
        } catch (err) {
            console.error("Unable to put item. Error users(3):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to put item. Error users(3): ${err.message}`);
        }
    };

    /**
     * ユーザー削除
     * @param signature
     */
    deleteUserById = async (req) => {
        const signature = req.headers["signature"]!.toString();
        const userId = req.userId!;
        const userParams = {
            TableName: this.userTableName,
            Key: {
                id: { S: userId },
            },
        };
        const signatureParams = {
            TableName: this.signatureTableName,
            Key: {
                stext: { S: signature },
            },
        };

        try {
            const command1 = new DeleteItemCommand(userParams);
            this.documentClient.send(command1);

            const command2 = new DeleteItemCommand(signatureParams);
            this.documentClient.send(command2);
        } catch (err) {
            console.error("Unable to delete item. Error users(4):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to delete item. Error users(4): ${err.message}`);
        }
    };
}
```

## Serviceファイルの実装
src ディレクトリの下に service ディレクトリを作り、その中に SignatureService.ts ファイルを作る。
```TypeScript:src/service/SignatureService.ts
import { Request } from "express";
import { User } from "../model/User";

export class SignatureService {
    /**
     * SignatureからユーザーIDを取得、存在しない場合は新規作成
     * @param req
     * @returns
     */
    getUserId = async (req: Request) => {
        let userId: string = "";
        const signature = req.headers["signature"];
        if (signature !== undefined) {
            if (typeof signature === "string") {
                const user = new User();
                userId = await user.getIdBySignature(signature);
                if (userId === "") {
                    const postData = req.body!;
                    const name = postData.user.name;
                    const gender = postData.user.gender;
                    userId = await user.createUser(signature, name, gender);
                    console.log("ユーザーID：" + userId + " を作成。");
                } else {
                    console.log("ユーザーID：" + userId + " を取得。");
                }
            }
        }
        return userId;
    };
}
```

## index.tsの修正
src/api/index.tsを以下のように書き換える
```TypeScript:src/api/index.ts
import serverlessExpress from "@vendia/serverless-express";
import express, { Request, Response, NextFunction } from "express";
import bodyParser from "body-parser";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { SignatureService } from "../service/SignatureService";

import { User } from "../model/User";

const app = express();
const router = express.Router();

router.use(bodyParser.json());
router.use(bodyParser.urlencoded({ extended: true }));

declare global {
    namespace Express {
        interface Request {
            userId?: string;
        }
    }
}

/**
 * トップページ
 * ※認証処理対象外
 */
router.get("/top", async (_req: Request, res: Response, _next: NextFunction) => {
    const json = { message: "Top Page!" };
    res.status(200).json(json);
});

/**
 * 認証処理（API共通のミドルウェア）
 * @param req
 * @param res
 * @param next
 */
router.use(async (req: Request, res: Response, next: NextFunction) => {
    console.log("signature", req.headers["signature"]);
    console.log("リクエスト", req.body);
    const signatureService = new SignatureService();
    const userId = await signatureService.getUserId(req);
    if (userId) {
        req.userId = userId;
        next();
    } else {
        if (req.path === "/favicon.ico") {
            res.status(201).json(null);
        }
        res.status(401).json({
            error: "401 Unauthorized",
        });
    }
});

/**
 * ユーザー作成orログイン
 */
router.post("/user", async (_req: Request, res: Response, _next: NextFunction) => {
    const json = { message: "User create or login" };
    res.status(200).json(json);
});

/**
 * ユーザー更新
 */
router.patch("/user", async (req: Request, res: Response, _next: NextFunction) => {
    const user = new User();
    await user.updateUserById(req);
    const json = { message: "User update" };
    res.status(200).json(json);
});

/**
 * ユーザー削除
 */
router.delete("/user", async (req: Request, res: Response, _next: NextFunction) => {
    const user = new User();
    await user.deleteUserById(req);
    const json = { message: "User delete" };
    res.status(200).json(json);
});

/**
 * 404エラーハンドラ（未定義ルート用）
 */
router.use((_req: Request, res: Response) => {
    res.status(404).json({
        error: "404 Not Found",
    });
});

/**
 * 500エラーハンドラ（すべてのルートの後に配置）
 */
router.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
    console.error(err.stack);
    res.status(500).send("System Error");
});

app.use("/", router);

global.dynamodbClient = new DynamoDBClient({
    region: process.env.AWS_REGION, // <===== https://qiita.com/chinone0711/items/13d57f9dc6cd13e5a2b8 の対応
});

export const handler = serverlessExpress({ app });
```

## 動作確認
再度 npm run dev を実行。
postman等のクライアントソフトで以下を設定して送信。
### トップページの動作確認
| URL | メソッド | ボディ(Json) |OK時の結果
|:-----------|:------------|:------------|:------------|
| http://localhost:3000/local/top | GET | なし | {<br />　"message": "Top Page!"<br />}<br />レスポンスが返却 |

### ユーザーの新規作成orログイン
| URL | メソッド | ヘッダー | POSTボディ(Json) |OK時の結果
|:-----------|:------------|:------------|:------------|:------------|
| http://localhost:3000/local/user | POST | signature<br />に任意の値(※) | {<br />　"user": {<br />　　"name": "山田",<br />　　"gender": "男性"<br />　}<br />} | DynamoDBへの<br />データ登録 |

### ユーザー情報の更新
| URL | メソッド | ヘッダー | PATCHボディ(Json) |OK時の結果
|:-----------|:------------|:------------|:------------|:------------|
| http://localhost:3000/local/user | PATCH | ※と同じ値 | {<br />　"user": {<br />　　"name": "鈴木",<br />　　"gender": "女性"<br />　}<br />} | DynamoDBの<br />データ更新 |

### ユーザー情報の削除
| URL | メソッド | ヘッダー | DELETEボディ(Json) |OK時の結果
|:-----------|:------------|:------------|:------------|:------------|
| http://localhost:3000/local/user | DELETE | ※と同じ値 | なし | DynamoDBの<br />データ削除 |

# 3章. バッチ処理を作ってみる

## バッチの起動設定
```diff_yaml:serverless.yml
　：
functions:
    # API処理
    api:
        handler: src/api/index.handler
        timeout: 30
        environment:
            ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
            TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
        events:
            - http:
                  method: ANY
                  path: "/"
                  cors: true
            - http:
                  method: ANY
                  path: "/{any+}"
                  cors: true

+   # バッチ処理
+   printPerMinute:
+       handler: src/batch/index.printPerMinute
+       timeout: 60
+       events:
+           - schedule: rate(1 minute)
+       environment:
+           ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
+           TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
　：
```

## バッチ処理本体の作成
srcディレクトリの下に batch ディレクトリを作りその中に index.ts を作る
```TypeScript:src/batch/index.ts
import { format } from "date-fns";

/**
 * バッチ処理サンプル
 * @param event
 */
export const printPerMinute = async (_event: any): Promise<any> => {
    console.log("1分周期で起動：" + format(new Date(), "yyyy-MM-dd HH:mm:ss"));
};
```

## バッチ処理の起動
```shell
$ npm run dev

> sample-api@1.0.0 dev
> serverless offline


Warning: Invalid configuration encountered
  at 'provider.region': must be equal to one of the allowed values [us-east-1, us-east-2, us-gov-east-1, us-gov-west-1, us-iso-east-1, us-iso-west-1, us-isob-east-1, us-west-1, us-west-2, af-south-1, ap-east-1, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2, ap-southeast-1, ap-southeast-2, ap-southeast-3, ap-southeast-4, ca-central-1, cn-north-1, cn-northwest-1, eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2, eu-west-3, il-central-1, me-central-1, me-south-1, sa-east-1]

Learn more about configuration validation here: http://slss.io/configuration-validation
Starting Offline at stage local (local)

Offline [http for lambda] listening on http://localhost:3002
Function names exposed for local invocation by aws-sdk:
   * api: sample-api-local-api
   * printPerMinute: sample-api-local-printPerMinute
Scheduling [printPerMinute] cron: [*/1 * * * *]   # <====== cron式でスケジューリングされている

   ┌───────────────────────────────────────────────────────────────────────┐
   │                                                                       │
   │   ANY  | http://localhost:3000/local                                  │
   │   POST | http://localhost:3000/2015-03-31/functions/api/invocations   │
   │   ANY  | http://localhost:3000/local/{any*}                           │
   │   POST | http://localhost:3000/2015-03-31/functions/api/invocations   │
   │                                                                       │
   └───────────────────────────────────────────────────────────────────────┘


# ↓このようなプリント文が表示されればOK

1分周期で起動：2025-01-10 17:57:00
(λ: printPerMinute) RequestId: 80332a53-98ba-4264-926c-64c4448c26ba  Duration: 38.21 ms  Billed Duration: 39 ms
Successfully invoked scheduled function: [printPerMinute]
1分周期で起動：2025-01-10 17:58:00
(λ: printPerMinute) RequestId: a741a7f3-c721-415c-9a95-bc0964f31cb3  Duration: 1.49 ms  Billed Duration: 2 ms
Successfully invoked scheduled function: [printPerMinute]
```
AWSにデプロイすると、EventBridgeが自動設定されて、定期的にLambda関数を実行するようになる。
EventBridge名は
＜プロジェクト名＞-＜環境名＞-＜Lambda関数名＞EventsRuleSchedulexxxxxxxxxxxxx となる。
例）sample-api-dev-PrintPerMinuteEventsRuleSchedule1-t7ddqi1VnUha

ちなみに、ローカルで特定の関数を手動実行したい場合には以下コマンドになる。
```shell
$ npx serverless invoke local -f <バッチ関数>
```


# 4章. 簡易的な管理画面を作ってみる
ユーザーデータをCSVでダウンロードするWebページを作成します。

## serverless.ymlの修正
```diff_yaml:serverless.yml
　：
functions:
+   # ADMIN処理
+   admin:
+       handler: src/admin/index.handler
+       timeout: 30
+       environment:
+           ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
+           TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
+       events:
+           - http:
+                 method: ANY
+                 path: "/admin"
+                 cors: true
+                 response:
+                     headers:
+                         Content-Type: "'text/html'"
+                     template: $input.path('$')

    # API処理
    api:
        handler: src/api/index.handler
　：
```

## Modelファイルの修正
src/model/User.tsに以下を追加
```TypeScript:src/Model/User.ts
    /**
     * 全てのユーザー情報を取得
     * ※DynamoDBは1回のScanの最大サイズが1MBなので、続きがある場合はLastEvaluatedKeyを使用して取得する
     * @returns
     */
    getAll = async () => {
        let items: any[] = [];
        let lastEvaluatedKey: { [key: string]: any } | undefined = undefined;

        do {
            const params: ScanCommandInput = {
                TableName: this.userTableName,
                ExclusiveStartKey: lastEvaluatedKey,
            };

            try {
                const command = new ScanCommand(params);
                const data: ScanCommandOutput = await this.client.send(command);
                if (data.Items) {
                    items = items.concat(data.Items);
                }
                lastEvaluatedKey = data.LastEvaluatedKey;
            } catch (err: any) {
                console.error("Unable to scan. Error users(5):", JSON.stringify(err, null, 2));
                throw new Error(`Unable to scan. Error users(5): ${err.message}`);
            }
        } while (lastEvaluatedKey);

        return items;
    };
```

## 必要なライブラリをインストール
```shell
$ npm i iconv-lite
$ npm i json2csv
```

### 管理画面本体のプログラムを作成
srcディレクトリの下に admin ディレクトリを作りその中に index.ts を作る
```TypeScript:src/admin/index.ts
import { Parser } from "json2csv";
import * as iconv from "iconv-lite";
import { User } from "../model/User";
import querystring from "querystring";

module.exports.handler = async (event, _context) => {
    const postData = querystring.parse(event.body);
    let jsonData;
    let outputFile = "";
    let fields: string[] = [];
    const user = new User();

    // kind別の処理
    if (postData.kind == "userdata") {
        jsonData = await user.getAll();
        console.log(jsonData);
        for (let i = 0; i < jsonData.length; i++) {
            jsonData[i].id = jsonData[i].id.S;
            jsonData[i].name = jsonData[i].name.S;
            jsonData[i].gender = jsonData[i].gender.S;
            jsonData[i].created_at = jsonData[i].created_at.S;
        }
        fields = ["id", "name", "gender", "created_at"];
        outputFile = "user.csv";

        const json2csvParser = new Parser({ fields });
        let csvData = json2csvParser.parse(jsonData);
        csvData = iconv.encode(csvData, "Shift_JIS");

        return {
            statusCode: 200,
            headers: {
                "Content-Type": "text/csv; charset=Shift_JIS",
                "Content-Disposition": "attachment; filename=" + outputFile,
                "Content-Encoding": "base64",
            },
            body: csvData.toString("base64"),
            isBase64Encoded: true,
        };
    }

    var renderedPage = renderFullPage();
    return {
        statusCode: 200,
        headers: { "Content-Type": "text/html" },
        body: renderedPage,
    };
};

function renderFullPage() {
    return `<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <link rel="icon" href="/favicon.ico">
    </head>
    <body>
        <a href="" style="text-decoration:none;" title="管理画面"><h1>管理画面</h1></a>
        <h2>ユーザーデータのダウンロード</h2>
        <form action="" method="post">
        <input type="submit" value="ダウンロード">
        <input type="hidden" name="kind" value="userdata">
        </form>
    </body>
</html>`;
}
```

## 動作確認
ブラウザで http://localhost:3000/local/admin にアクセスして以下のような画面が表示され、「ダウンロード」ボタンクリックで、user.csv がダウンロードできればOK
![admin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2348676/7372e3db-1e4c-1e71-278e-f9fa43141e8e.png)

# 参考
https://qiita.com/nozaki-amazia/items/c8c58f7647c4fec927ce

以上