概要

案件で、AWS SAM(Serverless Application Model、以後SAMと表記)を使用して、Boxにファイルを定期的にアップロードするLambdaを作成する機会がありました。
BoxAPIが使用される部分で苦戦したので、備忘のために残しておきます。
※SAMの基本的な仕組みについての説明は割愛します。

前提/やりたいこと

前提

前提として検証を行った環境は下記です。

  • AWS CLI version 2.13.7 (AWS CLIをインストール済みであること)
    • 認証情報を設定済み
  • SAM CLI version 1.95.0(SAM CLIをインストール済みであること)
  • pyenv version 2.4.11 (仮想環境 pyenvを使用)
  • python version 3.11.9
  • クライアント資格情報許可形式のBoxアプリケーションを作成済み(※詳細は後述)

やりたいこと

  • RDSに格納されているデータを取得し、それをcsvに書き出し、日次(毎日深夜1時)でboxのファイルにアップロードを行う

簡素ですが、構成図としては以下のような仕組みになります。

Boxの認証形式の違い

Boxでカスタムアプリケーションを作成する際、下記画像のように認証方法を選択する部分があります。記載のようにサーバー認証(JWT使用)ユーザー認証(OAuth2.0)サーバー認証(クライアント資格情報許可)の3つがあります。

それぞれの違いについて、表にまとめてみました。

認証方式 概要 利用ケース 認証の粒度 ユーザー関与
(ユーザーが認証プロセスに関与する必要性)
サーバー認証 (JWT使用) 外部コラボレーターやアプリの統合に最適。JSON Webトークンを使用し、公開キーと秘密キーで認証 サーバー間通信や外部統合アプリの認証 サーバー単位 (APIクライアント) 不要
ユーザー認証 (OAuth 2.0) ユーザーがBoxにログインして認証を行う形式。 ユーザーによるログインが必要なアプリ(例: モバイルアプリ、Webアプリ) ユーザー単位 必要
サーバー認証 (クライアント資格情報許可) アプリがクライアントIDとクライアントシークレットで認証を行う形式。 バックオフィスやサーバー間の統合、スクリプト処理 クライアント単位 不要

今回の実装では、Lambdaの定期実行・・・つまりスクリプト処理になるので、サーバー認証(クライアント資格情報許可)を使用する、という形になります。
サーバー認証(クライアント資格情報許可)アプリケーションを作成する際は、
クライアントID・クライアントシークレット・EnterpriseID
この3つが必要になります。
※Enterpriseアプリケーションでは、boxの管理者に作成したカスタムアプリを承認していただく必要がありますので注意が必要です。

フォルダ構成

一部抜粋して記載

.
└── プロジェクトのルートフォルダ
    ├── layers
    │   └── common_layer
    │       ├── common_utils.py
    │      
    ├── samconfig.toml
    ├── src
    │   └── upload_csv
    │       ├── __init__.py
    │       ├── app.py
    │       └── requirements.txt
    ├── template.yaml

実装に使用したコード

template.yaml

下記のような形でリソース定義をしています。
BoxUploadFunctionでLambda関数を、 ScheduledRuleForBoxUploadでEventBridgeルールを定義しています。その他の設定周りの解説は省略します。また、認証情報について、大部分は設定値を伏せています。

Parameters:
  Env:
    Type: String
    AllowedValues:
      - dev
    Default: dev
  App:
    Type: String
    Default: XXXXX
Mappings:
  EnvMap:
    dev:
      TargetEnv: "dev"
      VPCID: "XXXXXX"
      VPCSubnet1:"XXXXXX"
      VPCSubnet2: "XXXXXX"
      VPCSecurityGroup: "XXXXXX"
      DBHostname: "XXXXXX"
      DBDatabase: "XXXXXX"
      DBUsername: "XXXXXX"
      DBPassword:"XXXXXX"
      BOXCLIENTID:"XXXXXX" # BOXアプリの認証情報 
      BOXCLIENTSECRET: "XXXXXX"
      BOXGRANTTYPE : "client_credentials"
      BOXSUBJECTTYPE: "enterprise" 
      BOXSUBJECTID : "XXXXXX" # BoxアプリのEnterpriseIDを指定


 Resources:
 CommonLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: common_layer
      Description: レイヤー
      ContentUri: "layers/common_layer/"
      CompatibleRuntimes:
        - python3.11
    Metadata:
      BuildMethod: python3.11


  # Boxにアップロードを行う関数
  BoxUploadFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/upload_csv
      Handler: app.lambda_handler
      Timeout: 30
      Runtime: python3.11
      Architectures:
        - x86_64
      Layers:
        - !Ref CommonLayer
      Environment:
        Variables:
          BOXCLIENTID: !FindInMap [ EnvMap, !Ref Env, BOXCLIENTID ]
          BOXCLIENTSECRET: !FindInMap [ EnvMap, !Ref Env, BOXCLIENTSECRET ]
          BOXGRANTTYPE: !FindInMap [ EnvMap, !Ref Env, BOXGRANTTYPE ]
          BOXSUBJECTTYPE: !FindInMap [ EnvMap, !Ref Env, BOXSUBJECTTYPE ]
          BOXSUBJECTID: !FindInMap [ EnvMap, !Ref Env, BOXSUBJECTID]
      Role:
        !GetAtt SamLambdaExecutionRole.Arn

  PermissionForEventsToBoxUploadFunction:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref BoxUploadFunction
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt ScheduledRuleForBoxUpload.Arn

  # Lambdaを定期的に実行するためのEventBridgeのルールを定義
  ScheduledRuleForBoxUpload:
    Type: AWS::Events::Rule
    Properties:
      Description: "Schedule for Lambda Function to upload csv to box"
      ScheduleExpression: "cron(0 16 * * ? *)" # JSTの毎日深夜1時実行
      State: "ENABLED"
      Targets:
        - Arn: !GetAtt BoxUploadFunction.Arn
          Id: "BoxUploadFunction"
   SamLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${App}-${Env}-backend-lambda-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: !Sub ${App}-${Env}-backend-lambda-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                  - "logs:DescribeLogStreams"
                Resource: "arn:aws:logs:*:*"

                Effect: "Allow"
                Action:
                  - "secretsmanager:GetSecretValue"
                  - "secretsmanager:PutResourcePolicy"
                  - "secretsmanager:PutSecretValue"
                  - "secretsmanager:DeleteSecret"
                  - "secretsmanager:DescribeSecret"
                  - "secretsmanager:TagResource"
                Resource: "arn:aws:secretsmanager:*:*:secret:rds-db-credentials/*"
              -
                Effect: "Allow"
                Action:
                  - "secretsmanager:GetSecretValue"
                  - "secretsmanager:CreateSecret"
                  - "secretsmanager:ListSecrets"
                  - "secretsmanager:GetRandomPassword"
                  - "tag:GetResources"
                  - "rds-data:BatchExecuteStatement"
                  - "rds-data:BeginTransaction"
                  - "rds-data:CommitTransaction"
                  - "rds-data:ExecuteStatement"
                  - "rds-data:RollbackTransaction"
                  - "lambda:InvokeAsync"
                  - "lambda:InvokeFunction"
                  - "ec2:CreateNetworkInterface"
                  - "ec2:DescribeNetworkInterfaces"
                  - "ec2:DeleteNetworkInterface"
                Resource: "*"

その他ファイル

samconfig.toml
[dev.deploy.parameters]
stack_name = "XXXXXX(スタック名)"
s3_bucket = "XXXXXX(バケット名)"
s3_prefix = "XXXXXX"
region = "XXXXXX(リージョン名)"
capabilities = "CAPABILITY_IAM"
parameter_overrides = "Env=dev"
common_utils.py
import logging
import os
logger_ = logging.getLogger()

logLevelTable_ = {
    'NOTSET':   logging.NOTSET,    
    'DEBUG':    logging.DEBUG,     
    'INFO':     logging.INFO,      
    'WARNING':  logging.WARNING,
    'ERROR':    logging.ERROR,      
    'CRITICAL': logging.CRITICAL, 
}

logLevel_ = logging.INFO

if 'LOGGING_LEVEL' in os.environ and os.environ['LOGGING_LEVEL'] in logLevelTable_:
    logLevel_ = logLevelTable_[os.environ['LOGGING_LEVEL']]

logger_.setLevel(logLevel_)


def getLogger():
    return logger_
requirements.txt

以下の外部ライブラリを使用するため記載しています。

pymysql
boxsdk
pandas
boto3
app.py

boxのアップロードに、boxSDK for pythonを使用しています。
ファイルのアップロードにあたってはupload_streamメソッドを使用しています。(こちら参照)

 client.folder(folder_id).upload_stream(file_stream, file_name)

という形でファイルのアップロードを行うことができます。

処理のおおまかな流れとしては
データベースからデータを取得→認可処理を行いアクセストークンを取得しboxクライアントを作成→取得データをboxにアップロードする
という形になります。

import os
import json
from io import StringIO
import common_utils
import logging
import datetime

from boxsdk import OAuth2, Client
import boto3
import pymysql
import pandas as pd
import requests

# ログ出力のためのlogger取得
logger = common_utils.getLogger()

# DB接続情報
db_host = os.environ['DB_HOSTNAME']  # DBホスト名
db_user = os.environ['DB_USERNAME']  # DBユーザー名
db_pass = os.environ['DB_PASSWORD']  # DBパスワード
db_data = os.environ['DB_DATABASE']  # DBデータベース

# DBへ接続
db_connection = pymysql.connect(
    host=db_host,
    user=db_user,
    password=db_pass,
    database=db_data,
    cursorclass=pymysql.cursors.DictCursor  # 結果を辞書形式で取得
)

def lambda_handler(event, context):
    """BoxUpload
        データベースからデータを取得し、csvに変換してboxにアップロードする
        Parameters
        ----------
        None.

        Returns
        ------
        StatusCode : ステータスコード
        body: 処理完了を示す文言
        """
    #1.データベースからデータを取得してCSVファイルに変換
    csv_data = get_data_and_create_csv()
    logger.info(csv_data)

    #2.Boxクライアントを初期化
    client = initialize_box_client()
    logger.info(client)

    #3.データをBOXにアップロード
    upload_csv_to_box(client, csv_data)

    return {
        'statusCode': 200,
        'body': json.dumps('Process completed successfully')
    }


def get_data_and_create_csv():
    """テーブルからレコードを取得して、CSVに保存する関数
        Parameters
        ----------
        None.

        Returns
        ------
        csv_data : CSV出力用のデータ
        """

    try:
        logger.info('DB Connection started.')
        with db_connection.cursor() as cursors:
            # SQL文
            sql = """
               SELECT
                   column_1(カラム1),
                   column_2(カラム2),
                   .....
                FROM
                  sample_table(テーブル名)
            """
            # 実行
            cursors.execute(sql)

            # SQLクエリの結果を取得
            query_result = cursors.fetchall() 
            logger.info(query_result)

        # データベース接続を閉じる
        db_connection.close()
        logger.info("DB Connection closed.")

        # カラム名の取得(CSV出力の際のヘッダに利用)
        columns = []
        if cursors.description:
            columns = [desc[0] for desc in cursors.description]

        # レコードが空の場合も、ヘッダを含むデータフレームを作成
        # 取得したレコードをpandasのデータフレームに変換
        if not query_result:
            if not columns:
                raise ValueError("No columns found to generate CSV header.")
            # カラム名を使って空のデータフレームを作成
            df = pd.DataFrame(columns=columns)
        else:
            # クエリの結果があれば通常通りデータフレームを作成
            df = pd.DataFrame(query_result, columns=columns)
            logger.info(df)

        # CSVを文字列として保存
        csv_buffer = StringIO()
        df.to_csv(csv_buffer, index=False, header=True, encoding='utf-8')

        return csv_buffer.getvalue()


# アップロード先のBoxのフォルダID (簡略化のために直書きしています。実際は定数ファイルや
# 環境変数に登録などしてください)
folder_id = "XXXXXX"

# アップロードAPIのURL(簡略化のために直書きしています。実際は定数ファイルや
# 環境変数に登録などしてください)
url =  "https://upload.box.com/api/2.0/files/content"

# box接続のための認可処理
# アクセストークンを取得
def get_access_token():
    url = const.BOX_OAUTH2_TOKEN_URL

    data = {
        'client_id': os.environ['BOXCLIENTID'],
        'client_secret': os.environ['BOXCLIENTSECRET'],
        'grant_type': os.environ['BOXGRANTTYPE'],
        'box_subject_type': os.environ['BOXSUBJECTTYPE'],
        'box_subject_id': os.environ['BOXSUBJECTID'],
    }
    response = requests.post(url, data=data, verify=True)
    logger.info(response)
    if response.status_code == 200:
        token_data = response.json()
        logger.info(token_data)
        return token_data['access_token']
    else:
        raise Exception(
            f"Failed to get access token: {response.status_code} - {response.text}")


# Boxクライアントを初期化する関数
def initialize_box_client():
    access_token = get_access_token()
    oauth2 = OAuth2(client_id=os.environ['BOXCLIENTID'],
                    client_secret=os.environ['BOXCLIENTSECRET'], access_token=access_token)
    client = Client(oauth2)
    return client


# Boxにファイルをアップロードする関数
def upload_csv_to_box(client, csv_data):
    """Boxにファイルをアップロードする関数

        Parameters
        ----------
        client: Boxクライアント
        csv_data アップロード対象のcsvデータ

        Returns
        ------
        None.
        """
    try:
        # 現在の時間でファイル名を生成
        current_time = datetime.now().strftime('%Y%m%d_%H%M%S')
        file_name = f'{"(任意のファイル名)"}_{current_time}.csv'

        # メモリ上のCSVデータをBoxにアップロード
        file_stream = StringIO(csv_data)

        # ファイルをBoxフォルダにアップロード
        uploaded_file = client.folder(
            folder_id).upload_stream(file_stream, file_name)
        logger.info(
            f"File '{file_name}' uploaded successfully to Box (ID: {uploaded_file.id}).")

    except Exception as e:
        logger.error(f"Error during file upload: {e}")

ビルド&デプロイ

デプロイするユーザーのprofile(認証情報)を引数に指定してあげます。
--config-env devは、Mappingsセクションで定義した以下の部分の情報を取得することを指しています

Mappings:
  EnvMap:
    dev:
      TargetEnv: "dev"
      VPCID: "XXXXXX"
      VPCSubnet1:"XXXXXX"
      VPCSubnet2: "XXXXXX"
      VPCSecurityGroup: "XXXXXX"
      DBHostname: "XXXXXX"
      DBDatabase: "XXXXXX"
      DBUsername: "XXXXXX"
      DBPassword:"XXXXXX"
      BOXCLIENTID:"XXXXXX" # BOXアプリの認証情報 
      BOXCLIENTSECRET: "XXXXXX"
      BOXGRANTTYPE : "client_credentials"
      BOXSUBJECTTYPE: "enterprise" 
      BOXSUBJECTID : "XXXXXX" # BoxアプリのEnterpriseIDを指定

あとは、下記コマンドでビルド、デプロイを実施します。

sam build --config-env dev --profile XXXX
sam deploy --config-env dev --profile XXXX

詰まった点(今回一番時間をかけてしまった部分)

アップロード先のフォルダIDは正しいのに”Not Found”エラーが出る

上記設定を踏まえて、
実際にデプロイしてLambdaを実行してみたところ、下記のようなエラーが出ていました。

[ERROR] 2024-09-18T07:38:10.640Z    4899f23a-634e-43f4-8a62-954f21f95a0e    An unexpected error occurred: Message: Not Found
Status: 404
Code: not_found
Request ID: 2g0wj0htrwrhmfol
Headers: {'date': 'Wed, 18 Sep 2024 07:38:10 GMT', 'content-type': 'application/json', 'x-envoy-upstream-service-time': '181', 'box-request-id': 'XXXXXX', 'cache-control': 'no-cache, no-store', 'strict-transport-security': 'max-age=31536000', 'via': '1.1 google', 'Alt-Svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', 'Transfer-Encoding': 'chunked'}
URL: https://api.box.com/2.0/folders/XXXXXX(フォルダID)/items
Method: GET
Context Info: {'errors': [{'reason': 'invalid_parameter', 'name': 'folder', 'message': "Invalid value 'd_XXXXXX(フォルダID)'. 'folder' with value 'd_XXXXXX(フォルダID)' not found"}]}

エラー内容からして、アクセストークンを取得する関数は実行することができているようでしたが、
その先のファイルアップロードの関数でエラーが出ているようでした。

ローカルでcurlを叩いてみても、結果は同様でした。

 curl -X POST https://api.box.com/2.0/folders \                                                                                                                   -H "Authorization: Bearer XXXXXX(アクセストークン)" \                                                                                                                         
     -H "Content-Type: application/json" \
     -d '{"name": "XXXXXX(アプリ名)", "parent": {"id": "XXXXX(フォルダID)X"}}'
{"type":"error","status":404,"code":"not_found","context_info":{"errors":[{"reason":"invalid_parameter","name":"parent","message":"Invalid value 'd_XXXXXX(フォルダID)'. 'parent' with value 'd_XXXXXX(フォルダID)' not found"}]},"help_url":"http:\/\/developers.box.com\/docs\/#errors","message":"Not Found","request_id":"XXXXXX"}

正しいフォルダIDを指定しているはずなのにエラーが出ている状況でした。

どう解決したか

原因が全くわからなかったため、とりあえず開発者用のアクセストークンを用いて、アップロードAPIを叩いてみたところ、正常にアップロードAPIが実行されました。
※開発者トークンとは、開発およびテスト中に開発者が利用できるアクセストークンです。(あくまでも開発/テスト環境で使用するためのものなので、利用範囲についてはご注意ください。)
参照

そのため、原因はこのアクセストークンの権限の範囲が問題なのか?とも考えました。
しかしながら、結果は全然違いました。

アプリに関する設定をよくよく見返してみたところ、サービスアカウント情報の部分の説明文に、下記の説明がありました。

このアプリが承認されると、サービスアカウントが自動的に生成されます。このアカウントは、あなたのアプリのユーザーとして使用されます。デフォルトでは、アプリはこのユーザーとしてAPIコールを行います。

この記述から、「サービスアカウントのユーザーをフォルダに追加する必要がある?」と予想しました。

つまり、アップロードAPIを叩くユーザーがフォルダにコラボレーターとして追加されていなければ、当然そのユーザーから見ればフォルダの存在を認識することはできません。
そのため404 Not Found エラーが出ていた、というわけでした。

予想通り、アップロード対象のフォルダに、アプリ作成時のサービスアカウントをアップロード対象のフォルダに追加してあげることで、指定のフォルダにcsvファイルをアップロードすることができました。

CloudWatchのログを見ると、定期実行も問題なく行われているようでした。

【CloudWatchログ】

まとめ

BoxAPIを使用するにあたって、今回行った実装内容に関連する日本語の記事があまり少なく、若干実装に苦戦しました。
結果としては、公式ドキュメントに書いてあることや注意書きなどをしっかり見ることで解決につながったので、原点に立ち返って、初歩的な部分でミスをしていないかを確認することが改めて大事だなと感じました。また、Boxアプリの認証認可の仕組みや、Boxアプリの構造を十分理解して開発を行うことが重要だと感じたので、引き続き学習していければと思います。

参照資料

【BoxDev】

【Box Support】