From 8a2f23d23e19d0fc82f5a25bf3f3a344402e8e5e Mon Sep 17 00:00:00 2001
From: shoji-san <85208872+mt-osho-san@users.noreply.github.com>
Date: Sat, 28 Dec 2024 17:29:56 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=97=E3=83=AD=E3=83=95=E3=82=A1?=
=?UTF-8?q?=E3=82=A4=E3=83=AB=E7=94=BB=E5=83=8F=E5=8F=96=E5=BE=97API?=
=?UTF-8?q?=E3=81=AE=E5=AE=9F=E8=A3=85=20=20(#257)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### **User description**
## 概要
プロファイル画像を取得する API を作成
## 変更点
- Lambda, API GW の CDK コードを追加
- Lambda 関数の中身の Python コードを追加
## 影響範囲
既存の機能には影響なし
## テスト
- pytest によりテストを実行
## 関連 Issue
- 関連 Issue: #244
___
### **PR Type**
Enhancement
___
### **Description**
- プロファイル画像を取得するAPIを新たに実装しました。
- Lambda関数を追加し、S3から画像を取得してBase64エンコードした結果を返します。
- API GatewayにGETメソッドを追加し、ユーザーのプロフィール画像を取得できるようにしました。
- 画像取得関数に対するユニットテストを実装し、エラーハンドリングも確認しました。
___
### **Changes walkthrough** 📝
| Relevant
files |
---|
Enhancement |
settings.tsプロファイル画像取得用LambdaとAPIの追加
src/backend/lib/constructs/settings.ts
- プロファイル画像取得用のLambda関数を追加
- API GatewayにGETメソッドを追加
|
+15/-0
|
get_profile_image.pyS3から画像を取得するLambda関数の実装
src/backend/lambda/get_profile_image/get_profile_image.py
- S3から画像を取得する関数を実装
- 画像をBase64エンコードして返す処理を追加
|
+88/-0
|
|
Tests |
test_get_profile_image.pyプロファイル画像取得関数のテスト追加
src/backend/test/pytest/test_get_profile_image.py
- 画像取得関数のユニットテストを追加
- エラーハンドリングのテストを実装
|
+65/-0
|
|
___
> 💡 **PR-Agent usage**: Comment `/help "your question"` on any pull
request to receive relevant information
---
.../get_profile_image/get_profile_image.py | 88 +++++++
src/backend/lib/constructs/settings.ts | 15 ++
.../test/__snapshots__/snapshot.test.ts.snap | 234 +++++++++++++++++-
.../test/pytest/test_get_profile_image.py | 65 +++++
4 files changed, 400 insertions(+), 2 deletions(-)
create mode 100644 src/backend/lambda/get_profile_image/get_profile_image.py
create mode 100644 src/backend/test/pytest/test_get_profile_image.py
diff --git a/src/backend/lambda/get_profile_image/get_profile_image.py b/src/backend/lambda/get_profile_image/get_profile_image.py
new file mode 100644
index 00000000..0f8d2776
--- /dev/null
+++ b/src/backend/lambda/get_profile_image/get_profile_image.py
@@ -0,0 +1,88 @@
+import base64
+import logging
+import os
+
+import boto3
+from botocore.exceptions import ClientError
+
+# ログ設定
+logger = logging.getLogger()
+logger.setLevel(logging.INFO)
+
+
+def get_image_from_s3(key):
+ """
+ S3 から画像データを取得
+
+ Args:
+ key (string): S3 バケット内のファイルのキー(パス)。
+
+ Returns:
+ bytes: 取得した画像データ。
+ string: 画像のコンテンツタイプ。
+
+ Raises:
+ RuntimeError: S3 からデータを取得中にエラーが発生した場合。
+ """
+ # S3 クライアントの初期化
+ s3_client = boto3.client("s3")
+ bucket = os.environ["USER_SETTINGS_BUCKET"]
+
+ try:
+ response = s3_client.get_object(Bucket=bucket, Key=key)
+ data = response["Body"].read()
+ content_type = response["ContentType"]
+ return data, content_type
+ except ClientError as e:
+ if e.response["Error"]["Code"] == "NoSuchKey":
+ raise FileNotFoundError(
+ f"The key {key} does not exist in bucket {bucket}"
+ ) from e
+ raise RuntimeError(f"Failed to retrieve image from S3: {str(e)}") from e
+
+
+def lambda_handler(event, context):
+ """
+ Lambda 関数のエントリーポイント。API Gateway からのリクエストを処理し、ユーザーのプロフィール画像を S3 から取得。
+
+ Args:
+ event (dict): API Gateway からのリクエストイベント。
+ context (object): Lambda 実行環境の情報。
+
+ Returns:
+ dict: レスポンスコードとメッセージを含む辞書。
+ """
+ logger.info("get profile image function")
+ try:
+ # Cognito Authorizer から user_id を取得
+ user_id = event["requestContext"]["authorizer"]["claims"]["sub"]
+
+ # S3 パスを設定
+ s3_key = f"profile/image/{user_id}.png"
+
+ # S3 から画像を取得
+ image_data, content_type = get_image_from_s3(s3_key)
+
+ # Base64 エンコード
+ encoded_image = base64.b64encode(image_data).decode("utf-8")
+
+ # 成功レスポンスを返す
+ return {
+ "statusCode": 200,
+ "headers": {
+ "Content-Type": content_type,
+ "Content-Encoding": "base64",
+ },
+ "body": encoded_image,
+ "isBase64Encoded": True,
+ }
+
+ except FileNotFoundError as fe:
+ logger.error(f"File not found: {str(fe)}")
+ return {"statusCode": 404, "body": f"Image not found: {str(fe)}"}
+ except RuntimeError as re:
+ logger.error(f"Runtime error: {str(re)}")
+ return {"statusCode": 500, "body": f"Server error: {str(re)}"}
+ except Exception as e:
+ logger.error(f"Unexpected error: {str(e)}")
+ return {"statusCode": 500, "body": f"Failed to retrieve image: {str(e)}"}
diff --git a/src/backend/lib/constructs/settings.ts b/src/backend/lib/constructs/settings.ts
index a9827cb2..7b87af14 100644
--- a/src/backend/lib/constructs/settings.ts
+++ b/src/backend/lib/constructs/settings.ts
@@ -40,11 +40,26 @@ export class Settings extends Construct {
})
userSettingsBucket.grantPut(uploadProfileImageFunction)
+ //プロフィール画像取得用Lambda関数の定義
+ const getProfileImageFunction = new lambda.Function(this, 'getProfileImageFunction', {
+ runtime: lambda.Runtime.PYTHON_3_11,
+ handler: 'get_profile_image.lambda_handler',
+ timeout: cdk.Duration.seconds(15),
+ code: lambda.Code.fromAsset('lambda/get_profile_image'),
+ environment: {
+ USER_SETTINGS_BUCKET: userSettingsBucket.bucketName,
+ },
+ })
+ userSettingsBucket.grantRead(getProfileImageFunction)
// /APIの設定
const settingsApi = props.api.root.addResource('settings')
settingsApi.addMethod('POST', new apigateway.LambdaIntegration(uploadProfileImageFunction), {
authorizer: props.cognitoAuthorizer,
})
+
+ settingsApi.addMethod('GET', new apigateway.LambdaIntegration(getProfileImageFunction), {
+ authorizer: props.cognitoAuthorizer,
+ })
}
}
diff --git a/src/backend/test/__snapshots__/snapshot.test.ts.snap b/src/backend/test/__snapshots__/snapshot.test.ts.snap
index a242bd9b..d9ef9e57 100644
--- a/src/backend/test/__snapshots__/snapshot.test.ts.snap
+++ b/src/backend/test/__snapshots__/snapshot.test.ts.snap
@@ -336,7 +336,7 @@ exports[`Snapshot test 1`] = `
"Type": "AWS::IAM::Role",
"UpdateReplacePolicy": "Delete",
},
- "ApiDiaryApiDeploymentCA0DCBF5b04204490493f7bae0d0fbb51bda4809": {
+ "ApiDiaryApiDeploymentCA0DCBF5ddc2bf13dbc3d7fbb845a336b665119f": {
"DependsOn": [
"ApiDiaryApibouquetGET7AC93032",
"ApiDiaryApibouquetPOST2DEB7999",
@@ -350,6 +350,7 @@ exports[`Snapshot test 1`] = `
"ApiDiaryApiflowerGETEAC01B09",
"ApiDiaryApiflowerOPTIONS2A81112B",
"ApiDiaryApiflower7C61878D",
+ "ApiDiaryApisettingsGET4E223DBA",
"ApiDiaryApisettingsPOST5E845A8B",
"ApiDiaryApisettings70D21EB5",
"ApiDiaryApititleGET40FCE039",
@@ -380,7 +381,7 @@ exports[`Snapshot test 1`] = `
"Format": "{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","user":"$context.identity.user","caller":"$context.identity.caller","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength"}",
},
"DeploymentId": {
- "Ref": "ApiDiaryApiDeploymentCA0DCBF5b04204490493f7bae0d0fbb51bda4809",
+ "Ref": "ApiDiaryApiDeploymentCA0DCBF5ddc2bf13dbc3d7fbb845a336b665119f",
},
"MethodSettings": [
{
@@ -1400,6 +1401,127 @@ exports[`Snapshot test 1`] = `
},
"Type": "AWS::ApiGateway::Resource",
},
+ "ApiDiaryApisettingsGET4E223DBA": {
+ "Properties": {
+ "AuthorizationType": "COGNITO_USER_POOLS",
+ "AuthorizerId": {
+ "Ref": "ApiCognitoAuthorizer23B91BA2",
+ },
+ "HttpMethod": "GET",
+ "Integration": {
+ "IntegrationHttpMethod": "POST",
+ "Type": "AWS_PROXY",
+ "Uri": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition",
+ },
+ ":apigateway:",
+ {
+ "Ref": "AWS::Region",
+ },
+ ":lambda:path/2015-03-31/functions/",
+ {
+ "Fn::GetAtt": [
+ "SettingsgetProfileImageFunctionDBA40214",
+ "Arn",
+ ],
+ },
+ "/invocations",
+ ],
+ ],
+ },
+ },
+ "ResourceId": {
+ "Ref": "ApiDiaryApisettings70D21EB5",
+ },
+ "RestApiId": {
+ "Ref": "ApiDiaryApi1E03348A",
+ },
+ },
+ "Type": "AWS::ApiGateway::Method",
+ },
+ "ApiDiaryApisettingsGETApiPermissionCdkSampleStackApiDiaryApi4AB3EF3AGETsettings7E6B2258": {
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "SettingsgetProfileImageFunctionDBA40214",
+ "Arn",
+ ],
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition",
+ },
+ ":execute-api:",
+ {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ {
+ "Ref": "AWS::AccountId",
+ },
+ ":",
+ {
+ "Ref": "ApiDiaryApi1E03348A",
+ },
+ "/",
+ {
+ "Ref": "ApiDiaryApiDeploymentStageprodD9912276",
+ },
+ "/GET/settings",
+ ],
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
+ "ApiDiaryApisettingsGETApiPermissionTestCdkSampleStackApiDiaryApi4AB3EF3AGETsettings86A23BEE": {
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "SettingsgetProfileImageFunctionDBA40214",
+ "Arn",
+ ],
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition",
+ },
+ ":execute-api:",
+ {
+ "Ref": "AWS::Region",
+ },
+ ":",
+ {
+ "Ref": "AWS::AccountId",
+ },
+ ":",
+ {
+ "Ref": "ApiDiaryApi1E03348A",
+ },
+ "/test-invoke-stage/GET/settings",
+ ],
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
"ApiDiaryApisettingsPOST5E845A8B": {
"Properties": {
"AuthorizationType": "COGNITO_USER_POOLS",
@@ -4050,6 +4172,114 @@ exports[`Snapshot test 1`] = `
},
"Type": "AWS::IAM::Policy",
},
+ "SettingsgetProfileImageFunctionDBA40214": {
+ "DependsOn": [
+ "SettingsgetProfileImageFunctionServiceRoleDefaultPolicyBC909A15",
+ "SettingsgetProfileImageFunctionServiceRole36952281",
+ ],
+ "Properties": {
+ "Code": {
+ "S3Bucket": {
+ "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}",
+ },
+ "S3Key": "HASH_REPLACED.zip",
+ },
+ "Environment": {
+ "Variables": {
+ "USER_SETTINGS_BUCKET": {
+ "Ref": "SettingsuserSettingsBucket1D2B648F",
+ },
+ },
+ },
+ "Handler": "get_profile_image.lambda_handler",
+ "Role": {
+ "Fn::GetAtt": [
+ "SettingsgetProfileImageFunctionServiceRole36952281",
+ "Arn",
+ ],
+ },
+ "Runtime": "python3.11",
+ "Timeout": 15,
+ },
+ "Type": "AWS::Lambda::Function",
+ },
+ "SettingsgetProfileImageFunctionServiceRole36952281": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition",
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
+ ],
+ ],
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "SettingsgetProfileImageFunctionServiceRoleDefaultPolicyBC909A15": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "s3:GetObject*",
+ "s3:GetBucket*",
+ "s3:List*",
+ ],
+ "Effect": "Allow",
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "SettingsuserSettingsBucket1D2B648F",
+ "Arn",
+ ],
+ },
+ {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "SettingsuserSettingsBucket1D2B648F",
+ "Arn",
+ ],
+ },
+ "/*",
+ ],
+ ],
+ },
+ ],
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "SettingsgetProfileImageFunctionServiceRoleDefaultPolicyBC909A15",
+ "Roles": [
+ {
+ "Ref": "SettingsgetProfileImageFunctionServiceRole36952281",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
"SettingsuploadProfileImageFunction5C7EC09C": {
"DependsOn": [
"SettingsuploadProfileImageFunctionServiceRoleDefaultPolicy8B4E4832",
diff --git a/src/backend/test/pytest/test_get_profile_image.py b/src/backend/test/pytest/test_get_profile_image.py
new file mode 100644
index 00000000..ecb046f4
--- /dev/null
+++ b/src/backend/test/pytest/test_get_profile_image.py
@@ -0,0 +1,65 @@
+import os
+from unittest.mock import MagicMock, patch
+
+import pytest
+from botocore.exceptions import ClientError
+from get_profile_image.get_profile_image import get_image_from_s3
+
+# 環境変数の設定
+os.environ["USER_SETTINGS_BUCKET"] = "test-bucket"
+
+
+# fixtureでS3からの画像レスポンスを定義
+@pytest.fixture
+def s3_image_response():
+ return b"test_image_data"
+
+
+# get_image_from_s3のテスト
+@patch("get_profile_image.get_profile_image.boto3.client")
+def test_get_image_from_s3(mock_boto3_client, s3_image_response):
+ """S3から画像を取得する関数のテスト"""
+
+ # S3クライアントのモック
+ mock_s3_client = MagicMock()
+ mock_boto3_client.return_value = mock_s3_client
+
+ # S3レスポンスをモック
+ mock_s3_client.get_object.return_value = {
+ "Body": MagicMock(read=MagicMock(return_value=s3_image_response)),
+ "ContentType": "image/png",
+ }
+
+ s3_key = "profile/image/test-user-id.png"
+
+ # 関数を呼び出して結果を検証
+ result_data, result_content_type = get_image_from_s3(s3_key)
+ assert result_data == s3_image_response
+ assert result_content_type == "image/png"
+
+ # S3の呼び出しを確認
+ mock_s3_client.get_object.assert_called_once_with(
+ Bucket="test-bucket",
+ Key=s3_key,
+ )
+
+
+def test_get_image_from_s3_no_such_key_error():
+ """S3からのキーが存在しない場合のエラーテスト"""
+ with patch("get_profile_image.get_profile_image.boto3.client") as mock_boto3_client:
+ mock_s3_client = MagicMock()
+ mock_boto3_client.return_value = mock_s3_client
+
+ # NoSuchKey エラーをシミュレート
+ error_response = {
+ "Error": {
+ "Code": "NoSuchKey",
+ "Message": "The specified key does not exist.",
+ }
+ }
+ mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject")
+
+ s3_key = "profile/image/non-existent-user-id.png"
+
+ with pytest.raises(FileNotFoundError):
+ get_image_from_s3(s3_key)