【Hono】API Gateway + Hono を使って、Lambda1つでバックエンドAPIを全部作ってみた

# はじめに
バックエンドをAPI Gatewayに任せる時にめんどくさいのが、「複数のパスの管理」です。
各パスに対応するlambdaを書いたり、また、IaCのコード上で各パスをだらっと定義しまくったり・・・（for_eachなど工夫の余地はあると思いますが）。
そういうの面倒なので、今回は```Lambdalith``` という手法を試してみます。
Lambdalithとは、**1つのlambdaで複数のルート（パス）を処理しよう**というlambdaの設計スタイルです！
簡単にLambdalithを実現できるhonoというライブラリを使って、楽に実装してみます！！

![Lambdalith.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/ae5138f3-c46e-493c-93ba-cf7e250347ca.png)

※参考
Lambdalithについてまとまっている読みやすい記事です

https://serverless.co.jp/blog/5o3au2eyp/

# やってみた
## アーキ
![アーキ図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/b06cedf8-ddcc-4c45-b448-cb8f0839aad0.png)

今回のアーキです。
API Gatewayと、lambdaが**1つ**です！
Lambdalithを実現するために、lambda上のコードは```hono``` で実装します。

honoについては後述します。とりあえずドキュメント貼っておきます。

https://hono-ja.pages.dev/docs/

## リポジトリ

参考になるかわかりませんが、今回作業したリポジトリです。

https://github.com/suiwave/hono-aws-hello-world

リポジトリの概要は以下

## ①honoでlambdaのコードを準備する
まずは今回の主役であるhonoというライブラリでlambdaを実装していきます！

### honoのプロジェクトを準備
```package.json
{
  "name": "function",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "esbuild src/handler.ts --bundle --platform=node --outdir=dist"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "hono": "^4.7.8"
  },
  "devDependencies": {
    "@hono/node-server": "^1.14.1",
    "esbuild": "^0.25.3",
    "tsx": "^4.19.4"
  }
}
```

上記```package.json``` を参照し、```pnpm i``` していきます。

```bash
pnpm i
```

登場するライブラリを軽く紹介します。
**hono**
この記事の主役です。
REST APIサーバーを簡単に構築できるnodejsのライブラリです！！
ポータビリティが売りの一つで、今回やるように、lambdaなどにも簡単にのせることができます！！！

他にもいろいろ凄いです。やばいっす。
日本語版もあるくらい公式Docがフレンドリーで、よくまとめてくれています（AI向けのマークダウン形式ページもあったりしてそこも良い）。
この記事では紹介しきれないので、ぜひ読んでみてください。

https://hono-ja.pages.dev/docs/

**@hono/node-server**
honoをローカルに立てるためのライブラリです。

**esbuild**
tsをjsにトランスパイルするライブラリです。tscより速いらしいです。

**tsx**
tsを実行するライブラリです。ts-nodeより速いらしいです。

### 色々なルートをさばくhonoインスタンスを準備
では早速honoでREST APIを作っていきます。
中身はなんでもいいので、公式Docからコピペして以下のような構成にしました。

```
.
└── function
    ├── package.json
    └── src
        ├── app.ts
        ├── handler.ts
        ├── index.ts
        └── routes
            ├── book.ts
            └── user.ts
```

1つずつ見ていきます！

#### src/routes/book.ts
APIGatewayの「/book」パスに一致するリクエストをさばくAPIを定義します。
複数のルーティングをまとめる場所で「/book」に対応させます。

```src/routes/book.ts
import { Hono } from 'hono'

const book = new Hono()

book.get('/', (c) => c.text('List Books')) // GET /book
book.get('/:id', (c) => {
    // GET /book/:id
    const id = c.req.param('id')
    return c.text('Get Book: ' + id)
})
book.post('/', (c) => c.text('Create Book')) // POST /book

export default book
```

#### src/routes/user.ts
```src/routes/book.ts``` と同じです。置換しただけですね。

```src/routes/user.ts
import { Hono } from 'hono'

const user = new Hono()

user.get('/', (c) => c.text('List Users')) // GET /user
user.get('/:id', (c) => {
    // GET /user/:id
    const id = c.req.param('id')
    return c.text('Get User: ' + id)
})
user.post('/', (c) => c.text('Create User')) // POST /user

export default user
```

#### src/app.ts
上記で作成した複数のルーティングインスタンスを統合するファイルです。
ルートが増えるたびにここに追加していくイメージです。

```src/app.ts
import { Hono } from 'hono'

import book from './routes/book'
import user from './routes/user'

const app = new Hono()

app.route('/book', book)
app.route('/user', user)

export default app
```

#### src/handler.ts
上記で作成した、メインのインスタンスをlambda用のラッパーでつつんでexportするファイルです。
記載量の少なさがやばいですね。lambdaにはこのコードを載せます。これだけで良いのがビックリです。

```src/handler.ts
import { handle } from 'hono/aws-lambda'

import app from './app'

export const handler = handle(app)
```

#### src/index.ts
lambdaには直接は不要です。
テスト用にlocalhostで上記の**src/app.ts**をREST APIサーバーとしてたてるためのファイルです。
```src/index.ts
import { serve } from '@hono/node-server'
import app from './app'

const port = 3000
console.log(`Server is running on http://localhost:${port}`)

serve({
    fetch: app.fetch,
    port
})
```

```package.json
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "esbuild src/handler.ts --bundle --platform=node --outdir=dist"
  },
```

```bash
"dev": "tsx watch src/index.ts",
```

これで動きます。

```bash
> pnpm run dev
Server is running on http://localhost:3000
```

動かしてlocalでテストします！

```bash
>curl localhost:3000/user
List Users
>curl localhost:3000/user/1234
Get User: 1234
>curl -X POST localhost:3000/user
Create User
>curl -X POST localhost:3000/book
Create Book
>
```

良い感じですね！！

### jsへトランスパイル
紹介したesbuildでトランスパイルします。

```package.json
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "esbuild src/handler.ts --bundle --platform=node --outdir=dist"
  },
```

```bash
> pnpm run build

> function@1.0.0 build C:/work/repo/hono-aws-hello-world/function
> esbuild src/handler.ts --bundle --platform=node --outdir=dist


  dist/handler.js  55.1kb

Done in 71ms
```

爆速！！！

## Lambda + API Gatewayを準備する
### terraformでさっくりと
リソースの作成自体はterraformでさくっと終わらせます。
上記jsをのせたlambdaと、空のAPI Gatewayリソースを作成します。
つなげるところだけ手でやりましょう！

<details><summary>main.tf (長いので略)</summary>

```main.tf
############################################################################
## terraformブロック
############################################################################
terraform {
  # Terraformのバージョン指定
  required_version = "~> 1.7.0"

  # Terraformのaws用ライブラリのバージョン指定
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.33.0"
    }
  }
}

############################################################################
## providerブロック
############################################################################
provider "aws" {
  # リージョンを指定
  region = "ap-northeast-1"
}

locals {
  project = "hono_aws_hello_world"
  dir_path = "${path.module}/../function/dist"
}

############################################################################
## lambda
############################################################################
/* lambda実行ロール */
# lambda用AWSマネージドポリシーを準備
# ロギング用ポリシードキュメント
data "aws_iam_policy_document" "logging" {
  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = ["${aws_cloudwatch_log_group.lambda.arn}:*"]
  }
}

# ロギング用ポリシー
resource "aws_iam_policy" "logging" {
  name        = "lambda-logging-policy"
  description = "IAM policy for Lambda to write logs to CloudWatch"
  policy      = data.aws_iam_policy_document.logging.json
}

# lambda assume用ポリシードキュメント
data "aws_iam_policy_document" "lambda_assume_role" {
  statement {
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

# lambda用IAMロール作成
resource "aws_iam_role" "lambda_execution_role" {
  name               = "my-lambda-execution-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}

# lambda用IAMロールへポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "lambda_logs_attach" {
  role       = aws_iam_role.lambda_execution_role.name
  policy_arn = aws_iam_policy.logging.arn
}

# ロググループを作成
resource "aws_cloudwatch_log_group" "lambda" {
  name              = "/aws/lambda/${local.project}_lambda"
  retention_in_days = 14
}

# zipを作成
data "archive_file" "lambda_my_function" {
  type             = "zip"
  output_file_mode = "0666"
  source_dir       = local.dir_path
  output_path      = "${local.dir_path}.zip"
}

/* lambda関数 */
resource "aws_lambda_function" "lambda" {
  function_name = "${local.project}_lambda"
  role          = aws_iam_role.lambda_execution_role.arn

  runtime  = "nodejs20.x" # TODO:Terraform古い
  filename = data.archive_file.lambda_my_function.output_path
  handler  = "handler.handler"

  logging_config {
    log_format = "Text"
    log_group  = aws_cloudwatch_log_group.lambda.name
  }

  # Terraformに変更を無視させるため、lifecycle ルールを追加
  lifecycle {
    ignore_changes = [filename, source_code_hash]
  }
}

# API Gateway
resource "aws_api_gateway_rest_api" "rest" {
  name = "${local.project}_rest_api"
}

# lambdaをAPI Gatewayから実行できるように許可する
resource "aws_lambda_permission" "apigw_lambda" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_api_gateway_rest_api.rest.execution_arn}/*/*"
}
```
</details>


### API GatewayとLambdaをくっつける
#### API Gatewayのプロキシリソース機能とは

複数のパスをAPI Gatewayに設定する場合、個々にメソッドを設定する必要があると思います。
ただ今回は、すべてのリクエストを1つのLambdaでまとめて処理したいので、APIGatewayの「プロキシリソース機能」を使って、全パスのリクエストを1つのLambda関数に集約する構成にしてみます。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html

#### マネコンから操作してみる
##### プロキシリソース作成画面へ
![cap1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/8d1a1d55-85e0-4f34-a211-a62da0d2c056.png)

作成したAPI Gatewayリソースを表示し、「リソースを作成」をクリックします

##### プロキシリソースとして定義する
![cap2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/d0cd1e65-6031-4d99-8988-5c125bad1ca7.png)

「プロキシのリソース」トグルをチェックします。

##### リソース名を設定する
![cap3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/1f716b83-be46-48f7-b985-20fde51948e3.png)

リソース名が求められるので定義します。文字列に意味はないため、例示されている```{proxy+}``` を設定します。
設定は以上なので、「リソースを作成」をクリックします。

##### リソース作成結果を確認する
![cap4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/cde8a749-7183-4cfe-8922-52059bbe37fd.png)

良い感じですね。
ただ、メソッドの統合タイプが設定なしになっています。
リクエストはキャッチしますが、そのリクエストの伝搬先が設定されていない状況です。

##### メソッド設定画面へ
![cap4_2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/bb67dd1c-86e5-4797-9ed5-a9f591974d2b.png)

「ANY」をクリックします。

##### バックエンドリソースが未定義であることを確認する
![cap5.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/29f65c8f-ccd3-46de-9bec-9eab326e96f0.png)

未定義ですね。

##### 統合先設定画面へ
![cap5_2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/89f9a819-e8ff-4373-aa99-a86777fff69d.png)

「統合を編集」をクリックします。

##### Lambdaとプロキシ統合するよう設定する
![cap6.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/8cb09e0d-8f58-4c26-b136-1a774c67daa5.png)

「Lambdaプロキシ統合」トグルをオンにします。
honoは単体でもREST APIとして必要なレスポンスヘッダーやbodyを定義可能なので、プロキシ統合でOKです。ここら辺も簡単で素晴らしいですね。

##### 作成したLambdaを指定する
![cap7.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/f2dfcadb-c20c-4511-8fbb-0639a7cce90a.png)

作成したlambdaをドロップダウンから選択して、「保存」をクリックします。

##### 喜ぶ
![cap8.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/07d7d6d7-3cde-4369-b01d-de1ada68f1ac.png)

設定が完了しました。これだけで全てのパスがさばけます！！！！
今までIaCの中でだらだらパスを定義していたのはなんだったのでしょうか、最高！！！!


## 動作確認する！
### GETしてみる
```
>curl https://ilm77o6qdl.execute-api.ap-northeast-1.amazonaws.com/dev/user
List Users
>curl https://ilm77o6qdl.execute-api.ap-northeast-1.amazonaws.com/dev/user/1234
Get User: 1234
>
```

良い感じ！

### POSTしてみる
```
>curl -X POST https://ilm77o6qdl.execute-api.ap-northeast-1.amazonaws.com/dev/user
Create User
>curl -X POST https://ilm77o6qdl.execute-api.ap-northeast-1.amazonaws.com/dev/book
Create Book
```

最高！

### 存在しないパスを指定してみる
```
>curl https://ilm77o6qdl.execute-api.ap-northeast-1.amazonaws.com/dev/test
404 Not Found
```

なんとなにもしていないのにfallbackなレスポンスも返却してくれます！優しい

# おわりに
バックエンドも作らないと！でもめんどくさい！
テストも書きたいとなるとコードは軽量でローカルで動くようにしたい！でもめんどくさい！
めんどくさい！けどlambdaへのデプロイとか、その後のAPI Gatewayとの統合とか、更にいうとそれらのIaC管理も楽にしたい！！でもめんどくさい！

このような色んな欲求から、今回はhonoを試してみました。めっちゃ楽しかったです！