NestJS on AWS Lambda via API Gateway で ZIP バイナリを返却　

### はじめに
NestJSで実装したAPIを通じてZIPファイルのダウンロード機能を実装した後、Lamda上にデプロイし、API Gateway経由でそのファイルを適切にダウンロード可能にする方法について記述します。

### APIの実装

- NestJSのAPIをlambdaで動かすために [serverless-express](https://github.com/CodeGenieApp/serverless-express)を利用

以下のようにbootstrapを実装し、API Gateway経由でLambdaからZIPをダウンロードできるようにします。

```
async function bootstrap(): Promise<Handler> {
  const app = await NestFactory.create(AppModule);
　// ...
  await app.init();
  const expressApp = app.getHttpAdapter().getInstance();
  return serverlessExpress({
    app: expressApp,
    binarySettings: {
      isBinary: ({ headers }: { headers: Record<string, string> }) => {
        const result =
          Boolean(headers) &&
          Boolean(headers['content-type']) &&
          headers['content-type'].includes('application/zip');
        return result;
      },
      contentTypes: ['application/zip'],
    },
  });
}
```

この設定により、Lambdaのレスポンスでcontent-typeがapplication/zipの際には、バイナリレスポンスをBase64エンコードして返すようになります。API Gatewayはバイナリを直接扱えないため、この処理が必要です。
[AWS公式ドキュメント](https://docs.aws.amazon.com/apigateway/latest/developerguide/lambda-proxy-binary-media.html)でも記載されています。

>To return binary media from an AWS Lambda proxy integration, base64 encode the response from your Lambda function.


Controllerの実装をします。`StreamableFile`を返却するのみです。

```
  @Get(':id/downloadZip')
  downloadZip(
    @Param('id') id: string
  ): Promise<StreamableFile> {
    // ...
    return new StreamableFile(buffer, {
        type: 'application/zip',
        disposition: `attachment; filename="${id}.zip"`,
    });
  }
```

ここでは type に application/zip を設定しているので、先ほどの `binarySettings`が効いた形でレスポンス返却されます。


### API Gatewayの設定

REST APIのproxy resourceを作成し、ANY methodで `Lambda proxy integration`を on にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3580319/f0ffa624-211f-3d17-aeec-b3cb2f33231b.png)


ブラウザアプリからAPI接続するので OPTIONSも設定。こんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3580319/fe6edf9f-9c41-49d2-204a-3d98bf726c78.png)


API settings のBinary media types に `application/zip` を追加。公式では Binaryを返却するには `*/*`を設定する例や説明が記載されていますが、そうするとすべてのmedia typesを binary mediaとして取り扱ってしまい、OPTIONS preflight リクエストまでもバイナリとしてハンドリングされてしまうのでCORSの問題が発生するため指定のMIME typeのみをセットしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3580319/33f3e1f5-76fb-20ed-dc20-b4e139ba5cbd.png)

API Gateway設定は以上です。


### クライアント実装

今回の環境は SPAからAPI接続する形ですが、http client は Axiosを使っています。

```
    const response = await api.downloadZip(id, {
      responseType: 'arraybuffer',
      headers: {
        Accept: 'application/zip',
      },
    });

    if (response.status === 200) {
      const blob = new Blob([response.data as any], {
        type: 'application/zip',
      });
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = `${id}.zip`;
      link.click();
      window.URL.revokeObjectURL(link.href);
    } else {
      console.error('Error downloading file:', response.status);
    }
```

注意点はリクエストで Accept headerの設定を忘れないことです。
```
      headers: {
        Accept: 'application/zip',
      },
```

セットしない場合、以下のような解凍できない問題に直面しました。

Unable to expand "xxx.zip". It is in an unsupported format.

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3580319/d6868d5a-fb80-158f-b178-eecf19a29281.png)


[公式](https://docs.aws.amazon.com/apigateway/latest/developerguide/lambda-proxy-binary-media.html)でもちゃんと記載されているのですが、見落としてました。

>Note
To use a web browser to invoke an API with this example integration, set your API's binary media types to */*. API Gateway uses the first Accept header from clients to determine if a response should return binary media. To return binary media when you can't control the order of Accept header values, such as requests from a browser, set your API's binary media types to */* (for all content types).


API Gateway にバイナリを返却すべきかどうかの振る舞いを決定させるために最初の Accept headerに binary media typeを指定すべきということですね。
そうすることで、Lambdaが Base64 encode した後、APIGが decodeして元のバイナリを返却することができ、クライアントサイドで無事解凍できる Zipがダウンロードできました。

ローカル疎通では直で NestJS に繋いで問題なくダウンロード・解凍できていた一方、デプロイ環境でのみ解凍できない問題が起きていたので、API Gatewayの設定 や サーバーサイド実装を疑うところから調査したので少し手間取ってしまいました。


### 最後に

標準的なNode.jsサーバーの場合、バイナリレスポンスは手動で設定する必要がありますが、`serverless-express` を利用してbinarySettingsを用いることで、このプロセスを容易にカスタマイズできます。通常、バイナリデータを返却するには次のようなレスポンスフォーマットが必要です。

```
    const response = {
    　　isBase64Encoded: true,
        statusCode: 200,
        headers: {
            'Content-Disposition': 'attachment; filename="filename.zip"',
            'Content-Type': 'application/zip'
        },
        body: buffer.toString('base64')
    }

```

一方で serverless-express を使用することで、このプロセスを内部で自動的に適切なフォーマットに整えます。結果として、NestJSのControllerでのレスポンス実装は、バイナリデータを直接返すだけのシンプルなものになります。


この記事が、NestJSとAWS Lambdaを組み合わせた開発で直面する問題の解決に役立つことを願っています。