API Gateway + Lambda + S3で画像・PDFアップロードAPIを作ってみた

# はじめに
画像をS3バケットにアップロードする場合であれば、AWS CLIコマンドの実行でアップロードできると思いますが、APIコールする形でS3にアップロードできないか気になったので試してみました。
本記事では、API Gatewway ⇒ Lambda ⇒ S3バケットのリソースを構築し、base64にフォーマットした画像をAPIコール時のリクエストbodyに乗せてAPIを叩いてみようと思います。

<details><summary>AWS CLIでS3へファイルアップロードする場合</summary>

```bash:ｋ
aws s3 cp 画像ファイル.jpg s3://バケット名/画像ファイル.jpg
```

</details>



# 環境
- Python：3.13
- Postman：11.43.3
- git bash


# ゴール
PostmanからAPIをコールしS3に画像がアップロードされてることを確認する。
リクエストbodyにはbase64形式にフォーマットした3つの画像形式（jpeg、png、webp）を載せる。
APIの叩き方はcurl、Postman、ブラウザのfetch apiの3つでやってみます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/bceb40a8-026e-4542-92f0-bc266e8c161d.png" width=700px>


# 1. Lambdaの作成

APIからのリクエストを処理してS3にファイルをアップロードするLambda関数を作成します。
以下の設定も行っています。
- IAMに追加するアクション："s3:PutObject"
- 環境変数（S3_BUCKET_NAME）にバケット名設定

```python:lambda_function.py
import json
import base64
import boto3
import os

from request_processor import process_request
from file_processor import process_file
from s3_uploader import upload_to_s3
from response_generator import create_response


def lambda_handler(event, context):
    # イベントが文字列の場合は JSON にパース
    if isinstance(event, str):
        event = json.loads(event)

    try:
        # リクエスト処理
        body = process_request(event)
        
        # ファイル処理
        file_name, file_data, content_type = process_file(body)
        
        # S3アップロード
        bucket_name = upload_to_s3(file_name, file_data, content_type)
        
        # 成功レスポンス
        return create_response(200, {
            'message': 'アップロード成功'
        })
        
    except ValueError as e:
        # バリデーションエラー
        print(f'バリデーションエラー: {str(e)}')
        return create_response(400, {'error': str(e)})

    except Exception as e:
        # その他のエラー
        print(f'サーバーエラー: {str(e)}')
        return create_response(500, {'error': f'サーバーエラー: {str(e)}'})
```

lambda_handlerから呼び出してる関数は以下のスクリプトに記載してます。

<details><summary>request_processor.py</summary>

```python:request_processor.py
import json
import base64


def process_request(event):
    # イベントのログ出力
    print(f"イベント内容: {json.dumps(event)}")

    # リクエストボディを取得
    body = event.get('body', '')
    print(f"リクエストボディ: {body}")
    
    # デコード
    if event.get('isBase64Encoded', False):
        body = base64.b64decode(body).decode('utf-8')
        print(f"Base64エンコードされたボディをデコード後: {body}")

    # リクエストが文字列の場合はJSONに変換
    if isinstance(body, str) and body:
        body = json.loads(body)
    print(f"デコード後のボディ: {json.dumps(body)}")

    return body
```

</details>


<details><summary>file_processor.py</summary>

```python:file_processor.py
import base64


def process_file(body):
    # リクエストからファイル名とファイルデータを取得
    file_name = body.get('file_name', '')
    file_data = body.get('file_data', '')
    print(f"ファイル名: {file_name}")

    # 入力検証
    if not file_name:
        raise ValueError('ファイル名が指定されていません')
    
    if not file_data:
        raise ValueError('ファイルデータが指定されていません')
    
    # Base64エンコードされたデータをデコード
    decoded_data = base64.b64decode(file_data)
    
    # ファイルの種類を判断
    content_type = 'image/jpeg'
    if file_name.lower().endswith('.png'):
        content_type = 'image/png'
    elif file_name.lower().endswith('.webp'):
        content_type = 'image/webp'

    return file_name, decoded_data, content_type
```

</details>


<details><summary>s3_uploader.py</summary>

```python:s3_uploader 
import boto3
import os


def upload_to_s3(file_name, file_data, content_type):
    # S3バケット名
    bucket_name = os.environ.get('S3_BUCKET_NAME')
    
    # S3クライアントを作成
    s3_client = boto3.client('s3')
    
    # S3にファイルをアップロード
    print(f"S3にアップロード中: バケット名={bucket_name}, ファイル名={file_name}, コンテンツタイプ={content_type}")
    s3_client.put_object(
        Bucket=bucket_name,
        Key=file_name,
        Body=file_data,
        ContentType=content_type
    )
    
    return bucket_name
```

</details>

<details><summary>response_generator.py</summary>

```python:response_generator.py
import json

def create_response(status_code, body):
    return {
        'statusCode': status_code,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        'body': json.dumps(body)
    }
```

</details>




# 2. API Gateway

次に、Lambdaを呼び出すためのREST APIを作成します。

- メソッドタイプ：POST
- 統合タイプ: Lambda 関数
- Lambda 
    - `1. Lambdaの作成`で作成したLambdaを選択
    - プロキシ統合を選択

- バイナリサポートの設定
    - image/jpeg
    - image/png
    - image/webp

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/eaababc0-0aaa-4dbe-9dec-00b5463b44cc.png)

今回は検証なのでCORSで許可するオリジンは全て許可（* で設定）しておきます。
APIのデプロイ後、ステージからエンドポイントURLが確認できるのでメモしておきます。


# 3. 動作の確認


ここまでできたらメモしたAPIのエンドポイントをPostmanからPOSTメソッドでコールしてみます。

## リクエストBodyに渡す用の画像を準備

今回アップロードする画像は次のものです。

<details><summary>画像</summary>

![textExtractionSheet.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/16868cf2-e4d7-4ee1-8bd6-dec67a6ea362.png)

</details>


以下のコマンドで画像をbase64形式にフォーマットします。
コマンドの実行にはgit bashを利用します。

```bash
# JPEG画像の場合
base64 -w 0 test-image.jpg > test-image-base64.txt

# PNG画像の場合
base64 -w 0 test-image.png > test-image-base64.txt

# WebP画像の場合
base64 -w 0 test-image.webp > test-image-base64.txt
```

## PostmanでAPIコールする場合

Postmanを使って画像をアップロードしてみます。
アップロードする画像は`jpg`形式のものです。
Postメソッドを作成します。

- 「Headers」タブで以下を設定
    - Content-Type：application/json
- 「Body」タブを選択し、「raw」を選択、JSONを選択して以下のような内容を入力

```json:Body
{
  "file_name": "test-profile.jpg",
  "file_data": "base64形式に変換した値"
}
```

`Send`を押下してAPIをコールすると、`200`で返ってくるのが確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/c4bfb460-5808-4e56-8087-7a7a913d4873.png)

S3バケットを確認するとjpeg画像アップロードできていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/4c8c0d20-096f-450e-9e0d-523a92849304.png)


## curlでAPIコールする場合

コマンドラインからcurlを使ってAPIをコールする場合は以下のようにします。
アップロードする画像は`png`形式のものです。
リクエストBodyを記載したJSONをファイルを作成しそれをcurlで指定してAPIコールします。

```json:request.json
{
    "file_name": "test-profile2.png",
    "file_data": "base64形式に変換した値"
}
```

```bash
curl -X POST \
  APIのエンドポイントURL \
  -H "Content-Type: application/json" \
  -d @request.json
```

S3バケットを確認するとpng画像アップロードできていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/f17dd502-1609-44ac-9bd3-5a81a0a77a94.png)


## chromeのfetch APIからアップロードする場合

次はchromeブラウザのfetch APIを使ってアップロードしてみます。
スタイリングはtaildwind cssを使用しています。

https://tailwindcss.com/docs/installation/play-cdn

ブラウザから画像選択＋fetch apiをコールするための簡易的なHTMLを作成します。

<details><summary>index.html</summary>

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>画像アップロード</title>
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body class="p-4">
    <div class="max-w-md mx-auto bg-white p-6 rounded shadow">
        <h1 class="text-xl font-bold mb-4">画像アップロード</h1>
        
        <input class="mb-4 w-full" type="file" id="fileInput">
        
        <button id="uploadButton" class="w-full bg-blue-500 text-white py-2 px-4 rounded">
            アップロード
        </button>
        
        <div id="status" class="mt-4"></div>
    </div>

    <script>
        // APIエンドポイント
        const API_ENDPOINT = 'APIエンドポイントURL';
        
        document.getElementById('uploadButton').addEventListener('click', () => {
            const file = document.getElementById('fileInput').files[0];
            if (!file) return;
            
            const reader = new FileReader();
            reader.readAsDataURL(file);
            
            reader.onload = () => {
                // Base64データ部分のみを抽出
                const base64Data = reader.result.split(',')[1];
                
                fetch(API_ENDPOINT, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        file_name: file.name,
                        file_data: base64Data
                    })
                })
                .then(response => response.json())
                .then(data => {
                    document.getElementById('status').textContent = 'アップロード成功';
                });
            };
        });
    </script>
</body>
</html>
```

</details>

vscodeのLive ServerでWebサーバを起動し画面を立ち上げます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/38fd70b3-6b37-48e3-865e-00fb82137cd8.png)

`ファイルを選択`からWebp画像を選択しアップロードボタンを押下すると、数秒待った後に`アップロード成功`の文言が表示されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/9f9f4801-30f4-4d23-a1b9-1585d8fc4e50.png)

S3バケットを確認するとWebP画像アップロードできてました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/a69c1e56-dd57-4eee-8dfa-812e60da94c6.png)

## PDFファイルをアップロードする場合

PDFファイルも同様にAPIを通じてS3にアップロードできるのか気になったので追加で確認してみました。

### Lambdaの修正

Lambda関数の`file_processor.py`にPDFの`Content-Type`を追加します。

```diff_python
# ファイルの種類を判断
content_type = 'image/jpeg'
if file_name.lower().endswith('.png'):
    content_type = 'image/png'
elif file_name.lower().endswith('.webp'):
    content_type = 'image/webp'
+ # PDFのContent-Typeを追加
+ elif file_name.lower().endswith('.pdf'):
+     content_type = 'application/pdf'
```

### API Gatewayの修正

API GatewayのバイナリサポートにPDFのMIMEタイプを追加します。

- application/pdf

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/f6c05d83-6b06-49e1-a498-9da0c691e0e0.png)

### PDFをbase64にフォーマット

以下のようにしてPDFをbase64にフォーマットします。

```bash
base64 -w 0 test-document.pdf > test-document-base64.txt
```

リクエストBodyは以下のようにrequest.jsonに記載しておきます。
base64形式に変換された値を`test-document-base64.txt`からコピーして張り付けておきます。

```json:request.json
{
  "file_name": "test-document.pdf",
  "file_data": "base64形式に変換した値"
}
```

### APIコール

今回はcurlでAPIをコールしてみます。

```bash
curl -X POST \
  APIのエンドポイントURL \
  -H "Content-Type: application/json" \
  -d @request.json
```

### S3バケットを確認してみる

S3バケットを確認するとPDFもアップロードできてました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/27fa150d-64ef-4aa7-b621-d6ea48606a43.png)

# 4. 終わりに

今回試してみた限りではうまくいっていたので、ペイロードのサイズがオーバーしてしまう事象に遭遇はしなかったですが、Lambdaのペイロードサイズについてデベロッパーガイドに記載がありました。

> リクエストとレスポンスにそれぞれ 6 MB (同期)
参考：https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html

あまりに大きいデータを渡すときには注意しようと思います。