AWS Lambda + Honoで外部REST APIサーバ用のプロキシサーバを書く

## プロキシサーバをHonoで作ろう

前回、[最近 Hono で外部連携用の AWS Lambda 書いています](https://qiita.com/ssc-ksaitou/items/4a1a1e437d4139b32d19)という記事を書きましたが、記事の中でユースケースとしてあげていた「**他のサービスへのデータ連携のためにAWS Lambda + Honoでプロキシサーバを書く**」という応用例は、そこそこ需要があると個人的に思っているので、今回改めて記事にしました。

https://qiita.com/ssc-ksaitou/items/4a1a1e437d4139b32d19

:::note info
本記事で書くプロキシサーバは [squid](https://www.squid-cache.org/) などの本格的なものではなく、HTTPリクエストを外部サービスに簡単に中継する程度のものです
:::

:::note info
本記事のソースコードは https://gitlab.com/ksaitou/2024-03-28_awslambdahonoproxy から入手可能です
:::

:::note info
（2025/05/16）今は Hono 側にて [Proxy Helper](https://hono.dev/docs/helpers/proxy) という機能が用意されているので、本記事のようなリバースプロキシといった題材を扱う場合は利用を検討してみるといいかもしれません
:::

## なぜ外部サービスのREST APIサーバへのプロキシサーバが必要なのか？

様々なケースで外部サービスREST APIへの連携用中継（プロキシ）を作成したいケースがあると考えられます。

- 外部連携時のリクエスト（データ）を加工したい
    - 例: Teams のチャットに外部サービスからの通知を届けたいが、Teams側が受け取れるJSONと**外部サービスのJSONの形式が合わないので一度変換を噛ませたい**
- 外部連携時のログを取得したい
    - 例: 連携先サービスの流量やエラーを把握したいので、CloudWatch Logsなどにラムダからリクエストの内容やレスポンスの内容を記録しておいて、**後から外部連携の頻度などを分析できるようにしたい**
- リクエストにリトライ耐性を付与したい
    - 例: プロキシした際に相手先のシステムがダウンしていたなどの理由でエラーとなったリクエスト内容をキューに保存しておき、**後で再度リクエストできるように備えたい**
- **連携先サービスのREST APIの認証・認可に不満がある**
    - サービス内で複数の目的別認証トークンを保持できない
        - テスト環境・QA環境・本番環境など**複数環境で認証トークンを使い分けできない**
    - 認証トークンへの**認可が強力すぎるので制限をかけたい**
        - 認証トークン一つあれば特定APIの呼び出しから全データの取得〜削除のAPI呼び出しが出来てしまう
    - 例および実装例: 本記事で紹介します

本記事では最後の例にフォーカスして実装例を紹介します。


## 実際にHonoでプロキシサーバを書いてみる

### 簡単な設計

実際にHonoで以下のように動作するプロキシサーバを書いてみます。

```mermaid
sequenceDiagram
  participant origin as 連携元サービス
  participant proxy as 今回作成するプロキシ<br>(AWS Lambda + Honoで実装)
  participant dest as 連携先となるREST API<br>(外部サービス)

  alt 正常にプロキシする場合
    origin ->> proxy: POST "/api/v1/proxied_api"<br>Authorization: "プロキシ用トークン"
    proxy ->> proxy: 認可しているAPI: OK
    proxy ->> proxy: プロキシ用トークンを確認: OK
    proxy ->> dest: POST "/api/v1/proxied_api"<br>Authorization: "REST API用トークン"
    dest ->> proxy: 201 Created など
    proxy ->> origin: 201 Created など
  end

  alt 認可外のAPIへアクセスする場合
    origin ->> proxy: POST "/api/v1/NOT_ALLOWED_API"<br>Authorization: "プロキシ用トークン"
    proxy ->> proxy: 認可しているAPI: NG
    proxy ->> origin: 403 Forbidden
  end

  alt プロキシ用トークン提示失敗の場合
    origin ->> proxy: POST "/api/v1/proxied_api"<br>Authorization: "?????!!!!!"
    proxy ->> proxy: 認可しているAPI: OK
    proxy ->> proxy: プロキシ用トークンを確認: NG
    proxy ->> origin: 403 Forbidden
  end
```

動作としては、きちんと認可されているAPIにプロキシ用トークンを提示した場合は連携先サーバにリクエストを仲介し、認可外のAPIへアクセスした場合やプロキシ用のトークンを提示できない場合は `403 Forbidden` を返却するようになっています。

**仮に認証トークンが一種類しかなく、どんなAPI呼び出しでも認可してしまうような困ったREST APIがあったとして、そのセキュリティリスクをプロキシを挟むことで緩和するものとなっていることが分かると思います。**

またAWS Lambdaを用いることで、サーバを所有せずに必要に応じて起動し最小限のコストで運用できるようになっています。実装はHonoを用いることで、AWS Lambdaにベンダーロックインされなくなっています。

### 実装

ではソースコードを見てみましょう。
（https://gitlab.com/ksaitou/2024-03-28_awslambdahonoproxy でも公開しています）

```ts:src/index.ts
import { Hono, Context } from "hono";
import { env } from "hono/adapter";

// 連携先REST APIへプロキシする関数
const proxy = async (c: Context) => {
  const {
    DISTRIBUTED_AUTH_TOKEN,
    TARGET_HOST,
    TARGET_HOST_VERY_SECRET_AUTH_TOKEN,
  } = env<{
    /** 自前の認証トークン (`Bearer XXXXXX`) */
    DISTRIBUTED_AUTH_TOKEN: string;
    /** もともとの宛先（プロキシ先） */
    TARGET_HOST: string;
    /** もともとの宛先の認証トークン (`Bearer YYYYYY`) */
    TARGET_HOST_VERY_SECRET_AUTH_TOKEN: string;
  }>(c);

  // 自前の認証トークンを提示しているかチェック
  console.log("req auth", c.req.header("Authorization"));
  if (DISTRIBUTED_AUTH_TOKEN !== c.req.header("Authorization")) {
    c.status(403);
    return c.json({ message: "Authorization token mismatched" });
  }

  // プロキシ用のリクエストを作成
  const targetHost = new URL(TARGET_HOST);
  const newUrl = Object.assign(new URL(c.req.url), {
    protocol: targetHost.protocol,
    hostname: targetHost.hostname,
    port: targetHost.port,
  });
  const newHeaders = new Headers(c.req.raw.headers);
  newHeaders.set("Authorization", TARGET_HOST_VERY_SECRET_AUTH_TOKEN);
  const req = new Request(newUrl.toString(), {
    method: c.req.method,
    headers: newHeaders,
    body: ["GET", "HEAD"].includes(c.req.method)
      ? undefined
      : await c.req.blob(),
  });

  // 連携先のAPIサーバにリクエスト発行 / 連携元にそのままレスポンス
  console.log(`Proxying to ${c.req.method} ${newUrl}...`);
  const res = await fetch(req);
  console.log(`  => ${res.status} ${res.statusText}`);
  return res;
};

// プロキシが必要なAPIエンドポイントのみプロキシを適用
export default new Hono()
  .post("/api/v1/proxied_api", proxy)
  .all("/api/v1/another_proxied_api", proxy);
```

### 実装のポイント

プロキシの実装についていくつかポイントを書いておきました。

- 基本的には定義されたAPIルートに対して、プロキシ内の認証が通ったらプロキシする素直な実装です
- Honoは独自のリクエスト・レスポンスAPIを持たず [Fetch API](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API) に登場するWeb API標準の[`Request`](https://developer.mozilla.org/ja/docs/Web/API/Request)・[`Response`](https://developer.mozilla.org/ja/docs/Web/API/Response)・[`Headers`](https://developer.mozilla.org/ja/docs/Web/API/Headers)でリクエストやレスポンスを表現しているので、その流儀に従っています[^1]
    - 連携元サービスから飛んできた `Request` オブジェクトを少し修正したものをFetchAPI(連携先サービス)に送る[^2]
    - FetchAPI(連携先サービス)から返された `Response` はそのまま連携元サービスに返す
        - FetchAPIのリクエスト・レスポンスと、サーバ(Hono)のリクエスト・レスポンスで扱う値が同じ型なので、今回のようなプロキシ用途ではオブジェクトをそのまま渡したりして、**ちょっと楽できる**ことが分かります
- 同様にURLのホスト部の書き換えも自作処理で頑張るのではなく標準の `URL` クラスを使ってホスト部のみを書き換えています
- プロキシリクエスト内容の `body: await c.req.blob()` の部分は Node.js では `body: c.req.raw.body, duplex: "half"` と指定することも可能です
    - Bun 1.0.35 ではこれは動作しませんでした。なので今回の例では元のリクエストのBlobを一旦受信してからそれをそのままプロキシするリクエストに詰め込んでいます

[^1]: 別に `fetch` に渡すリクエスト情報については明示的に `new Request(...)` せずともコンストラクタに渡す引数を `fetch` にそのまま渡しても構いませんが、本記事では説明のためにわざわざ `new Request(...)` しています
[^2]: ソースコード中では `c.req` （`Request`のラッパオブジェクト） を使っていますが、そのままの`Request`オブジェクトは `c.req.raw` でアクセスできます


## Honoとはそもそも何なのか

そもそもHonoで実装するHTTPサーバ `const app = new Hono()` は、雑にいってしまえばインターネットに繋がらないけれども、自作したAPIルートにはつながる、Fetch APIのモック実装みたいなものです。[^3]

[^3]: [Hono fetchの引数](https://hono.dev/api/hono#fetch)は本来の[Fetch API](https://developer.mozilla.org/ja/docs/Web/API/fetch)と違いますが、第一引数にRequestを取り、返り値としてResponseを返し、fetchという名前なので、同一視できるような存在ということでいいと考えます

```ts:src/honotest.test.ts
import { describe, expect, test } from "vitest";
import { Hono } from "hono";

describe("hono fetch", async () => {
  // Honoのサーバを作成＆fetch(本体)を取り出す
  const app = new Hono().get("/hello", async (c) => new Response("World"));
  const { fetch } = app;

  // fetch を用いてFetch APIと同じ感覚でリクエストを投げてみる
  test("/hello", async () => {
    const res = await fetch(new Request("http://example.com/hello"));
    expect(await res.text()).toBe("World");
  });

  test("/should_not_be_found", async () => {
    const res = await fetch(
      new Request("http://example.com/should_not_be_found")
    );
    expect(res.status).toBe(404);
  });
});
```

ちなみにこうやってHonoで書いたサーバを `export default` したソースをBunで実行するとWebサーバが起動するわけですが、

```ts:src/index.ts
export default (new Hono().get("/hello", async (c) => new Response("World")))
```

```bash
$ bun --hot src/index.ts
Started server http://localhost:3000
```

これはHonoが特別何かをしているわけではなく、Bun側で `fetch` をエクスポートしているソースを実行する場合、[自動的に `fetch` をWebサーバとして起動してくれる機能](https://bun.sh/docs/api/http)があるお蔭です。ちなみに[Cloudflare Workersも同様の仕様](https://developers.cloudflare.com/workers/runtime-apis/fetch/)です。

ではAWS Lambdaはというと、そのような便利仕様ではないため、`hono/aws-lambda`でラップしてあげる必要があったのでした。

```ts:src/lambda.ts
import { handle } from 'hono/aws-lambda'
import app from "./index"

// Honoをラップし、 handler としてエクスポート
export const handler = handle(app)
```

結局このAWS Lambdaのアダプタがやっていることは、Honoの `fetch` に食べさせる `Request` オブジェクトを [ラムダのイベント引数から作成](https://github.com/honojs/hono/blob/13039a9526d032f854901888473567a4bd8a6c08/src/adapter/aws-lambda/handler.ts#L196)、`fetch` から返された `Response` オブジェクトを[ラムダの結果オブジェクトに変換](https://github.com/honojs/hono/blob/13039a9526d032f854901888473567a4bd8a6c08/src/adapter/aws-lambda/handler.ts#L170)しているわけです。 `fetch` 用のアダプタですね。

```mermaid
sequenceDiagram
  participant fp as Function URL
  participant lambda as AWS Lambda 
  participant adaptor as hono/aws-lambda
  participant app as app.fetch<br>(new Hono())

  fp ->> lambda: HTTPリクエスト
  lambda ->> adaptor: HTTPリクエストから生成した `ev` オブジェクト
  adaptor ->> app: `ev` オブジェクトをWebAPI標準のRequestに変換
  app ->> adaptor: WebAPI標準のResponseオブジェクトを返却
  adaptor ->> lambda: AWS Lambdaの結果オブジェクトに変換
  lambda ->> fp: HTTPレスポンス
```

別のサーバ上にHonoを載せる場合も同様にRequest・Responseオブジェクトをサーバネイティブなオブジェクトとの間で変換してあげれば実装できることがわかります。


## まとめ

Honoでの簡単なプロキシサーバの書き方について触れました。それによりAWS Lambdaと組み合わせて外部APIセキュリティの脆弱さを補強する実装例を示しました。

また、Hono自体がWeb標準なFetchAPIをベースにしていることもあり、開発が楽になることも分かりました。

みなさんもどんどん小さなサーバを書いてみましょう。