[AWS] Serverless FrameworkでRESTfulAPIを作成する (S3編)

今回はServerless FrameworkでRESTfulAPIを介しS3からデータを取得したり保存したりします。

DynamoDBの回と同様に、プロジェクトの設定ファイルであるserverless.ymlにCloud Formationの書式でS3の設定からIAM周りまで一元管理できます。分かってくると楽しいですね。逆に詰まるとネット上に情報があまり転がってなくて地獄ですがw

serverless.ymlの設定

S3のバケット名を環境変数として定義しておきます。最終的に「(ステージ名).storage」といった感じにステージ名を先頭に付けることができます。例えばdevへデプロイする時には「dev.storage」と言った文字列となります。この環境変数YAML内以外にもプログラムからも参照できます。ちなみにS3はドメインと同じ書式で命名する必要があります(data.example.com的な)。また同じリージョンで他の利用者に使われている文字列は使えません、早い者勝ちです。

provider:
  environment:
    S3_BUCKET: ${self:provider.stage}.storage

resourcesから下にCloud Formationの書式で必要なリソースやIAMを定義していきます。IAMの権限などは適宜調整してください。

resources:
  Resources:
    ### S3バケット設定
    Bucket:
      Type: AWS::S3::Bucket
      Properties:
        # バケット名
        BucketName: ${self:provider.environment.S3_BUCKET}

    ### IAMの設定
    S3IamPolicy:
      Type: AWS::IAM::Policy
      DependsOn: Bucket
      Properties:
        PolicyName: lambda-s3
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
            ## バケットからデータを読み取り、追加、削除
            - Effect: Allow
              Action:
                - s3:GetObject
                - s3:PutObject
                - s3:DeleteObject
              Resource: arn:aws:s3:::${self:provider.environment.S3_BUCKET}/*

            ## オブジェクトの一覧を取得する
            - Effect: Allow
              Action:
                - s3:ListBucket
              Resource: arn:aws:s3:::${self:provider.environment.S3_BUCKET}
        Roles:
          - Ref: IamRoleLambdaExecution

Node.jsからS3を操作する

追加と更新 - Create,Update

データを新規に記録、または更新する場合にはs3.putObject()を利用します。更新する場合は単純に同名のKeyを指定するだけ。S3はファイルサーバに利用しますが実態はKVSですからね。

const AWS = require("aws-sdk");
const s3 = new AWS.S3();

module.exports.set = (event, context, callback) => {
  // データ準備
  const params = {
    Bucket: "bucketname",      // バケット名
    Key: "hello.txt",          // ファイル名
    Body: "こんにちは!こんにちは!",  // ファイルの内容
    ContentType: "text/plain"  // MIME (任意)
  };

  // S3へ記録
  s3.putObject(params, (err, data)=>{
    // エラー時
    if ( err ) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    // 正常終了
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data})
      });
    }
  });
}

成功時のdataには以下のような値が入っています。

{"ETag":"\"7ac66c0f148de9519b8bd264312c4d64\""}

読み込み – Read

S3からデータを取得する場合にはs3.getObject()を利用します。

const AWS = require("aws-sdk");
const s3 = new AWS.S3();

module.exports.get = (event, context, callback) => {
  // データ準備
  const params = {
    Bucket: "bucketname",      // バケット名
    Key: "hello.txt",          // ファイル名
  };

  // S3からデータ取得
  s3.getObject(params, (err, data)=>{
    // エラー時
    if ( err ) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    // 正常終了
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data})
      });
    }
  });
}

正常終了した際にdataに入っているのは以下の様なデータです。実際のファイル内容はBody.dataなのですがご覧の通りバイナリ(Buffer)として返されます。

{
  "AcceptRanges": "bytes",
  "LastModified": "2020-06-04T12:09:26.000Z",
  "ContentLength": 7,
  "ETag": "\"7ac66c0f148de9519b8bd264312c4d64\"",
  "ContentType": "text/plain",
  "ServerSideEncryption": "AES256",
  "Metadata": {},
  "Body": {
    "type": "Buffer",
    "data": [
      97,
      98,
      99,
      100,
      101,
      102,
      103
    ]
  }
}

クライアントで変換しても良いのですが、Lambda側で文字列に直す場合は単純にtoString()を通すだけです。

callback(null, {
  statusCode: 200,
  body: JSON.stringify({status:true, data:data.Body.toString()})
});

削除 – Delete

S3からデータを削除する場合にはs3.deleteObject()を利用します。

const AWS = require("aws-sdk");
const s3 = new AWS.S3();

module.exports.remove = (event, context, callback) => {
  // データ準備
  const params = {
    Bucket: "bucketname",      // バケット名
    Key: "hello.txt",          // ファイル名
  };

  // S3からデータ削除
  s3.deleteObject(params, (err, data)=>{
    // エラー時
    if ( err ) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    // 正常終了
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data})
      });
    }
  });
}

こいつが厄介なのは、削除対象のファイル(Key)が存在しなくてもエラーにはならない点です。またバージョニングなどがされていないバケットでは成功時のdataには何も入っていません。

オブジェクトの一覧

バケット内のオブジェクトの一覧を取得する際には、s3.listObjects()またはs3.listObjectsV2()を利用します。新規のプロジェクトで特別な理由が無ければV2の利用がAWSからは推奨されているようです。

Important This API has been revised. We recommend that you use the newer version, ListObjectsV2, when developing applications. For backward compatibility, Amazon S3 continues to support ListObjects.

※AWS API Referenceより

はい、というわけで何も考えずにV2を使ったサンプルですw

const AWS = require("aws-sdk");
const s3 = new AWS.S3();

module.exports.list = (event, context, callback) => {
  // 準備
  const params = {
    Bucket: "bucketname",      // バケット名
  };

  // S3からオブジェクトの一覧を取得
  s3.listObjectsV2(params, (err, data)=>{
    // エラー時
    if (err) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    // 正常終了
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data})
      });
    }
  });
};

成功時にdataに入ってくるのは以下の様な値です。Contentsの中に配列としてファイル一覧が格納されます。

{
  "IsTruncated": false,
  "Contents": [
    {
      "Key": "a.txt",
      "LastModified": "2020-06-04T12:41:50.000Z",
      "ETag": "\"7ac66c0f148de9519b8bd264312c4d64\"",
      "Size": 7,
      "StorageClass": "STANDARD"
    },
    {
      "Key": "b.txt",
      "LastModified": "2020-06-04T12:41:53.000Z",
      "ETag": "\"7ac66c0f148de9519b8bd264312c4d64\"",
      "Size": 7,
      "StorageClass": "STANDARD"
    },
    {
      "Key": "c.txt",
      "LastModified": "2020-06-04T12:09:26.000Z",
      "ETag": "\"7ac66c0f148de9519b8bd264312c4d64\"",
      "Size": 7,
      "StorageClass": "STANDARD"
    },
    {
      "Key": "d.txt",
      "LastModified": "2020-06-04T12:18:07.000Z",
      "ETag": "\"7ac66c0f148de9519b8bd264312c4d64\"",
      "Size": 7,
      "StorageClass": "STANDARD"
    }
  ],
  "Name": "dev.storage1",
  "Prefix": "",
  "MaxKeys": 1000,
  "CommonPrefixes": [],
  "KeyCount": 4
}

実際に試してみる

準備

基本的な設定方法については過去記事を参照ください。 blog.katsubemakito.net

最初に「rest-s3」という名前のプロジェクトを作成しました。

$ serverless        
Serverless: No project detected. Do you want to create a new one? Yes
Serverless: What do you want to make? AWS Node.js
Serverless: What do you want to call this project? rest-s3

作成されたディレクトリに入ってnpm initしておきます。

$ cd rest-s3
$ npm init

追加でモジュールのインストールを行います。

$ npm install aws-sdk query-string

サンプルコード

今回はS3に対してシンプルなCRUDをするサンプルを動かしてみます。

serverless.yml

プロジェクトの設定を行います。S3を準備しつつCRUDを行うAPIを3つ、バケット内のオブジェクト一覧を返すAPIを1つ作成します。

service: rest-s3
provider:
  name: aws
  runtime: nodejs12.x
  environment:
    S3_BUCKET: ${self:provider.stage}.storage1
functions:
  get:
    handler: handler.get
    events:
      - http:
          path: storage/get/{key}
          method: get
  set:
    handler: handler.set
    events:
      - http:
          path: storage/set/{key}
          method: post
  remove:
    handler: handler.remove
    events:
      - http:
          path: storage/remove/{key}
          method: post
  list:
    handler: handler.list
    events:
      - http:
          path: storage/list
          method: get
resources:
  Resources:
    Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:provider.environment.S3_BUCKET}
        ## デフォルト暗号化
        BucketEncryption:
          ServerSideEncryptionConfiguration:
            - ServerSideEncryptionByDefault:
                SSEAlgorithm: AES256
    S3IamPolicy:
      Type: AWS::IAM::Policy
      DependsOn: Bucket
      Properties:
        PolicyName: lambda-s3
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - s3:GetObject
                - s3:PutObject
                - s3:DeleteObject
              Resource: arn:aws:s3:::${self:provider.environment.S3_BUCKET}/*
            - Effect: Allow
              Action:
                - s3:ListBucket
              Resource: arn:aws:s3:::${self:provider.environment.S3_BUCKET}
        Roles:
          - Ref: IamRoleLambdaExecution

handler.js

実際のコードです。handler.jsの中に以下の関数をそのまま貼り付けます。

'use strict';

//----------------------------------------
// モジュール
//----------------------------------------
const queryString = require("query-string");
const AWS = require("aws-sdk");
const s3 = new AWS.S3();

//----------------------------------------
// 定数
//----------------------------------------
// S3バケット名
const S3_BUCKETNAME = process.env.S3_BUCKET;


/**
 * S3からデータを返す
 *  GET /(stage)/storage/get/{key}
 *
 *  example)
 *    $ curl 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/storage/get/foo.txt'
 */
module.exports.get = (event, context, callback) => {
  //--------------------------
  // 引数チェック
  //--------------------------
  const key = event.pathParameters.key;
  let err = [];
  if( ! ( key !== "" && key.match(/^([a-zA-Z0-9]{1,})\.txt$/) ) ){
    err.push("invalid key")
  }

  // エラーがあれば終了
  if( err.length >= 1 ){
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({status:false, message:err}),
    });

    return(false);
  }

  //--------------------------
  // S3からデータ取得
  //--------------------------
  const params = {
    Bucket: S3_BUCKETNAME,
    Key: key,
  };

  s3.getObject(params, (err, data)=>{
    if (err) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data, body:data.Body.toString()})
      });
    }
  });
};


/**
 * S3に保存する
 *  POST /(stage)/storage/set/{key}
 *  data=xxxxx
 *
 *  example)
 *    $ curl -d 'data=xxxxx' -X POST 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/storage/set/foo.txt'
 */
module.exports.set = (event, context, callback) => {
  //--------------------------
  // 引数チェック
  //--------------------------
  const body = queryString.parse(event.body);
  const key = event.pathParameters.key;
  let err = [];
  if( ! ( key !== "" && key.match(/^([a-zA-Z0-9]{1,})\.txt$/) ) ){
    err.push("invalid key")
  }
  if( ! ("data" in body && body.data !== "" && body.data.length <= 1024) ){
    err.push("invalid data")
  }

  // エラーがあれば終了
  if( err.length >= 1 ){
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({status:false, message:err}),
    });

    return(false);
  }

  //--------------------------
  // S3へ保存
  //--------------------------
  const params = {
    Bucket: S3_BUCKETNAME,
    Key: key,
    Body: body.data,
    ContentType: "text/plain"
  };

  s3.putObject(params, (err, data)=>{
    if (err) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data})
      });
    }
  });
};


/**
 * S3からデータを削除する
 *  POST /(stage)/storage/remove/{key}
 *
 *  example)
 *    $ curl -X POST 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/storage/remove/foo.txt'
 */
module.exports.remove = (event, context, callback) => {
  //--------------------------
  // 引数チェック
  //--------------------------
  const key = event.pathParameters.key;
  let err = [];
  if( ! ( key !== "" && key.match(/^([a-zA-Z0-9]{1,})\.txt$/) ) ){
    err.push("invalid key")
  }

  // エラーがあれば終了
  if( err.length >= 1 ){
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({status:false, message:err}),
    });

    return(false);
  }

  //--------------------------
  // S3からデータ削除
  //--------------------------
  const params = {
    Bucket: S3_BUCKETNAME,
    Key: key,
  };

  s3.deleteObject(params, (err, data)=>{
    if (err) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data})
      });
    }
  });
};


/**
 * S3のオブジェクト一覧を取得
 *  POST /(stage)/storage/list
 *
 *  example)
 *    $ curl 'https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/storage/list'
 */
module.exports.list = (event, context, callback) => {
  const params = {
    Bucket: S3_BUCKETNAME,
  };

  s3.listObjectsV2(params, (err, data)=>{
    if (err) {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify({status:false, message:err})
      });
    }
    else {
      callback(null, {
        statusCode: 200,
        body: JSON.stringify({status:true, data:data})
      });
    }
  });
};

デプロイ

deployコマンドでAWSへ反映します。

$ serverless deploy

途中でURLが表示されるのでこれをメモします。

endpoints:
  GET - https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/get/{key}
  POST - https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/set/{key}
  POST - https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/remove/{key}
  GET - https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/list

この時点ですでにS3にバケットが作成されています。AWSマネジメントコンソールから確認してください。YAMLなどで未指定の場合はバージニア北部にできています。

実行する

curlコマンドで挙動を確認してみます。

追加 - Create

まずはデータが一切無いので追加から行います。a.txt, b.txt, c.txtの3つのファイルを作成しました。

$ curl -d 'data=abcdefg' -X POST https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/set/a.txt
{"status":true,"data":{"ETag":"\"7ac66c0f148de9519b8bd264312c4d64\"","ServerSideEncryption":"AES256"}}

$ curl -d 'data=12345678' -X POST https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/set/b.txt
{"status":true,"data":{"ETag":"\"25d55ad283aa400af464c76d713c07ad\"","ServerSideEncryption":"AES256"}}

$ curl -d 'data=foobar' -X POST https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/set/c.txt
{"status":true,"data":{"ETag":"\"3858f62230ac3c915f300c664312c63f\"","ServerSideEncryption":"AES256"}}

S3のバケットの中をのぞくとデータが保存されてますね。実際に開いて中身も確認すると渡したデータがしっかり保存されているのがわかります。

なお、すでにあるファイル名を指定すると、更新(上書き)を行うことができます。

読み込み – Read

今度は先ほどS3へ保存したデータを読み込んでみます。 jqコマンドでJSONを人間が見やすいよう整形しています。うむ、バッチリですね。

$ curl 'https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/get/c.txt' | jq
{
  "status": true,
  "data": {
    "AcceptRanges": "bytes",
    "LastModified": "2020-06-04T16:39:35.000Z",
    "ContentLength": 6,
    "ETag": "\"3858f62230ac3c915f300c664312c63f\"",
    "ContentType": "text/plain",
    "ServerSideEncryption": "AES256",
    "Metadata": {},
    "Body": {
      "type": "Buffer",
      "data": [
        102,
        111,
        111,
        98,
        97,
        114
      ]
    }
  },
  "body": "foobar"
}

オブジェクトの一覧

では現在S3のバケット内にあるオブジェクトの一覧を取ってきます。

$ curl 'https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/list' | jq
{
  "status": true,
  "data": {
    "IsTruncated": false,
    "Contents": [
      {
        "Key": "a.txt",
        "LastModified": "2020-06-04T16:38:26.000Z",
        "ETag": "\"7ac66c0f148de9519b8bd264312c4d64\"",
        "Size": 7,
        "StorageClass": "STANDARD"
      },
      {
        "Key": "b.txt",
        "LastModified": "2020-06-04T16:39:01.000Z",
        "ETag": "\"25d55ad283aa400af464c76d713c07ad\"",
        "Size": 8,
        "StorageClass": "STANDARD"
      },
      {
        "Key": "c.txt",
        "LastModified": "2020-06-04T16:39:35.000Z",
        "ETag": "\"3858f62230ac3c915f300c664312c63f\"",
        "Size": 6,
        "StorageClass": "STANDARD"
      }
    ],
    "Name": "dev.storage1",
    "Prefix": "",
    "MaxKeys": 1000,
    "CommonPrefixes": [],
    "KeyCount": 3
  }
}

削除 – Delete

最後に最初に作ったa.txtを削除してみます。

$ curl -X POST https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/remove/a.txt
{"status":true,"data":{}}

はい、無事に削除されているを確認できました。

せっかくなので先ほどのAPI経由でリストを取ってきてみますか。

$ curl 'https://ztqte5360e.execute-api.us-east-1.amazonaws.com/dev/storage/list' | jq
{
  "status": true,
  "data": {
    "IsTruncated": false,
    "Contents": [
      {
        "Key": "b.txt",
        "LastModified": "2020-06-04T16:39:01.000Z",
        "ETag": "\"25d55ad283aa400af464c76d713c07ad\"",
        "Size": 8,
        "StorageClass": "STANDARD"
      },
      {
        "Key": "c.txt",
        "LastModified": "2020-06-04T16:39:35.000Z",
        "ETag": "\"3858f62230ac3c915f300c664312c63f\"",
        "Size": 6,
        "StorageClass": "STANDARD"
      }
    ],
    "Name": "dev.storage1",
    "Prefix": "",
    "MaxKeys": 1000,
    "CommonPrefixes": [],
    "KeyCount": 2
  }
}

こちらも無事にa.txtが消えているのが確認できました。

参考ページ