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.py
S3から画像を取得する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)