API GatewayとLambdaを使ってS3の静的ホスティングをしてみる

S3にあるHTMLや画像などの静的ファイルを、API Gateway+Lambdaを使ってHTTPSで公開するコードを書きました。

LambdaでS3オブジェクトを読み込んで、そのまま返すだけですが、バイナリファイルの扱いが面倒でしたので、記事にしておきます。

ポイントは、API Gatewayのバイナリメディアタイプを `*/*` にすることです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490207/a3c1dfcf-3f8a-7214-a831-e90a34f7cda5.png)

S3のファイルをHTTP(S)で公開するには、S3のバケットにStatic website hostingの設定をするか、CloudFrontを使うことが多いと思いますが、なんらかの事情でこれらの機能を使えない場合の対処です。（あんまりないかもしれませんが）


# バイナリファイルの扱い

LambdaとAPI Gatewayとのあいだでは画像などのバイナリを直接受け渡すことができません。API Gatewayでバイナリメディアタイプという設定をし、BASE64で受け渡します。


## API GatewayからLambdaへのバイナリ受け渡し

以下の条件を満たすとき、API GatewayはPOSTメソッドなどのリクエストボディをBASE64エンコードしてLambdaに渡してくれます。Lambda側で必要に応じてBASE64デコードする必要があります。

条件

- ブラウザからのリクエストヘッダのContent-TypeがAPI Gatewayのバイナリメディアタイプに合致

※この記事の目的である静的ホスティングであれば、リクエストボディの考慮は不要です。


## LambdaからAPI Gatewayへのバイナリ受け渡し

以下の条件をすべて満たすとき、API GatewayはLambdaから渡されたレスポンスボディをBASE64デコードしてからレスポンスします。バイナリをレスポンスすべきときは以下の条件をすべて満たすようにしたうえで、Lambda側はレスポンス時にBASE64エンコードする必要があります。

条件

- ブラウザからの `Accept` リクエストヘッダがバイナリメディアタイプと合致
- レスポンスヘッダのContent-Typeがバイナリメディアタイプと合致
- LambdaからAPI Gatewayに返す値の `isBase64Encoded` が `true`


## 静的ホスティングにおけるバイナリファイルの扱いに関する実装

レスポンスすべきファイルが画像などのバイナリのときにだけBASE64エンコードするという処理を思いつきます。そこで、バイナリメディアタイプに `image/*, font/*` などと列挙しようとしました。しかし、これはうまくいきません。バイナリメディアタイプを `*/*` にする必要がありました。


**理由その1**
バイナリのファイルタイプは画像だけでなく無数にあり列挙しきれないのが理由です。ちょっと試しただけでフォントファイルとかPDFとかいろいろ出てきます。テキストファイルをバイナリとして扱うのは問題ありませんので、すべてをバイナリとして扱うのが早いです。

**理由その2**
バイナリをレスポンスするにはブラウザからの `Accept` リクエストヘッダがバイナリメディアタイプと合致する必要があるのですが、合致しない場合があるのです。Chromeの場合、HTMLのimg要素でブラウザが画像をサーバにリクエストするときには `Accept` の先頭が `image/*` になるのですが、画像ファイルを新しいタブで直接読み込んだ場合には `Accept` が `text/html, image/*` の順になります（他のブラウザは試してません）。Chromeの挙動は間違ってないとは思います。しかし、API Gatewayはバイナリ判定するときに `Accept` の先頭しか見ない仕様だとドキュメントに書かれています。したがって、Chromeで新しいタブで開いたときはAPI Gatewayは `text/html` だとみなしてしまいます。バイナリメディアタイプにバイナリのタイプを列挙する方法ですと、画像やPDFをタブで開こうとしてもバイナリファイルにならずに開けません。`text/html` がバイナリメディアタイプに合致する必要があります。

テキストファイルなのにLambdaでBASE64エンコードして、API Gatewayがデコードするのはもったいない気がします。そこで、バイナリメディアタイプは `*/*` にしたうえで、明らかにテキスト形式であるHTML/CSS/JSなどの場合はLambda側で `isBase64Encoded` を `false` とすれば、無駄なBASE64エンコード・デコードを省略できます。


# ハマりポイント

上記の理由その2に書いた通り、新しいタブで画像が表示されないという問題が十分ハマりポイントだったのですが、それに関連してもうひとつ。

バイナリメディアタイプをCloudFormationのテンプレートに書いても、なぜか反映されない場合がありました。CloudFormationのStackをいったん削除してから作り直すと反映されますが、その場合API GatewayのURLが変わってしまいます。なので、せっかくCloudFormationに書いてるのに、改めて手動でバイナリメディアタイプだけ設定しなおすことが多々ありました。


# ぼやき

API Gatewayにいろんな機能がありますが、Lambdaと組み合わせるときには、へんにAPI Gatewayの機能に期待するよりも、Lambda側に任せ、コードで表現しちゃったほうが早い気がします。。。バイナリの扱いに限らず。

バイナリメディアタイプを `*/*` にするのも、Lambdaで判定を実装するからAPI Gatewayは余計な判断をするな、という意思でもあります。


# ソースコード

## LambdaのJavaScriptコード

ここまで書いたことを実装したLambdaのコードです。JavaScriptです。

S3のオブジェクトに設定されているContent-Typeが信用できない想定で、拡張子でContent-Typeを判断してレスポンスするようにしています。この場合、使われうる拡張子をリストアップする必要があります。ついでにバイナリファイルかどうか（BASE64するかどうか）の判定もしています。

`api/app.js`

```javascript
const AWS = require('aws-sdk');

function basename(key) {
    const p = key.lastIndexOf("/");
    if (p >= 0) {
        return key.substring(p + 1);
    } else {
        return key;
    }
}

function extname(key) {
    const p = key.lastIndexOf(".");
    if (p > 0) {
        return key.substring(p + 1);
    } else {
        return "";
    }
}

exports.lambda_handler = async (event, context) => {
    const s3_bucket = process.env.S3_BUCKET;
    const s3_key = event.path.substring(1);
    // TODO "/" で終わっていたら "/index.html" にするとかしたほうがいい
    const ext = extname(s3_key);
    const s3 = new AWS.S3();
    console.log(event);
    return new Promise(function(resolve, reject) {
        s3.getObject({
            Bucket: s3_bucket,
            Key: s3_key,
        }, function (err, data) {
            if (err) {
                console.log(err);
                resolve({
                    "statusCode": 400,
                    "headers": {
                        "Content-Type": "application/json",
                    },
                    "body": JSON.stringify(err),
                });
                return;
            }
            const body = data.Body;
            let text = "";
            for (let i = 0, len = body.byteLength; i < len; i++) {
                text += String.fromCharCode(body[i]);
            }
            let contentType;
            let isBase64Encoded = true;
            if (ext == "jpg") {
                contentType = "image/jpeg";
            } else if (ext == "gif") {
                contentType = "image/gif";
            } else if (ext == "html") {
                contentType = "text/html";
                isBase64Encoded = false;
            } else if (ext == "css") {
                contentType = "text/css";
                isBase64Encoded = false;
            } else if (ext == "js") {
                contentType = "text/javascript";
                isBase64Encoded = false;
            } else if (ext == "json") {
                contentType = "application/json";
                isBase64Encoded = false;
            } else if (ext == "woff") {
                contentType = "font/woff";
            } else if (ext == "ttf") {
                contentType = "font/ttf";
            } else if (ext == "ico") {
                contentType = "image/vnd.microsoft.icon";
            } else {
                contentType = "text/plain";
            }
            let responseBody;
            if (isBase64Encoded) {
                responseBody = data.Body.toString("base64");
            } else {
                responseBody = data.Body.toString();
            }
            resolve({
                "statusCode": 200,
                "headers": {
                    "Content-Type": contentType,
                    "Last-Modified": data.LastModified,
                },
                "body": responseBody,
                "isBase64Encoded": isBase64Encoded,
            });
        });
    });
};
```

## CloudFormationのテンプレート

CloudFormationのテンプレートです。ホスティングしているS3のバケット名はテンプレートのパラメータで渡し、Lambdaには環境変数で渡しています。

`template.yaml`

```yaml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  S3Bucket:
    Type: String

Resources:
  StaticWebsiteFileFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code: api
      Handler: app.lambda_handler
      FunctionName: staticwebsite-file
      Role: !GetAtt StaticWebsiteFileFunctionRole.Arn
      Runtime: nodejs10.x
      Timeout: 30
      Environment:
        Variables:
          S3_BUCKET: !Ref S3Bucket
  StaticWebsiteFileFunctionPermissionProd:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      FunctionName: !Ref StaticWebsiteFileFunction
      SourceArn: !Sub
        - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/${__Method__}/${__Path__}
        - __Stage__: "*"
          __ApiId__: !Ref StaticWebsiteFileApiGateway
          __Method__: "*"
          __Path__: "*"
  StaticWebsiteFileFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action:
              - "sts:AssumeRole"
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole # ログ書き込み用
        - arn:aws:iam::aws:policy/AmazonS3FullAccess # S3閲覧用
  StaticWebsiteFileApiGateway:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: staticwebsite-file
      BinaryMediaTypes:
        # ここがポイント
        - "*/*"
      Body:
        info:
          version: 1.0
          title: !Ref AWS::StackName
        paths:
          "/":
            get:
              x-amazon-apigateway-integration:
                httpMethod: POST
                type: aws_proxy
                uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${StaticWebsiteFileFunction.Arn}/invocations
          "/{proxy+}": # すべてのパスを同じLambdaが受け取る設定
            get:
              x-amazon-apigateway-integration:
                httpMethod: POST
                type: aws_proxy
                uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${StaticWebsiteFileFunction.Arn}/invocations
        swagger: 2.0
      Policy:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal: "*"
            Action: execute-api:Invoke
            Resource: "execute-api:/*"
  StaticWebsiteFileApiGatewayProdStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      DeploymentId: !Ref StaticWebsiteFileApiGatewayDeployment
      RestApiId: !Ref StaticWebsiteFileApiGateway
      StageName: Prod
  StaticWebsiteFileApiGatewayDeployment:
    Type: AWS::ApiGateway::Deployment
    Properties:
      RestApiId: !Ref StaticWebsiteFileApiGateway
      StageName: Stage
Outputs:
  Api:
    Value: !Sub "https://${StaticWebsiteFileApiGateway}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
```

# デプロイスクリプト

CloudFormationのデプロイをするbashスクリプトの例です。デプロイ後にAPI GatewayのURLを表示します。

`deploy.sh`

```shell
set -Ceu

# 環境に合わせてパラメータを設定
profile=default
cf_s3_bucket=... # CloudFormationで使うS3バケット名
stack_name=staticwebsite
s3_bucket=... # HTMLや画像などを置くS3バケット名

aws --profile $profile cloudformation package \
    --template-file template.yaml \
    --s3-bucket $cf_s3_bucket \
    --s3-prefix staticwebsite \
    --output-template-file var/packaged-$profile.yaml

aws --profile $profile cloudformation deploy \
    --template-file var/packaged-$profile.yaml \
    --stack-name $stack_name \
    --capabilities CAPABILITY_IAM \
    --no-fail-on-empty-changeset \
    --parameter-overrides \
    S3Bucket=$s3_bucket

aws --profile $profile cloudformation describe-stacks \
    --stack-name $stack_name |
    jq -r '.Stacks[0].Outputs[0].OutputValue'
```


# 関連記事

最近API Gatewayの記事ばかり書いています。今年後半はAPI Gatewayに悩まされ続けました。

- [IAM認証のAWS API GatewayにEC2インスタンスからSigV4署名してアクセスするには](https://qiita.com/suzuki-navi/items/21b5840608ad67548f0b) (12/23)
- [API GatewayをVPC内のプライベートに変更するとHTTP2非対応になる](https://qiita.com/suzuki-navi/items/35c716c25f6511fef7a5) (12/22)
- [API GatewayをVPC内のプライベートアクセスのみに限定](https://suzuki-navi.hatenablog.com/entry/2020/12/21/081530) (12/21)
- [CloudFormationでAPI Gatewayを変更しても反映されない](https://suzuki-navi.hatenablog.com/entry/2020/12/17/214538) (12/17)
- [AWS::ApiGateway::Stage と AWS::ApiGateway::Deployment の違い](https://suzuki-navi.hatenablog.com/entry/2020/12/16/230323) (12/16)
- [IAM認証のAWS API GatewayにC#からIAMロールでSigV4署名してアクセスするには](https://qiita.com/suzuki-navi/items/4bf03406069761b8257f) (2020/10/19)
- [IAM認証のAWS API GatewayにC#からIAMユーザでSigV4署名してアクセスするには](https://qiita.com/suzuki-navi/items/385deb31f36a2a66fac0) (2020/10/14)
- [IAM認証のAWS API GatewayにPythonからSigV4署名してアクセスするには](https://qiita.com/suzuki-navi/items/1ea284c97075f3a34b30) (2020/07/22)