SAMを使ってAPI-Gateway経由でAWS::Serverless::Functionから画像をダウンロードする

# はじめに

AWSのSAMでASP.NET Core WebApiプロジェクトを作りPDFなどをダウンロードする場合、APIGatewayに設定を行わないとBase64でエンコードされたファイルがダウンロードされてしまいます。
これ自体は[API Gateway REST API を使用したバイナリサポートの有効化](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-payload-encodings.html)の記載に従い、マネジメントコンソールで操作をすれば解決はできるのですが、SAMのテンプレートでこれを指定すにはどうすればよいでしょうか？

# 前準備

AWS関連のプロジェクトテンプレートと、Lambda関連のデプロイサブコマンドをインストールしていない場合はインストールしておいてください。

```sh
❯ dotnet new --install Amazon.Lambda.Templates
❯ dotnet tool install -g Amazon.Lambda.Tools
```

# Amazon.Lambda.AspNetCoreServer

SAMのプロジェクトテンプレートにはいくつかの種類がありますが、まずは以前からあった`serverless.AspNetCoreWebAPI`で確認していきましょう。

## プロジェクトの作成とデプロイ

プロジェクトを作成します。

```
> dotnet new serverless.AspNetCoreWebAPI -o webapi
```

コントローラーを追加します。とりあえず今回は一般的なPNGと比較的新しいWebP、あと確認のために通常のテキストファイルをダウンロードできるようにしておきましょう。

```cs:AssetsController.cs
using Microsoft.AspNetCore.Mvc;

namespace ImageDownloadWebApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AssetsController : ControllerBase
{
    [HttpGet("png")]
    public IActionResult Png() => File(System.IO.File.ReadAllBytes("image.png"), "image/png", "image.png");
    [HttpGet("webp")]
    public IActionResult WebP() => File(System.IO.File.ReadAllBytes("image.webp"), "image/webp", "image.webp");
    [HttpGet("text")]
    public IActionResult text() => File(System.Text.Encoding.UTF8.GetBytes("1234"), "text/plain", "data.txt");
}
```

AWSにデプロイします。

```
❯ dotnet lambda deploy-serverless --s3-bucket [アプリをアップロードするS3のバケット名] --stack-name image-download-api
```

## バイナリーデータのダウンロード

デプロイしたURLからそれぞれのアセットをダウンロードします。

```sh
curl -X GET https://xxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/api/assets/text \
  -H 'accept: text/plain' -o text.txt
curl -X GET https://xxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/api/assets/png \
  -H 'accept: image/png' -o image.png
curl -X GET https://xxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/api/assets/webp \
  -H 'accept: image/webp' -o image.webp
```
テキストは普通に表示されますが、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/15879236-8fe8-7950-e710-5b8b630901e6.png)
PNGは次のように認識されないファイルとしてダウンロードされました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/52db3ea9-9b8a-2b49-c022-27b9f8efe322.png)
テキストエディターでimage.pngを開くとBase64っぽい形式でダウンロードされているようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/56eaaceb-cfa1-c877-2bac-edd0fd155cf6.png)
WebPはよくわからないバイナリーデータですね。

## ApiGatewayの設定と再デプロイ

下記に記載がある通りAPIGateway RESTAPIではUTF8のテキストデータを想定しているため、バイナリーデータは個別の許可が必要です。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-payload-encodings-configure-with-console.html

ということで`image/png`と`image/webp`を追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/d4c4c1b1-0564-7738-4b8f-8067acfd9feb.png)
APIGatewayの設定を変更したら忘れずにデプロイしましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/c48b78c4-177f-887c-e2ad-c8d785d0bd2b.png)

## バイナリーデータ → Base64 → バイナリーデータの変換

image.pngは表示できるようになりましたが、image.webpはいまだに表示できないままです。これは前述のとおりAPIGatewayのバックエンドではバイナリーデータはBase64で返却しなければならないというAPIGatewayの制限から来るものです。
プログラム側でBase64形式で画像を返してもよいのですが、LambdaEntryPointでRegisterResponseContentEncodingForContentTypeを使ってコンテンツタイプを登録することで自動的にバイナリデータ→Base64の変換を受け持ってくれるのでこれを利用しましょう。

```cs:
public class LambdaEntryPoint: Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction
{
    protected override void Init(IWebHostBuilder builder)
    {
        RegisterResponseContentEncodingForContentType("image/webp", ResponseContentEncoding.Base64);
        builder
            .UseStartup<Startup>();
    }
    protected override void Init(IHostBuilder builder)
    {
    }
}
```

`application/octet-stream`や`image/png`など代表的なファイルはあらかじめ登録されているようです。

https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.AspNetCoreServer/README.md#default-registered-content-types

WebPもダウンロードできるようになりましたね。

確認が終わったらスタックも削除しておきましょう

```
❯ dotnet lambda delete-serverless --stack-name image-download-api
```

# Amazon.Lambda.AspNetCoreServer.Hosting

.NET 6のMinimalAPIに対応する場合も基本的にはASP.NET側とAPIGateway側に未知のコンテンツタイプを登録していけばよいです。
ただし、MinimalAPIの場合はLambdaEntryPointが無いので、LambdaRuntimeSupportServerを拡張してカスタムのホスティング環境を作成する必要があります。

## プロジェクトを作成してカスタムホスティング環境を適用する。

プロジェクトを作り、AspNetCoreWebAPIの時と同じコントローラーを追加します。

```
❯ dotnet new serverless.AspNetCoreMinimalAPI -o MinimalApi
```

```cs:AssetsController.cs
using Microsoft.AspNetCore.Mvc;

namespace ImageDownloadWebApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AssetsController : ControllerBase
{
    [HttpGet("png")]
    public IActionResult Png() => File(System.IO.File.ReadAllBytes("image.png"), "image/png", "image.png");
    [HttpGet("webp")]
    public IActionResult WebP() => File(System.IO.File.ReadAllBytes("image.webp"), "image/webp", "image.webp");
    [HttpGet("text")]
    public IActionResult text() => File(System.Text.Encoding.UTF8.GetBytes("1234"), "text/plain", "data.txt");
}
```

image/webpを登録したカスタムのランタイムサーバーと、ホスティング用の拡張メソッドを定義します。

```cs
using Amazon.Lambda.AspNetCoreServer;
using Amazon.Lambda.AspNetCoreServer.Hosting.Internal;
using Amazon.Lambda.AspNetCoreServer.Internal;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;

public class ImageSupportAPIGatewayRestApiLambdaRuntimeSupportServer : LambdaRuntimeSupportServer
{
    public ImageSupportAPIGatewayRestApiLambdaRuntimeSupportServer(IServiceProvider serviceProvider)
        : base(serviceProvider)
    {
    }

    protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceProvider)
    {
        var handler = new APIGatewayRestApiMinimalApi(serviceProvider).FunctionHandlerAsync;
        return HandlerWrapper.GetHandlerWrapper(handler, new DefaultLambdaJsonSerializer());

    }

    public class APIGatewayRestApiMinimalApi : APIGatewayProxyFunction
    {
        public APIGatewayRestApiMinimalApi(IServiceProvider serviceProvider) : base(serviceProvider)
        {
            RegisterResponseContentEncodingForContentType("image/webp", ResponseContentEncoding.Base64);
        }
    }
}

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAWSLambdaHostingCustomized(this IServiceCollection services, LambdaEventSource eventSource)
    {
        if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME")))
            return services;
        var type = eventSource switch
        {
            LambdaEventSource.RestApi => typeof(ImageSupportAPIGatewayRestApiLambdaRuntimeSupportServer),
            _ => throw new NotSupportedException()
        };
        Utilities.EnsureLambdaServerRegistered(services, type);
    }
}
```

デフォルトのホスティング設定をコメントにして、`builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi);`上で作成したランタイムでホストします。


```cs:Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSwaggerDocument();

// Add services to the container.
builder.Services.AddControllers();

// Add AWS Lambda support. When application is run in Lambda Kestrel is swapped out as the web server with Amazon.Lambda.AspNetCoreServer. This
// package will act as the webserver translating request and responses between the Lambda event source and ASP.NET Core.
//builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi);
builder.Services.AddAWSLambdaHostingCustomized(LambdaEventSource.RestApi);

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.UseOpenApi();
app.UseSwaggerUi3();

app.MapGet("/", () => "Welcome to running ASP.NET Core Minimal API on AWS Lambda");

app.Run();
```

SAMのデプロイ後に、`image/png`と`image/webp`を許可する[ApiGatewayの設定](#apigateway%E3%81%AE%E8%A8%AD%E5%AE%9A%E3%81%A8%E5%86%8D%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4)をすれば、同様に利用できるようになります。

# Amazon.Lambda.AspNetCoreServer.Hosting + HTTP API

ここまではWebGateway のREST APIを利用してきましたが、HTTP APIを利用するとAPI Gateway側のコンテンツタイプ設定を行わなずに済みます。MinimalApiを利用する場合は次のようにHttpApi用のカスタムランタイムを作成し、

```cs
public class ImageSupportAPIGatewayHttpApiV2LambdaRuntimeSupportServer : LambdaRuntimeSupportServer
{
    public ImageSupportAPIGatewayHttpApiV2LambdaRuntimeSupportServer(IServiceProvider serviceProvider)
        : base(serviceProvider)
    {
    }

    protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceProvider)
    {
        var handler = new APIGatewayHttpApiMinimalApi(serviceProvider).FunctionHandlerAsync;
        return HandlerWrapper.GetHandlerWrapper(handler, new DefaultLambdaJsonSerializer());

    }

    public class APIGatewayHttpApiMinimalApi : APIGatewayHttpApiV2ProxyFunction
    {
        public APIGatewayHttpApiMinimalApi(IServiceProvider serviceProvider) : base(serviceProvider)
        {
            RegisterResponseContentEncodingForContentType("image/webp", ResponseContentEncoding.Base64);
        }
    }
}

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAWSLambdaHostingCustomized(this IServiceCollection services, LambdaEventSource eventSource)
    {
        // Not running in Lambda so exit and let Kestrel be the web server
        if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME")))
            return services;

        var type = eventSource switch
        {
            LambdaEventSource.RestApi => typeof(ImageSupportAPIGatewayRestApiLambdaRuntimeSupportServer),
            LambdaEventSource.HttpApi => typeof(ImageSupportAPIGatewayHttpApiV2LambdaRuntimeSupportServer),
            _ => throw new NotSupportedException()
        };

        Utilities.EnsureLambdaServerRegistered(services, type);
        return services;
    }
}
```

ホスティング設定時に`LambdaEventSource.RestApi`ではなく`LambdaEventSource.HttpApi`を利用するように修正します。

```cs
builder.Services.AddAWSLambdaHostingCustomized(LambdaEventSource.RestApi);
```

合わせて、`serverless.template`を次のように書き換えてデプロイします。

```diff_json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Transform": "AWS::Serverless-2016-10-31",
  "Description": "An AWS Serverless Application that uses the ASP.NET Core framework running in Amazon Lambda.",
  "Parameters": {},
  "Conditions": {},
  "Resources": {
    "AspNetCoreFunction": {
      "Type": "AWS::Serverless::Function",
      "Properties": {
        "Handler": "MinimalApi",
        "Runtime": "dotnet6",
        "CodeUri": "",
        "MemorySize": 256,
        "Timeout": 30,
        "Role": null,
        "Policies": [
          "AWSLambda_FullAccess"
        ],
        "Events": {
          "ProxyResource": {
-            "Type": "Api",
+            "Type": "HttpApi",
            "Properties": {
              "Path": "/{proxy+}",
              "Method": "ANY"
            }
          },
          "RootResource": {
-            "Type": "Api",
+            "Type": "HttpApi",
            "Properties": {
              "Path": "/",
              "Method": "ANY"
            }
          }
        }
      }
    }
  },
  "Outputs": {
    "ApiURL": {
      "Description": "API endpoint URL for Prod environment",
      "Value": {
-        "Fn::Sub": "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
+        "Fn::Sub": "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"
      }
    }
  }
}
```

# まとめ

APIGateway経由でバイナリーデータをダウンロードする場合、アプリ側とAPIGateway側の両方の設定が必要です。
ただし、APIGateway側の設定はHTTP APIを利用するのであれば`serverless.template`での設定だけで完結することができます。

# おわりに

ASP.NETの進化とAWS側のサービスの増加に伴い、ホスティング方法が多岐にわたってきてマニュアルをざっと見ただけだとこの場合どうやってやるの？を理解するのが大変になってきましたね。。。