AWS AmplifyでTypeScriptとExpressを使ったLambda関数のRESTful APIを作る方法

はいさい！ちゅらデータぬオースティンやいびーん！

# 概要
AWS AmplifyのCLIを通して、API GatewayとLambdaを用いたRESTful APIを作る際に、サーバーレスのExpressと、TypeScriptを使う方法を紹介します。

## 背景
まず、サーバーレスのExpressをなぜLambdaで使うのかですが、単純に筆者は、Expressに慣れており、Expressならさっさと書けるからです。筆者と同様な作者はいるかと思いますが、いかがでしょうか？

その他に、Expressで書くと、Lambdaを脱出する必要があったら、コードを再利用することもできるので、おすすめです。

実際、Expressをサーバーレスで実行するために、オープンソースのプロジェクトがあり、現役で開発が進んでいます。それほど人気な書き方・実装方法だと筆者は捉えています。

https://github.com/vendia/serverless-express

そして、なぜTypeScriptをLambdaに導入するかですが、こちらは、コードの品質を上げるためです。型がないバックエンド開発を筆者はしたくないのです。JavaScriptだけだと、バックエンド開発においてはどうしても物足りなさを感じます。

ただ、Amplify CDKを使っているプロジェクトにおいて、どうやってTypeScriptを導入できるかが明確でないので、正式ドキュメントも混ぜて解説していきたいと思いました。

Amplifyプロジェクトをお持ちでない方は、前回の記事でまずAmplifyのプロジェク開設をしてから本記事をお読みになることをお勧めします。

https://qiita.com/tronicboy/items/24f8a17394628d9a769f

# RESTFul APIをAmplifyプロジェクトに追加する
まずは、Amplify CLIを使って、RESTFul APIを追加します。Amplify CLIで追加すると、テンプレートのコードを生成してくれるので、出発しやすくなります。

以下のコマンドを実行して、追加の設定を始めます。
```
amplify add api
```
最初のダイアログで`REST`を選択します。
![スクリーンショット 2022-08-01 15.59.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/00e56f3e-a21c-3e7c-d492-7689939b4c4d.png)
APIのリソース名を付け、APIのパスも設定します。今回は便宜的に`/todos`にしますが、任意です。
![スクリーンショット 2022-08-01 17.07.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/4021095a-9152-9801-7c1b-cfcae043a5bd.png)
次はLambda関数の関数名を設定します。
![スクリーンショット 2022-08-01 17.08.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/1c272bab-18a2-8206-396c-bdbc7bd2d5da.png)
そして、Runtimeの選択では、`NodeJS`を選択し、templateでは、`Serverless ExpressJS`を選択します。
![スクリーンショット 2022-08-01 17.09.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/6a11b70f-15b4-0f97-93f4-0b9f00fcc49d.png)
詳細設定は`n`で大丈夫なのですが、仮に`y`を入力すると、Lambda関数のLayers、Secret Managerのアクセス、環境変数など、様々な設定ができます。

ちなみに、これらの設定は後からもできるのでこの段階でやらなくてもいいです。
![スクリーンショット 2022-08-01 17.11.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/78700e5d-907d-1428-b696-4bda21f3987c.png)

次、APIへのアクセスに認証を必要とするかの質問ですが、こちらはCognitoのUser Poolと併用している場合、Cognitoで認証を経ているユーザーのみAPIへアクセスできるように制限することができます。

今は制限しませんが、公開されるので、ご注意ください。
![スクリーンショット 2022-08-01 17.13.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/c6e1914b-866d-6ea5-0bd5-39b3eabf7997.png)
他のパスも追加するか聞かれますが、不要なので、`n`
![スクリーンショット 2022-08-01 17.16.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/ae636707-ac93-43fc-e81a-f53faa2546ab.png)
すると、ローカルの設定にAPIの設定が入りましたが、AmplifyのAWSクラウドに反映されていないので、以下のコマンドを実行して反映させます。
```
amplify push
```
実行すると、今回加える変更（API GatewayとLambda関数の追加）が表示されます。`y`で実行してみましょう。
![スクリーンショット 2022-08-01 17.17.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/8cfe53e0-85b5-b6de-0455-190004d6be95.png)
成功すると、API Gatewayのエンドポイントが出力されます。
![スクリーンショット 2022-08-01 17.22.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/746b3764-de05-9b04-1f11-0f3a23508847.png)
アクセスしてみるとLambda Expressのテンプレートのままのレスポンスが来ます。

ターミナルに出力されたエンドポイントに`/todos`を足さないと、`missing token`というエラーが返ってくるのでご注意！
![スクリーンショット 2022-08-01 17.23.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/9bae36fd-c115-d8ca-5908-943b46ca08b2.png)
これでAPIの開設は終わり、TypeScriptを導入する準備ができました。

# ExpressのLambda関数にTypeScriptを追加する
これからは、上記のAPI追加で生成されたLambda関数にTypeScriptを導入していきます。ここからの説明は、Amplify正式ドキュメントを参考にしています。

https://docs.amplify.aws/cli/function/build-options/

## Lambda関数用の`package.json`と`tsconfig.json`を作成する
まずは、TypeScriptをコンパイルするためのパッケージをインストールする必要があるので、先ほど生成されたLambda関数のダイレクトリに`cd`で入りましょう。

新しいLambda関数は`./amplify/backend/function`の中に入っています。筆者の場合は以下のようなコマンドを実行しました。
```
cd amplify/backend/function/AustinTSExpressLambdaFunc/
```
`cd`ができたら、`package.json`を作ります。
```
yarn init
```
そして、必要なパッケージをインストールします。
```
yarn add -D typescript @types/node @types/express @types/aws-serverless-express esbuild express body-parser aws-sdk @vendia/serverless-express
```
`tsconfig.json`も同じダイレクトリに作成します。
```
touch tsconfig.json
```
中身は以下の推奨設定でいいかと思います。AWSのLambdaでTypeScriptを使用する方法について解説する正式ドキュメントから拝借させていただいています！

`baseUrl`および`include`の設定では`/lib`というフォルダーを指していますが、これから作ります。ここにTypeScriptのファイルを入れていきます。
```json:amplify/backend/function/AustinTSExpressLambdaFunc/tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "strict": true,
    "preserveConstEnums": true,
    "noEmit": false,
    "sourceMap": false,
    "module":"commonjs",
    "moduleResolution":"node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "outDir": "./src",
    "allowJs": true,
    "lib": ["dom", "es6"],
    "baseUrl": "./lib",
  },
  "exclude": ["node_modules"],
  "include": ["./lib/**/*.ts"],
}
```

## `/lib`に既存のExpressの`.js`ファイルを移動し、`.ts`に変換する
次に行うのは、テンプレートで生成されたExpressの`.js`ファイルを`/lib/*.ts`に移動しつつ、拡張子を変えることです。
```
mkdir lib
mv src/*.js lib/
cd lib
for f in *.js; do mv -- "$f" "${f%.js}.ts"; done
cd ..
```
二つのファイルのために上記のようなコマンドはOver killだと思いますが...それはさておき、これでは必要なファイルを`src`から抽出できたので、残っているファイルは削除しましょう。

`src`のフォルダー自体は必要なので、再度作っておきます。
```
rm -rf src/
mkdir src
```

## ExpressのテンプレートのTypeScriptエラーを解消する
これからは、テンプレートのファイルをTypeScriptらしい書き方に直すのと、エラーを解消していきます。
元々あったapp.jsからコメントを削除すればこうなります。
```typescript:amplify/backend/function/AustinTSExpressLambdaFunc/lib/app.ts
const express = require('express')
const bodyParser = require('body-parser')
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware')

const app = express()
app.use(bodyParser.json())
app.use(awsServerlessExpressMiddleware.eventContext())

app.use(function(req, res, next) {
  res.header("Access-Control-Allow-Origin", "*")
  res.header("Access-Control-Allow-Headers", "*")
  next()
});


app.get('/todos', function(req, res) {
  res.json({success: 'get call succeed!', url: req.url});
});

app.get('/todos/*', function(req, res) {
  res.json({success: 'get call succeed!', url: req.url});
});

app.post('/todos', function(req, res) {
  // Add your code here
  res.json({success: 'post call succeed!', url: req.url, body: req.body})
});

app.post('/todos/*', function(req, res) {
  // Add your code here
  res.json({success: 'post call succeed!', url: req.url, body: req.body})
});


app.put('/todos', function(req, res) {
  // Add your code here
  res.json({success: 'put call succeed!', url: req.url, body: req.body})
});

app.put('/todos/*', function(req, res) {
  // Add your code here
  res.json({success: 'put call succeed!', url: req.url, body: req.body})
});


app.delete('/todos', function(req, res) {
  // Add your code here
  res.json({success: 'delete call succeed!', url: req.url});
});

app.delete('/todos/*', function(req, res) {
  // Add your code here
  res.json({success: 'delete call succeed!', url: req.url});
});

app.listen(3000, function() {
    console.log("App started")
});

module.exports = app
```
これを以下のように修正します。
```typescript:amplify/backend/function/AustinTSExpressLambdaFunc/lib/app.ts
import express from "express";
import bodyParser from "body-parser";

const app = express();
app.use(bodyParser.json());

app.use(function (req, res, next) {
  res.header("Access-Control-Allow-Origin", "*");
  res.header("Access-Control-Allow-Headers", "*");
  next();
});

app.get("/todos", function (req, res) {
  res.json({ success: "get call succeed!", url: req.url });
});

app.get("/todos/*", function (req, res) {
  res.json({ success: "get call succeed!", url: req.url });
});

app.post("/todos", function (req, res) {
  res.json({ success: "post call succeed!", url: req.url, body: req.body });
});

app.post("/todos/*", function (req, res) {
  res.json({ success: "post call succeed!", url: req.url, body: req.body });
});

app.put("/todos", function (req, res) {
  res.json({ success: "put call succeed!", url: req.url, body: req.body });
});

app.put("/todos/*", function (req, res) {
  res.json({ success: "put call succeed!", url: req.url, body: req.body });
});

app.delete("/todos", function (req, res) {
  res.json({ success: "delete call succeed!", url: req.url });
});

app.delete("/todos/*", function (req, res) {
  res.json({ success: "delete call succeed!", url: req.url });
});

app.listen(3000, function () {
  console.log("App started");
});

export default app;
```
`index.js`で以下のようになっていますが、こちらも修正します。
```typescript:amplify/backend/function/AustinTSExpressLambdaFunc/lib/index.ts
const awsServerlessExpress = require('aws-serverless-express');
const app = require('./app');

/**
 * @type {import('http').Server}
 */
const server = awsServerlessExpress.createServer(app);

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */
exports.handler = (event, context) => {
  console.log(`EVENT: ${JSON.stringify(event)}`);
  return awsServerlessExpress.proxy(server, event, context, 'PROMISE').promise;
};
```
`aws-serverless-express`ではなく、元々の`@vendia/serverless-express`を使うようにしていますが、これはAWSに依存するものを減らすためです。
```typescript:amplify/backend/function/AustinTSExpressLambdaFunc/lib/index.ts
import serverlessExpress from "@vendia/serverless-express";
import app from "./app";

exports.handler = serverlessExpress({ app });
```
これで全てのエラーは解消されているので、ビルド手順に入ります！

## `esbuild`でLambda関数のTypeScriptコードをコンパイルする
ここまでくると、あとはビルドするのみ！

AWS正式ドキュメントでは、<a href="https://esbuild.github.io/">esbuild</a>の使用が推奨されているので、こちらでもそうします。

先ほど、新しく作成した`package.json`の`scripts`に以下のコマンドを追加します。
```json:amplify/backend/function/AustinTSExpressLambdaFunc/package.json
{
  "name": "AustinTSExpressLambdaFunc",
  "version": "1.0.0",
  "main": "src/index.js",
  "license": "MIT",
  "devDependencies": {
    "@types/aws-serverless-express": "^3.3.5",
    "@types/express": "^4.17.13",
    "@types/node": "^18.6.3",
    "@vendia/serverless-express": "^4.10.1",
    "aws-sdk": "^2.1185.0",
    "body-parser": "^1.20.0",
    "esbuild": "^0.14.51",
    "express": "^4.18.1",
    "typescript": "^4.7.4"
  },
  "scripts": {
    "build": "esbuild ./lib/index.ts --bundle --minify --external:aws-sdk --sourcemap --platform=node --target=es2020 --outfile=./src/index.js"
  }
}
```
ここで重要なのは、`package.json`では`aws-sdk`のパッケージをインストールしていますが、これはTypeScriptのインタプリターのためのみで、バンドルに含めたくないことです。
それゆえに、esbuildのコマンドで`--external:aws-sdk`と、バンドルから排除しています。

なぜかというと、`aws-sdk`はLambda関数のNodeJS環境なら、インストールせずに使うことができます。また、含めると、<strong>ものすごく大きくなり、重くなります</strong>。

# デプロイできるようにする
最後に、`amplify push`のコマンドを実行した時に、自動的にビルドしてくれるようにします。

## ルート・ダイレクトリーの`package.json`に`script`を追加する
Amplifyプロジェクトのルートダイレクトリにある`./package.json`に特別な`script`コマンドを追加する必要があります。

ありがたいことに、正式ドキュメントにそのやり方が書いてあります。

しかし、注意していただきたいことがあります。複数の`package.json`があります。これから追加する`script`は、Lambda関数の`amplify/backend/function/AustinTSExpressLambdaFunc/package.json`ではなく、<strong>ルートダイレクトリーにある主要な`./package.json`に加えなければなりません</strong>。

筆者は、これを理解できずに数時間を無駄にしながらも懸命にパソコンとAmazonを憎み続けました。

```json:package.json
{
  "name": "amplify-qiita",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "webpack --config webpack.prod.config.js",
    "start": "webpack serve",
    "amplify:AustinTSExpressLambdaFunc": "cd amplify/backend/function/AustinTSExpressLambdaFunc/ && yarn build"
  },
  "devDependencies": {
    "@babel/core": "^7.18.9",
    "@babel/preset-env": "^7.18.9",
    "babel-loader": "^8.2.5",
    "file-loader": "^6.2.0",
    "html-webpack-plugin": "^5.5.0",
    "mini-css-extract-plugin": "^2.6.1",
    "ts-loader": "^9.3.1",
    "typescript": "^4.7.4",
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.9.3",
    "webpack-manifest-plugin": "^5.0.0"
  }
}
```
`amplify:<Lambda関数名>`という名前のscriptを追加し、先ほど追加した`build`のコマンドを実行するようにします。

## Amplifyのバックエンドをプッシュしてみる
Lambda関数のソースコードを自動的にコンパイルしてくれる設定ができたので、うまくデプロイできるか試してみましょう。
```
amplify push
```
Lambda関数の`Function`の変更を検知してくれているので、`y`を入力してプッシュします。
![スクリーンショット 2022-08-02 9.00.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/48b977dd-2f3b-21c8-64e4-00e360899393.png)
### 問題発生！M1 Macbookをご利用の皆様ご注意を。
筆者はApple SiliconのARMチップで開発していますが、上記のコマンドを実行すると以下のようなエラーが出ます。
![スクリーンショット 2022-08-02 9.03.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/7e7ec876-af97-6d2c-40be-4c0837cad809.png)
原因は、`Amplify CLI`にあります。

筆者の場合は、Amplify CLIが`Intel`のプロセスで`Rosetta`に翻訳されながら実行されており、`darwin-64`の環境になっているのに対して、ローカルで実行する`yarn`は、M1ネイティブで`darwin-arm64`の環境になっているのです。

これでバグるわけです。

対応策は以下のように、`package.json`の`script`にインストール済みのパッケージを削除して再インストールするコマンドを追加することです。
```json:package.json
{
  "name": "amplify-qiita",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "webpack --config webpack.prod.config.js",
    "start": "webpack serve",
    "amplify:AustinTSExpressLambdaFunc": "cd amplify/backend/function/AustinTSExpressLambdaFunc/ && rm -rf node_modules && yarn install && yarn build"
  },
  "devDependencies": {
    "@babel/core": "^7.18.9",
    "@babel/preset-env": "^7.18.9",
    "babel-loader": "^8.2.5",
    "file-loader": "^6.2.0",
    "html-webpack-plugin": "^5.5.0",
    "mini-css-extract-plugin": "^2.6.1",
    "ts-loader": "^9.3.1",
    "typescript": "^4.7.4",
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.9.3",
    "webpack-manifest-plugin": "^5.0.0"
  }
}
```
上記の変更を加えた上でもう一度`amplify push`を実行してみます。
![スクリーンショット 2022-08-02 9.17.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/a30f1a33-c57d-d31f-3cbc-a9181624cd47.png)
無事に成功しました！よかったさ

M1、好きだけど、時々困りますねぇ。:sweat_smile:

## エンドポイントにアクセスしてみる
デプロイできたわけですので、エンドポイントにもう一度アクセスしてみましょう。問題なくデプロイできていれば、依然と全く同じレスポンスが帰ってくるはずです。
![スクリーンショット 2022-08-02 9.20.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/3c4fa7b5-ab8a-6a00-2f89-1166f73c1893.png)
おお！TypeScript、使えるぞ！かりゆし:star:

Lambda関数のコンソールも確認してみると、esbuildらしいコードが`index.js`に入っています。
![スクリーンショット 2022-08-02 9.25.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/b50f1edf-c33c-bc96-c2b0-4f9146702ba8.png)

# まとめ
これまで、Amplify CLIでRESTful APIを作り、Expressを使ったLambda関数のソースコードをTypeScriptに変換して、それを`amplify push`でコンパイルできるようにしてきましたが、いかがでしょうか？

やはり、TypeScriptは筆者の幸せの源ですので、どこでもいつでも便利に使えるようにしたいのですが、Amplifyのバックエンドでも使えるのはこの上なく喜ばしいことです。

筆者と同じくTypeScript愛好家もいらっしゃると思いますので、どんどん色んなプロジェクトに入れていきましょう！