【AWS CDK】API Gateway で S3 をプロキシしてオブジェクトをアップロードしてみた

はじめに

テントの中から失礼します、CX事業本部のてんとタカハシです!

API Gateway では、S3 をプロキシして、オブジェクトのダウンロードやアップロード、削除など様々なことができます。単純な CRUD 操作であれば、Lambda を実装することなく実現可能なので結構便利です。

本記事では、API Gateway で S3 をプロキシして、Bucket に格納されているオブジェクトをアップロードする REST API を CDK で作成してみます。

下記のチュートリアルをガッツリ参考していますので、細かい説明についてはこちらをご参照ください。

他の記事で、オブジェクトのダウンロード、削除、一覧取得にもチャレンジしています。

本記事及び、上記の記事にて試した機能を全て搭載した REST API のソースコードを下記リポジトリに置いています。

GitHub - iam326/api-gateway-proxy-to-s3-by-cdk

環境

環境は下記の通りです。

$ cdk --version
1.102.0 (build a75d52f)

$ yarn --version
1.22.10

$ node --version
v14.7.0

実装

  • オブジェクトを格納するための S3 Bucket を作成する
  • API Gateway で REST API を作成する
  • 上記 API にリソース/users/{userId}/files/{fileName}を作成する
  • 上記リソースに PUT メソッドを作成して、S3 をプロキシする

lib/api-gateway-proxy-to-s3-by-cdk-stack.ts

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

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

    const projectName: string = this.node.tryGetContext('projectName');

    // ★ S3
    
    const bucket = new s3.Bucket(this, 'Bucket', {
      bucketName: `${projectName}-bucket`,
    });

    // ★ API Gateway
    
    const restApiRole = new iam.Role(this, 'Role', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
      path: '/',
    });
    bucket.grantReadWrite(restApiRole);

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `${projectName}-api`,
      deployOptions: {
        stageName: 'v1',
        loggingLevel: apigateway.MethodLoggingLevel.INFO,
        dataTraceEnabled: true,
      },
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: ['POST', 'OPTIONS', 'PUT', 'DELETE'],
        statusCode: 200,
      },
    });

    // リソースを作成する `/users/{userId}/files/{fileName}`
    const users = restApi.root.addResource('users');
    const userId = users.addResource('{userId}');
    const files = userId.addResource('files');
    const fileName = files.addResource('{fileName}');
    
    // オブジェクトをアップロードするための PUT メソッドを作成する
    fileName.addMethod(
      'PUT',
      new apigateway.AwsIntegration({
        service: 's3',
        integrationHttpMethod: 'PUT',
        // アップロード先を指定する
        path: `${bucket.bucketName}/{folder}/{object}`,
        options: {
          credentialsRole: restApiRole,
          passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
          requestParameters: {
            // メソッドリクエストのパスパラメータ userId を 統合リクエストのパスパラメータ folder にマッピングする
            'integration.request.path.folder': 'method.request.path.userId',
            // メソッドリクエストのパスパラメータ fileName を 統合リクエストの object にマッピングする
            'integration.request.path.object': 'method.request.path.fileName',
          },
          integrationResponses: [
            {
              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',
                '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': "'*'",
              },
            },
            {
              statusCode: '400',
              selectionPattern: '4\\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': "'*'",
              },
            },
            {
              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': "'*'",
              },
            },
          ],
        },
      }),
      {
        requestParameters: {
          'method.request.path.userId': true,
          'method.request.path.fileName': true,
        },
        methodResponses: [
          {
            statusCode: '200',
            responseParameters: {
              'method.response.header.Timestamp': true,
              'method.response.header.Content-Length': true,
              'method.response.header.Content-Type': true,
              'method.response.header.Access-Control-Allow-Headers': true,
              'method.response.header.Access-Control-Allow-Methods': true,
              'method.response.header.Access-Control-Allow-Origin': true,
            },
          },
          {
            statusCode: '400',
            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,
            },
          },
          {
            statusCode: '500',
            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,
            },
          },
        ],
      }
    );
  }
}

動作確認(JSON ファイル)

API Gateway で作成した REST API を使って、テキトーな JSON ファイルをアップロードします。その後、アップロードしたファイルを直接 S3 からダウンロードして、中身を確認します。

$ curl -X PUT \
-H "Content-Type: application/json" \
-d '{"hello": "world"}' \
https://<DOMAIN_NAME>/v1/users/sample-user/files/sample.json

$ aws s3 cp s3://<BUCKET_NAME>/sample-user/sample.json sample.json
download: s3://<BUCKET_NAME>/sample-user/sample.json to ./sample.json

$ cat sample.json 
{"hello": "world"}

上手くアップロードできていることを確認できました。

JSON ファイルをアップロードをしたりするだけであればこれで良いのですが、このままだと画像のアップロードは上手くいきません。少し API の設定を変更する必要があります。

画像のアップロードに対応する

設定変更箇所をハイライト表示しています。

lib/api-gateway-proxy-to-s3-by-cdk-stack.ts

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

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

    const projectName: string = this.node.tryGetContext('projectName');

    // ★ S3
    
    const bucket = new s3.Bucket(this, 'Bucket', {
      bucketName: `${projectName}-bucket`,
    });

    // ★ API Gateway
    
    const restApiRole = new iam.Role(this, 'Role', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
      path: '/',
    });
    bucket.grantReadWrite(restApiRole);

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `${projectName}-api`,
      deployOptions: {
        stageName: 'v1',
        loggingLevel: apigateway.MethodLoggingLevel.INFO,
        dataTraceEnabled: true,
      },
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: ['POST', 'OPTIONS', 'PUT', 'DELETE'],
        statusCode: 200,
      },
      // バイナリメディアタイプを設定して、画像を扱えるようする
      binaryMediaTypes: ['image/*'],
    });

    // リソースを作成する `/users/{userId}/files/{fileName}`
    const users = restApi.root.addResource('users');
    const userId = users.addResource('{userId}');
    const files = userId.addResource('files');
    const fileName = files.addResource('{fileName}');
    
    // オブジェクトをアップロードするための PUT メソッドを作成する
    fileName.addMethod(
      'PUT',
      new apigateway.AwsIntegration({
        service: 's3',
        integrationHttpMethod: 'PUT',
        // アップロード先を指定する
        path: `${bucket.bucketName}/{folder}/{object}`,
        options: {
          credentialsRole: restApiRole,
          passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
          requestParameters: {
            // メソッドリクエストのヘッダー Content-Type を 統合リクエストのヘッダーにマッピングする
            'integration.request.header.Content-Type':
              'method.request.header.Content-Type'
            // メソッドリクエストのパスパラメータ userId を 統合リクエストのパスパラメータ folder にマッピングする
            'integration.request.path.folder': 'method.request.path.userId',
            // メソッドリクエストのパスパラメータ fileName を 統合リクエストの object にマッピングする
            'integration.request.path.object': 'method.request.path.fileName',
          },
          integrationResponses: [
            {
              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',
                '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': "'*'",
              },
            },
            {
              statusCode: '400',
              selectionPattern: '4\\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': "'*'",
              },
            },
            {
              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': "'*'",
              },
            },
          ],
        },
      }),
      {
        requestParameters: {
          'method.request.header.Content-Type': true,
          'method.request.path.userId': true,
          'method.request.path.fileName': true,
        },
        methodResponses: [
          {
            statusCode: '200',
            responseParameters: {
              'method.response.header.Timestamp': true,
              'method.response.header.Content-Length': true,
              'method.response.header.Content-Type': true,
              'method.response.header.Access-Control-Allow-Headers': true,
              'method.response.header.Access-Control-Allow-Methods': true,
              'method.response.header.Access-Control-Allow-Origin': true,
            },
          },
          {
            statusCode: '400',
            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,
            },
          },
          {
            statusCode: '500',
            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,
            },
          },
        ],
      }
    );
  }
}

動作確認(画像)

動作確認用に、この画像をsample.pngとして使用します。

API Gateway で作成した REST API を使って、画像をアップロードします。その後、アップロードしたファイルを直接 S3 からダウンロードして確認します。

$ curl -X PUT \
-H "Content-Type: image/png" \
--data-binary "@./sample.png" \
https://<DOMAIN_NAME>/v1/users/sample-user/files/sample.png

$ curl https://<DOMAIN_NAME>/v1/users/sample-user/files/sample.png --output get-sample.png
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 68853  100 68853    0     0   297k      0 --:--:-- --:--:-- --:--:--  298k

上手くいきました!

おわりに

本記事では、API Gateway で S3 をプロキシして、Bucket にオブジェクトをアップロードしてみました。次回は、オブジェクトの削除にチャレンジします。

今回は以上になります。最後まで読んで頂きありがとうございました!