aws cdk v2 で cloud front に S3とApiGatewayのオリジンを紐づけたメモ

## 概要

以下のような構造にするときに参考になるCDKがあまり見つからなかったのでメモ。

```
- CloudFront
  - S3
  - Api Gateway - S3
```

## ソース

### APIGateway側
ハマリポイント

* エンドポイントタイプを「エッジ最適化」にしていたら失敗した。
  * 「リージョン」に変更することで解決
今回はステージv1にデプロイしている。

```cdk/lib/rest-stack.ts
import * as core from 'aws-cdk-lib'
import * as apigateway from 'aws-cdk-lib/aws-apigateway'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as s3 from 'aws-cdk-lib/aws-s3'
import { Construct } from 'constructs'

interface Props extends core.StackProps {
  bucketStackName: string
  bucketName: string
  projectId: string
}

export class AWSCartaGraphRESTAPIStack extends core.Stack {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id, props)
    // バケットへのアクセス権限を持ったIAMを作成
    const bucket = s3.Bucket.fromBucketName(
      this,
      props.bucketStackName,
      props.bucketName,
    )
    const restApiRole = this.createRestAPIRole(bucket)

    // APIGateway作成
    const apiName = `${props.projectId}-rest-api`
    const restApi = this.createRestAPIGateway(apiName)
    this.createUsagePlan(restApi, apiName)

    // JDON格納用 integration
    const jsonIntegration = this.createAwsIntegrationToUploadBucket({
      toUploadBucketPath: `${bucket.bucketName}/logs/{requestTime}_{object}.json`,
      restApiRole,
    })

    const methodOptions = this.createMethodOptions()
    const apiRoot = restApi.root.addResource('api')
    const paragraphMoveLog = apiRoot
      .addResource('logs')
      .addResource('{uid}')

    paragraphMoveLog.addMethod('PUT', jsonIntegration, methodOptions)
  }

  private createRestAPIRole(bucket: s3.IBucket) {
    const restApiRole = new iam.Role(this, 'Role', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
      path: '/',
    })
    bucket.grantReadWrite(restApiRole)
    return restApiRole
  }

  private createRestAPIGateway(restApiName: string) {
    const restApi = new apigateway.RestApi(this, restApiName, {
      description: 'バックエンドRESTAPI',
      restApiName,
      endpointTypes: [apigateway.EndpointType.REGIONAL],
      deployOptions: {
        stageName: 'v1',
      },
    })
    return restApi
  }

  private createUsagePlan(restApi: apigateway.RestApi, apiName: string) {
    const apiKey = restApi.addApiKey('defaultKeys')
    const usagePlan = restApi.addUsagePlan(`${apiName}-usage-plan`, {
      quota: { limit: 30, period: apigateway.Period.DAY },
      throttle: { burstLimit: 2, rateLimit: 1 },
    })
    usagePlan.addApiKey(apiKey)
    usagePlan.addApiStage({ stage: restApi.deploymentStage })

    new core.CfnOutput(this, 'APIKey', {
      value: apiKey.keyId,
    })
  }

  private createAwsIntegrationToUploadBucket(prop: {
    toUploadBucketPath: string
    restApiRole: iam.Role
  }) {
    const integration = new apigateway.AwsIntegration({
      service: 's3',
      integrationHttpMethod: 'PUT',
      path: prop.toUploadBucketPath,
      options: {
        credentialsRole: prop.restApiRole,
        passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
        requestParameters: {
          'integration.request.header.Content-Type': 'method.request.header.Content-Type',
          'integration.request.path.object': 'method.request.path.uid',
          'integration.request.path.requestTime': 'context.requestTimeEpoch',
        },
        integrationResponses: [
          this.createOkResponse(),
          this.createNotFoundResponse(),
          this.createServerErrorResponse(),
        ],
      },
    })
    return integration
  }
  private createOkResponse(): apigateway.IntegrationResponse {
    return {
      statusCode: '200',
      responseParameters: {
        'method.response.header.Timestamp': 'integration.response.header.Date',
        'method.response.header.Content-Length':
          'integration.response.header.Content-Length',
        'method.response.header.Content-Type':
          'integration.response.header.Content-Type',
        ...this.createServerErrorResponse().responseParameters,
      },
    }
  }
  private createNotFoundResponse(): apigateway.IntegrationResponse {
    return {
      statusCode: '400',
      selectionPattern: '4\\d{2}',
      responseParameters: this.createServerErrorResponse().responseParameters,
    }
  }
  private createServerErrorResponse(): apigateway.IntegrationResponse {
    return {
      statusCode: '500',
      selectionPattern: '5\\d{2}',
      responseParameters: {
        'method.response.header.Access-Control-Allow-Headers':
          "'Content-Type,Authorization'",
        'method.response.header.Access-Control-Allow-Methods':
          "'OPTIONS,POST,PUT,GET,DELETE'",
        'method.response.header.Access-Control-Allow-Origin': "'*'",
      },
    }
  }

  private createMethodOptions(): apigateway.MethodOptions {
    const responseParameters = {
      'method.response.header.Access-Control-Allow-Headers': true,
      'method.response.header.Access-Control-Allow-Methods': true,
      'method.response.header.Access-Control-Allow-Origin': true,
    }
    return {
      apiKeyRequired: true,
      requestParameters: {
        'method.request.header.Content-Type': true,
        'method.request.path.uid': true
      },
      methodResponses: [
        {
          statusCode: '200',
          responseParameters: {
            'method.response.header.Timestamp': true,
            'method.response.header.Content-Length': true,
            'method.response.header.Content-Type': true,
            ...responseParameters,
          },
        },
        {
          statusCode: '400',
          responseParameters,
        },
        {
          statusCode: '500',
          responseParameters,
        },
      ],
    }
  }
}
```

作成後、URLを控えておく。

### CloudFront側

* 今回はapiのステージがv1になっている前提。
  * `additionalBehaviors`の設定が特にポイント

```cdk/lib/cf-stack.ts
import * as core from 'aws-cdk-lib'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cf from 'aws-cdk-lib/aws-cloudfront'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'
import { basePath } from '../constants/paths'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'
import * as certManager from 'aws-cdk-lib/aws-certificatemanager'
import { Construct } from 'constructs'

interface Props extends core.StackProps {
  bucketName: string
  identityName: string
  defaultCachePolicyName: string
  imageCachePolicyName: string
  functionName: string
  distributionName: string
  rootDomain: string
  deployDomain: string
  projectNameTag: string
  restApiUrl: string
}

export class AWSCarTaGraphClientStack extends core.Stack {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id, props)
    // CloudFront オリジン用のS3バケットを作成
    const bucket = this.createS3(props.bucketName)
    // CloudFront で設定する オリジンアクセスアイデンティティ を作成
    const identity = this.createIdentity(bucket, props.identityName)
    // S3バケットポリシーで、CloudFrontのオリジンアクセスアイデンティティを許可
    this.createPolicy(bucket, identity)

    const zone = this.findRoute53HostedZone(props.rootDomain)
    const cert = this.createTLSCertificate(props.deployDomain, zone)
    // CloudFrontディストリビューションを作成
    const distribution = this.createCloudFront(
      bucket,
      identity,
      cert,
      props.defaultCachePolicyName,
      props.imageCachePolicyName,
      props.distributionName,
      props.deployDomain,
      props.restApiUrl,
    )
    // 指定したディレクトリをデプロイ
    this.deployS3(bucket, distribution, '../client/build', props.bucketName)

    // route53 の CloudFrontに紐づくレコード作成
    this.addRoute53Records(zone, props.deployDomain, distribution)

    // 確認用にCloudFrontのURLに整形して出力
    new core.CfnOutput(this, `${props.distributionName}-top-url`, {
      value: `https://${distribution.distributionDomainName}/`,
    })

    core.Tags.of(this).add('Project', props.projectNameTag)
  }

  private createS3(bucketName: string) {
    const bucket = new s3.Bucket(this, bucketName, {
      bucketName,
      accessControl: s3.BucketAccessControl.PRIVATE,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: core.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      cors: [
        {
          allowedMethods: [s3.HttpMethods.GET],
          allowedOrigins: ['*'],
          allowedHeaders: ['*'],
        },
      ],
    })
    return bucket
  }

  private createIdentity(bucket: s3.Bucket, identityName: string) {
    const identity = new cf.OriginAccessIdentity(this, identityName, {
      comment: `${bucket.bucketName} access identity`,
    })
    return identity
  }

  private createPolicy(bucket: s3.Bucket, identity: cf.OriginAccessIdentity) {
    const myBucketPolicy = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:GetObject', 's3:ListBucket'],
      principals: [
        new iam.CanonicalUserPrincipal(
          identity.cloudFrontOriginAccessIdentityS3CanonicalUserId,
        ),
      ],
      resources: [bucket.bucketArn + '/*', bucket.bucketArn],
    })
    bucket.addToResourcePolicy(myBucketPolicy)
  }

  private createCloudFront(
    bucket: s3.Bucket,
    identity: cf.OriginAccessIdentity,
    cert: certManager.DnsValidatedCertificate,
    defaultCachePolicyName: string,
    imageCachePolicyName: string,
    distributionName: string,
    deployDomain: string,
    restApiUrl: string,
  ) {
    const defaultPolicyOption = {
      cachePolicyName: defaultCachePolicyName,
      comment: 'ポリシー',
      enableAcceptEncodingGzip: true,
      enableAcceptEncodingBrotli: true,
    }
    const myCachePolicy = new cf.CachePolicy(
      this,
      defaultCachePolicyName,
      defaultPolicyOption,
    )
    const imgCachePolicy = new cf.CachePolicy(this, imageCachePolicyName, {
      headerBehavior: cf.CacheHeaderBehavior.allowList(
        'Access-Control-Request-Headers',
        'Access-Control-Request-Method',
        'Origin',
      ),
    })
    const origin = new origins.S3Origin(bucket, {
      originAccessIdentity: identity,
    })
    const spaRoutingFunction = new cf.Function(this, 'SpaRoutingFunction', {
      functionName: 'SpaRoutingFunction',
      // 拡張子が含まれないURLはSPAファイルにリダイレクト
      code: cf.FunctionCode.fromInline(`
      function handler(event) {
        var request = event.request;
        if(request.uri.startsWith('/cartagraph-editor') && !request.uri.includes('.')) {
          request.uri = '/cartagraph-editor/index.html';
        } else if(request.uri.startsWith('/cartagraph-gamebook') && !request.uri.includes('.')) {
          request.uri = '/cartagraph-gamebook/index.html';
        } else if (!request.uri.includes('.')){
          request.uri = '/cartagraph/index.html';
        } 
        return request;
      }
      `),
    })
    core.Tags.of(spaRoutingFunction).add('Service', 'Cloud Front Function')
    const apiEndPointUrlWithoutProtocol = core.Fn.select(
      1,
      core.Fn.split('://', restApiUrl),
    )
    const apiEndPointDomainName = core.Fn.select(
      0,
      core.Fn.split('/', apiEndPointUrlWithoutProtocol),
    )
    const d = new cf.Distribution(this, distributionName, {
      comment: '',
      defaultRootObject: '/index.html',

      priceClass: cf.PriceClass.PRICE_CLASS_200,
      defaultBehavior: {
        origin,
        cachePolicy: myCachePolicy,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        functionAssociations: [
          {
            eventType: cf.FunctionEventType.VIEWER_REQUEST,
            function: spaRoutingFunction,
          },
        ],
      },
      additionalBehaviors: {
        'v1/*': {
          origin: new origins.HttpOrigin(apiEndPointDomainName, {}),
          allowedMethods: cf.AllowedMethods.ALLOW_ALL,
          viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: new cf.CachePolicy(
            this,
            `${distributionName}-rest-api-cache-policy`,
            {
              cachePolicyName: `${distributionName}-rest-api-cache-policy`,
              comment: 'CloudFront + ApiGateway用ポリシー',
              headerBehavior: cf.CacheHeaderBehavior.allowList(
                'x-api-key',
                'content-type',
              ),
            },
          ),
        },
      },
      minimumProtocolVersion: cf.SecurityPolicyProtocol.TLS_V1_2_2021,
      // Route53と連携するためのカスタムドメイン
      certificate: cert,
      domainNames: [deployDomain],
    })
    core.Tags.of(d).add('Service', 'Cloud Front')

    return d
  }

   private deployS3(
     siteBucket: s3.Bucket,
     distribution: cf.Distribution,
     sourcePath: string,
     bucketName: string,
   ) {
     // Deploy site contents to S3 bucket
     new s3deploy.BucketDeployment(
       this,
       `${bucketName}-deploy-with-invalidation`,
       {
         sources: [s3deploy.Source.asset(sourcePath)],
         destinationBucket: siteBucket,
         distribution,
         distributionPaths: ['/*'],
         destinationKeyPrefix: basePath,
       },
     )
   }

  private findRoute53HostedZone(rootDomain: string) {
    return route53.HostedZone.fromLookup(this, `${rootDomain}-hosted-zone`, {
      domainName: rootDomain,
    })
  }

  private createTLSCertificate(
    deployDomain: string,
    hostedZone: route53.IHostedZone,
  ) {
    return new certManager.DnsValidatedCertificate(
      this,
      `${deployDomain}-certificate`,
      {
        domainName: deployDomain,
        hostedZone, // DNS 認証に Route 53 のホストゾーンを使う
        region: 'us-east-1', // 必ず us-east-1 リージョン
        validation: certManager.CertificateValidation.fromDns(),
      },
    )
  }

  private addRoute53Records(
    zone: route53.IHostedZone,
    deployDomain: string,
    cf: cf.Distribution,
  ) {
    const propsForRoute53Records = {
      zone,
      recordName: deployDomain,
      target: route53.RecordTarget.fromAlias(
        new route53Targets.CloudFrontTarget(cf),
      ),
    }
    new route53.ARecord(this, 'ARecord', propsForRoute53Records)
    new route53.AaaaRecord(this, 'AaaaRecord', propsForRoute53Records)
  }
}

```

```ini:cdk/.env
PROJECT_ID=hogefuga
ROOT_DOMAIN=hoge.example.com
DEPLOY_DOMAIN=www.hogexxx.example.com
TAG_PROJECT_NAME=hogehoge
REST_API_URL=https://piyo.execute-api.ap-northeast-1.amazonaws.com

```

## 追加：CloudFrontからAPIGatewayへのアクセスにx-api-keyを追加。

```diff:cdk/lib/cdk-stack.ts
      additionalBehaviors: {
        'v1/*': {
          origin: new origins.HttpOrigin(apiEndPointDomainName, {
+            customHeaders: {
+              'x-api-key': apiKey,
+            },
          }),
```

```diff:.env
PROJECT_ID=hogefuga
ROOT_DOMAIN=hoge.example.com
DEPLOY_DOMAIN=www.hogexxx.example.com
TAG_PROJECT_NAME=hogehoge
REST_API_URL=https://piyo.execute-api.ap-northeast-1.amazonaws.com
+ X_API_KEY=piyopiyo
```

```ts:cdk/bin/cdk.ts
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { AWSCarTaGraphClientStack } from '../lib/cdk-stack'
import * as dotenv from 'dotenv'

dotenv.config()
const envList = [
  'PROJECT_ID',
  'ROOT_DOMAIN',
  'DEPLOY_DOMAIN',
  'TAG_PROJECT_NAME',
  'REST_API_URL',
  'X_API_KEY',
] as const
for (const key of envList) {
  if (!process.env[key]) throw new Error(`please add ${key} to .env`)
}
const processEnv = process.env as Record<typeof envList[number], string>

const app = new cdk.App()
const env = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
}
const projectId = processEnv.PROJECT_ID

new AWSCarTaGraphClientStack(app, `${projectId}-stack`, {
  bucketName: `${projectId}-s3-bucket`,
  identityName: `${projectId}-origin-access-identity-to-s3-bucket`,
  defaultCachePolicyName: `${projectId}-cache-policy-default`,
  imageCachePolicyName: `${projectId}-cache-policy-image`,
  functionName: `${projectId}-lambda-edge-ogp`,
  distributionName: `${projectId}-distribution-cloudfront`,
  rootDomain: processEnv.ROOT_DOMAIN,
  deployDomain: processEnv.DEPLOY_DOMAIN,
  projectNameTag: processEnv.TAG_PROJECT_NAME,
  restApiUrl: processEnv.REST_API_URL,
  apiKey: processEnv.X_API_KEY,
  env,
})
```

## 参考

[CloudFront・S3・API GatewayでマルチオリジンなSPAサイトを作ってみる](https://www.blog.danishi.net/2021/12/05/post-5536/)

https://dev.classmethod.jp/articles/spa-cloudfront-and-api-gateway-voiding-cors/


https://dev.classmethod.jp/articles/cloudfront-in-front-on-websocket-api-gateway/

https://github.com/awslabs/aws-solutions-constructs/tree/main/source/patterns/%40aws-solutions-constructs/aws-cloudfront-apigateway

https://zenn.dev/mryhryki/articles/2021-04-30-api-gateway-with-cloudfront

https://www.blog.danishi.net/2021/12/05/post-5536/

https://yohei-a.hatenablog.jp/entry/20210702/1625203047