API GatewayでCloudFrontの真似事をしてSPAを動作させてみる

# はじめに

とある理由でSPAのアプリケーションの配信にS3＋CloudFrontを使うことができなかったので、API Gateway+S3を使ってみることにしました。

# S3を定義する

API Gatewayのチュートリアル「[API Gateway で REST API を Amazon S3 のプロキシとして作成する](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/integrating-api-with-aws-services-s3.html)」にあるように、API GatewayとS3を直接連携して、バケットのCRUDができるAPIとして公開する機能がAPI Gatewayにあります。この機能を利用して今回はAPI GatewayからS3内にある静的サイト(index.html)にアクセスできるように実装してみます。

まずはs3を定義して静的サイトのホスティングができるようにします。

```typescript
import * as cdk from '@aws-cdk/core'
import * as s3 from '@aws-cdk/aws-s3'

export class SpaHostingStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: WebStackProps) {
    super(scope, id)
    new s3.Bucket(this, 's3', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: 'index.html',
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    })
  }
}
```

# IAMロールを作成する

S3は公開設定をOFFにした状態です。API Gatewayにのみバケットの読み込み権限を与えるようにIAMロールを定義します。

```typescript
import * as cdk from '@aws-cdk/core'
import * as s3 from '@aws-cdk/aws-s3'
import * as iam from '@aws-cdk/aws-iam'

export class SpaHostingStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const bucket = new s3.Bucket(this, 's3', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: 'index.html',
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    })

    const credentialsRole = new iam.Role(this, 'role', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
    })

    bucket.grantRead(credentialsRole)
  }
}  
```

`grantRead()`メソッドを用いると特定のS3バケットに対しての読み込み権限をIAMロールのポリシーに上書きすることが簡単にできます。

# API Gatewayを設定する

SPAの配信を行うために、APIの`/`と`{何かしらのパス}`へのアクセスに対しては`index.html`、ブラウザ自身が行う`/bundle.js`と`/favicon.ico`へのアクセスに対しては静的なファイルを返すようにします。

```typescript
import * as cdk from '@aws-cdk/core'
import * as s3 from '@aws-cdk/aws-s3'
import * as apigateway from '@aws-cdk/aws-apigateway'
import * as iam from '@aws-cdk/aws-iam'

export class SpaHostingStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const bucket = new s3.Bucket(this, 's3', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: 'index.html',
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    })

    const credentialsRole = new iam.Role(this, 'role', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
    })

    bucket.grantRead(credentialsRole)

    const api = new apigateway.RestApi(this, 'api', {
      binaryMediaTypes: ['image/*'],
    })

    const s3RootIntegration = new apigateway.AwsIntegration({
      service: 's3',
      integrationHttpMethod: 'GET',
      path: `${bucket.bucketName}/index.html`,
      options: {
        credentialsRole,
        requestParameters: {
          'integration.request.header.Accept': 'method.request.header.Accept',
          'integration.request.header.Content-Type': 'method.request.header.Content-Type',
        },
        integrationResponses: [
          {
            statusCode: '200',
            responseParameters: {
              'method.response.header.Content-Type': 'integration.response.header.Content-Type',
              'method.response.header.Access-Control-Allow-Headers': 'integration.response.header.Access-Control-Allow-Headers',
              'method.response.header.Access-Control-Allow-Methods': 'integration.response.header.Access-Control-Allow-Methods',
              'method.response.header.Access-Control-Allow-Credentials': 'integration.response.header.Access-Control-Allow-Credentials',
              'method.response.header.Access-Control-Allow-Origin': 'integration.response.header.Access-Control-Allow-Origin',
            },
          },
        ],
      },
    })

    api.root.addMethod('GET', s3RootIntegration, {
      methodResponses: [
        {
          statusCode: '200',
          responseParameters: {
            'method.response.header.Content-Type': false,
            'method.response.header.Access-Control-Allow-Headers': false,
            'method.response.header.Access-Control-Allow-Methods': false,
            'method.response.header.Access-Control-Allow-Credentials': false,
            'method.response.header.Access-Control-Allow-Origin': false,
          },
          responseModels: {
            'text/html': {
              modelId: 'Empty',
            },
          },
        },
      ],
      requestParameters: {
        'method.request.header.Content-Type': false,
        'method.request.header.Accept': false,
      },
    })

    api.root.addResource('build.js').addMethod(
      'GET',
      new apigateway.AwsIntegration({
        service: 's3',
        integrationHttpMethod: 'GET',
        path: `${bucket.bucketName}/build.js`,
        options: {
          credentialsRole,
          requestParameters: {
            'integration.request.header.Accept': 'method.request.header.Accept',
            'integration.request.header.Content-Type': 'method.request.header.Content-Type',
          },
          integrationResponses: [
            {
              statusCode: '200',
              responseParameters: {
                'method.response.header.Content-Type': 'integration.response.header.Content-Type',
                'method.response.header.Access-Control-Allow-Headers': 'integration.response.header.Access-Control-Allow-Headers',
                'method.response.header.Access-Control-Allow-Methods': 'integration.response.header.Access-Control-Allow-Methods',
                'method.response.header.Access-Control-Allow-Credentials': 'integration.response.header.Access-Control-Allow-Credentials',
                'method.response.header.Access-Control-Allow-Origin': 'integration.response.header.Access-Control-Allow-Origin',
              },
            },
          ],
        },
      }),
      {
        methodResponses: [
          {
            statusCode: '200',
            responseParameters: {
              'method.response.header.Content-Type': false,
              'method.response.header.Access-Control-Allow-Headers': false,
              'method.response.header.Access-Control-Allow-Methods': false,
              'method.response.header.Access-Control-Allow-Credentials': false,
              'method.response.header.Access-Control-Allow-Origin': false,
            },
            responseModels: {
              'text/html': {
                modelId: 'Empty',
              },
            },
          },
        ],
        requestParameters: {
          'method.request.header.Content-Type': false,
          'method.request.header.Accept': false,
        },
      },
    )

    api.root.addResource('favicon.ico').addMethod(
      'GET',
      new apigateway.AwsIntegration({
        service: 's3',
        integrationHttpMethod: 'GET',
        path: `${bucket.bucketName}/favicon.ico`,
        options: {
          credentialsRole,
          requestParameters: {
            'integration.request.header.Accept': 'method.request.header.Accept',
            'integration.request.header.Content-Type': 'method.request.header.Content-Type',
          },
          integrationResponses: [
            {
              statusCode: '200',
              responseParameters: {
                'method.response.header.Content-Type': 'integration.response.header.Content-Type',
                'method.response.header.Access-Control-Allow-Headers': 'integration.response.header.Access-Control-Allow-Headers',
                'method.response.header.Access-Control-Allow-Methods': 'integration.response.header.Access-Control-Allow-Methods',
                'method.response.header.Access-Control-Allow-Credentials': 'integration.response.header.Access-Control-Allow-Credentials',
                'method.response.header.Access-Control-Allow-Origin': 'integration.response.header.Access-Control-Allow-Origin',
              },
            },
          ],
        },
      }),
      {
        methodResponses: [
          {
            statusCode: '200',
            responseParameters: {
              'method.response.header.Content-Type': false,
              'method.response.header.Access-Control-Allow-Headers': false,
              'method.response.header.Access-Control-Allow-Methods': false,
              'method.response.header.Access-Control-Allow-Credentials': false,
              'method.response.header.Access-Control-Allow-Origin': false,
            },
            responseModels: {
              'text/html': {
                modelId: 'Empty',
              },
            },
          },
        ],
        requestParameters: {
          'method.request.header.Content-Type': false,
          'method.request.header.Accept': false,
        },
      },
    )

    api.root.addResource('{proxy+}').addMethod('GET', s3RootIntegration, {
      methodResponses: [
        {
          statusCode: '200',
          responseParameters: {
            'method.response.header.Content-Type': false,
            'method.response.header.Access-Control-Allow-Headers': false,
            'method.response.header.Access-Control-Allow-Methods': false,
            'method.response.header.Access-Control-Allow-Credentials': false,
            'method.response.header.Access-Control-Allow-Origin': false,
          },
          responseModels: {
            'text/html': {
              modelId: 'Empty',
            },
          },
        },
      ],
      requestParameters: {
        'method.request.header.Content-Type': false,
        'method.request.header.Accept': false,
      },
    })
  }
}
```

リソース名を`{proxy+}`と設定することで、ワイルドカードのような形でパス名を置くことができます。また、htmlのコンテンツを返すためにヘッダーに色々な設定を加えます。

# おわりに

とりあえずAPI GatewayでSPAの配信はできないことはないがとても面倒だということがわかりました。また、この方法だと例えば`xxxx.svg`などの静的ファイルが加わった時にその都度リソースの設定で対応する必要があります。面倒な場合はwebpackで`url-loader`を設定して全部htmlファイルにまとめてしまうなど、ウェブアプリ側でも対応が必要になったりもします。基本的にはこういった用途にはCloudFrontを使うのが無難かなと思いました。

# 参考

[API Gateway を用いて、S3 で静的ウェブサイトホスティングで公開したVue アプリをHTTPS化してみた。](https://www.ecomottblog.com/?p=6405)
[How to integrate API Gateway with s3 in CDK](https://stackoverflow.com/questions/63852022/how-to-integrate-api-gateway-with-s3-in-cdk)