【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}を作成する
  • 上記リソースに GET メソッドを作成して、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}');
    
    // オブジェクトをダウンロードするための GET メソッドを作成する
    fileName.addMethod(
      'GET',
      new apigateway.AwsIntegration({
        service: 's3',
        integrationHttpMethod: 'GET',
        // ダウンロード先を指定する
        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,              
            },            
          },
        ],
      }
    );
  }
}

動作確認(テキストファイル)

テキトーなテキストファイルを作成して、直接 S3 にアップロードします。その後、API Gateway で作成した REST API にリクエストすると、アップロードしたファイルの中身が返ってきます。

$ cat sample.txt
hello, world

$ aws s3 cp sample.txt s3://<BUCKET_NAME>/sample-user/sample.txt
upload: ./sample.txt to s3://<BUCKET_NAME>/sample-user/sample.txt

$ curl  https://<DOMAIN_NAME>/v1/users/sample-user/files/sample.txt                                         
hello, world

テキストファイルをダウンロードするのであればこれで良いのですが、このままだと画像のダウンロードは上手くいきません。少し 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.grantRead(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}');
    
    // オブジェクトをダウンロードするための GET メソッドを作成する
    fileName.addMethod(
      'GET',
      new apigateway.AwsIntegration({
        service: 's3',
        integrationHttpMethod: 'GET',
        // ダウンロード先を指定する
        path: `${bucket.bucketName}/{folder}/{object}`,
        options: {
          credentialsRole: restApiRole,
          passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
          requestParameters: {
            // メソッドリクエストのヘッダー Accept を 統合リクエストのヘッダーにマッピングする
            'integration.request.header.Accept': 'method.request.header.Accept',
            // メソッドリクエストのパスパラメータ 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.Accept': 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として使用します。

画像を直接 S3 にアップロードした後、API Gateway で作成した REST API にリクエストすると、アップロードした画像をダウンロードできます。

$ aws s3 cp sample.png s3://<BUCKET_NAME>/sample-user/sample.png
upload: ./sample.png to s3://<BUCKET_NAME>/sample-user/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   294k      0 --:--:-- --:--:-- --:--:--  294k

html ファイルの img タグで画像を表示した場合も上手くいきます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Sample</title>
  </head>
  <body>
    <p>GET Image File</p>
    <img
      src="https://<DOMAIN_NAME>/v1/users/sample-user/files/sample.png"
    />
  </body>
</html>

おわりに

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

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