🛒

【AWS×Java】SAMで冷蔵庫管理アプリを構築 #2 DynamoDB編(CRUD API②)

に公開

はじめに

当記事の最終ゴールについては以下の記事をご確認ください。
https://zenn.dev/superrelax102/articles/424ebd380d6c53

今回のゴール:API Gateway + Lambda(Java)から DynamoDBへ POST/PUT/DELETE できる
前提読者:AWS初心者、開発初心者

また、今回は以下「【AWS×Java】SAMで冷蔵庫管理アプリを構築 #2 DynamoDB編(CRUD API①)」を実施済みの前提で進めます。
https://zenn.dev/superrelax102/articles/6fb52f65c44e74

以下を実施することで従量課金が発生します。その点については自己責任でお願いします。無料枠があるが上限があります。削除することで課金を止めることが可能です。
また、本記事は学習ログです。詳細は公式ドキュメントを適宜参照してください。

アーキテクチャ概要

今回は以下赤枠部分を完成させます。(範囲は前回と同様)

図1: サーバレス構成の全体像

「Infrastructure Composer/ Application Composer」でtemplate.yamlに追加

前回同様、backendディレクトリの配下に「template.yaml」があるので右クリックをして
「Open with Infrastructure Composer」を選択してください。
すると以下のような図が表示されているかと思います。
(旧称がInfrastructure Composerらしく、私は「Infrastructure Composer」と表示されていましたが、「Application Composer」と表示されている方はそちらを選択してください)

-図2 Application Composer(前回状態)

まずはAPI Gatewayに以下を追加します。

API Gateway

(以下を追加するだけで、それ以外はそのまま)

項目名 設定値
メソッド POST
パス /items
メソッド PUT
パス /items
メソッド DELETE
パス /items

その後、API Gatewayの各ポートとLambda関数を接続してください。

-図3 Application Composer(API GatewayとLambdaの接続)

これで終わりと言いたいところなのですが、PUTは変更するアイテムのID、DELETEは削除するアイテムのIDを指定する必要があるのでパスを「/items/{id}」にする必要があります。また、この修正に関してはアプリケーションコンポーザー上では指定できず、直接「template.yaml」を更新する必要があります。

template.yaml
      Events:
        ServerlessRestApiGETitems:
          Type: Api
          Properties:
            Path: /items
            Method: GET
        ServerlessRestApiPOSTitems:
          Type: Api
          Properties:
            Path: /items
            Method: POST
        ServerlessRestApiDELETEitems:
          Type: Api
          Properties:
-            Path: /items
+            Path: /items/{id}
            Method: DELETE
        ServerlessRestApiPUTitems:
          Type: Api
          Properties:
-            Path: /items
+            Path: /items/{id}
            Method: PUT

これでtemplate.yamlの修正は完了です。

実装要件

前回まではシステム目線での解説ばかりでしたが、このあたりから冷蔵庫管理アプリとしての実装要件も意識しながら進めていきたいです。

-図4 アプリの画面イメージ(初期画面)

-図5 アプリの画面イメージ(既存アイテムの編集画面)

上記を見て分かる通り、今回以下のような機能の実装が必要です。

アイテム追加(POST処理)

・アイテム名と数量を指定してテーブルに追加

アイテム取得(GET処理)

・テーブルから全アイテムのアイテム名と数量を取得
  (前回GETは実装してますが、数量を取得する処理はまだ実装できていません)

アイテム編集(PUT処理)

・テーブルの特定のアイテムをIDにて指定し、アイテム名と数量を変更

アイテム削除(DELETE処理)

・テーブルの特定のアイテムをIDにて指定し、テーブルから削除

App.javaにDynamoDBへのCRUD機能を追加

ここからはApp.javaを修正します。GET処理に数量を追加することと、POST/PUT/DELETE処理を追加していきます。

App.javaの修正

App.java修正内容
App.java
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
import java.util.stream.Collectors;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import com.amazonaws.services.lambda.runtime.LambdaLogger;

import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
+import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;
+import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
+import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;

public class App implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

    private final DynamoDbClient ddb = DynamoDbClient.create();
+    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
        Map<String, String> headers = new HashMap<>();
        headers.put("Content-Type", "application/json");
        headers.put("X-Custom-Header", "application/json");

        APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent()
                .withHeaders(headers);

        LambdaLogger logger = (context != null && context.getLogger() != null)
                ? context.getLogger()
                : new LambdaLogger() {
                    @Override
                    public void log(String message) {
                        System.out.println(message);
                    }
                    @Override
                    public void log(byte[] message) {
                        System.out.println(new String(message, StandardCharsets.UTF_8));
                    }
                };

        try {
-            String path = (input != null) ? input.getPath() : null;
            String method = (input != null) ? input.getHttpMethod() : null;
+            Map<String,String> pathParams = (input != null) ? input.getPathParameters() : null;
+            String idParam = (pathParams != null) ? pathParams.get("id") : null;
            // 環境変数からテーブル名取得
            String tableName = System.getenv("FRIDGETABLE_TABLE_NAME");
            if (tableName == null) {
                throw new RuntimeException("FRIDGETABLE_TABLE_NAME env var is not set");
            }

-            // DynamoDBから全件取得(Scan)
-            ScanResponse scanResponse = ddb.scan(ScanRequest.builder()
-                    .tableName(tableName)
-                    .limit(50) // 任意:件数制限
-                    .build());
-
-            // itemのidとnameだけJSONに整形(最小例)
-            List<Map<String, AttributeValue>> items = scanResponse.items();
-            String itemsJson = items.stream()
-                    .map(item -> {
-                        String id = item.getOrDefault("id", AttributeValue.builder().s("").build()).s();
-                        String name = item.getOrDefault("name", AttributeValue.builder().s("").build()).s();
-                        return String.format("{\"id\":\"%s\",\"name\":\"%s\"}", id, name);
-                    })
-                    .collect(Collectors.joining(","));
-
-            String output = "{ \"items\": [" + itemsJson + "] }";
-            return response
-                    .withStatusCode(200)
-                    .withBody(output);
+            switch (method) {
+                case "GET": {
+                    // 一覧取得
+                    ScanResponse scan = ddb.scan(ScanRequest.builder().tableName(tableName).limit(50).build());
+                    List<Map<String, AttributeValue>> items = scan.items();
+                    List<Map<String, Object>> dto = items.stream().map(item -> Map.of(
+                            "id",    item.getOrDefault("id", AttributeValue.fromS("")).s(),
+                            "name",  item.getOrDefault("name", AttributeValue.fromS("")).s(),
+                            "count", item.containsKey("count") ? Integer.parseInt(item.get("count").n()) : 0
+                    )).collect(Collectors.toList());
+                    return response.withStatusCode(200).withBody(mapper.writeValueAsString(Map.of("items", dto)));
+                }
+                case "POST": {
+                    // 新規作成
+                    String body = input.getBody();
+                    if (body == null || body.isBlank()) return badRequest(response,"request body is empty");
+                    Map<String, Object> req = mapper.readValue(body, new TypeReference<Map<String,Object>>(){});
+                    String id   = Optional.ofNullable((String) req.get("id")).filter(s -> !s.isBlank()).orElse(UUID.randomUUID().toString());
+                    String name = Optional.ofNullable((String) req.get("name")).orElse("");
+                    int count   = Optional.ofNullable((Integer) req.get("count")).orElse(0);
+
+                    Map<String, AttributeValue> item = new HashMap<>();
+                    item.put("id",    AttributeValue.fromS(id));
+                    item.put("name",  AttributeValue.fromS(name));
+                    item.put("count", AttributeValue.fromN(String.valueOf(count)));
+
+                    try {
+                        ddb.putItem(PutItemRequest.builder()
+                                .tableName(tableName)
+                                .item(item)
+                                .conditionExpression("attribute_not_exists(#id)")
+                                .expressionAttributeNames(Map.of("#id","id"))
+                                .build());
+                    } catch (ConditionalCheckFailedException e) {
+                        return conflict(response,"id already exists");
+                    }
+                    return response.withStatusCode(201).withBody(mapper.writeValueAsString(itemToDto(item)));
+                }
+                case "PUT": {
+                    // 更新
+                    if (idParam == null || idParam.isBlank()) return badRequest(response,"path parameter 'id' is required");
+                    String body = input.getBody();
+                    if (body == null || body.isBlank()) return badRequest(response,"request body is empty");
+                    Map<String, Object> req = mapper.readValue(body, new TypeReference<Map<String,Object>>(){});
+                    String name = (String) req.get("name");
+                    Integer count = (req.get("count") instanceof Integer)? (Integer) req.get("count"):null;
+                    if (name == null && count == null) return badRequest(response,"field 'name' or 'count' is required");
+
+                    // 動的にUpdateExpressionを作成
+                    StringBuilder updateExp = new StringBuilder("SET ");
+                    Map<String,String> attrNames = new HashMap<>();
+                    Map<String,AttributeValue> attrValues = new HashMap<>();
+                    List<String> parts = new ArrayList<>();
+                    if (name != null) {
+                        parts.add("#n = :name");
+                        attrNames.put("#n","name");
+                        attrValues.put(":name",AttributeValue.fromS(name));
+                    }
+                    if (count != null) {
+                        parts.add("#c = :count");
+                        attrNames.put("#c","count");
+                        attrValues.put(":count",AttributeValue.fromN(String.valueOf(count)));
+                    }
+                    updateExp.append(String.join(",",parts));
+
+                    UpdateItemResponse up = ddb.updateItem(UpdateItemRequest.builder()
+                            .tableName(tableName)
+                            .key(Map.of("id", AttributeValue.fromS(idParam)))
+                            .updateExpression(updateExp.toString())
+                            .expressionAttributeNames(attrNames)
+                            .expressionAttributeValues(attrValues)
+                            .conditionExpression("attribute_exists(id)")
+                            .returnValues(ReturnValue.ALL_NEW)
+                            .build());
+                    return response.withStatusCode(200).withBody(mapper.writeValueAsString(itemToDto(up.attributes())));
+                }
+                case "DELETE": {
+                    // 削除
+                    if (idParam == null || idParam.isBlank()) return badRequest(response,"path parameter 'id' is required");
+                    try {
+                        DeleteItemResponse del = ddb.deleteItem(DeleteItemRequest.builder()
+                                .tableName(tableName)
+                                .key(Map.of("id", AttributeValue.fromS(idParam)))
+                                .conditionExpression("attribute_exists(id)")
+                                .returnValues(ReturnValue.ALL_OLD)
+                                .build());
+                        Map<String, AttributeValue> old = del.attributes();
+                        if (old == null || old.isEmpty()) return notFound(response,"item not found");
+                        return response.withStatusCode(200).withBody(mapper.writeValueAsString(itemToDto(old)));
+                    } catch (ConditionalCheckFailedException e) {
+                        return notFound(response,"item not found");
+                    }
+                }
+                default:
+                    return response.withStatusCode(405).withBody("{\"error\":\"method not allowed\"}");
+            }

        } catch (Exception e) {
            logger.log("Error: " + e.getMessage());
            return response.withStatusCode(500).withBody("{\"error\": \"" + e.getMessage() + "\"}");
        }
    }
+    private Map<String, Object> itemToDto(Map<String, AttributeValue> item) {
+        return Map.<String, Object>of(
+            "id",    item.getOrDefault("id", AttributeValue.fromS("")).s(),
+            "name",  item.getOrDefault("name", AttributeValue.fromS("")).s(),
+            "count", item.containsKey("count") ? Integer.parseInt(item.get("count").n()) : 0
+        );
+    }
+    private APIGatewayProxyResponseEvent badRequest(APIGatewayProxyResponseEvent r,String m){return r.withStatusCode(400).withBody("{\"error\":\""+m+"\"}");}
+    private APIGatewayProxyResponseEvent notFound(APIGatewayProxyResponseEvent r,String m){return r.withStatusCode(404).withBody("{\"error\":\""+m+"\"}");}
+    private APIGatewayProxyResponseEvent conflict(APIGatewayProxyResponseEvent r,String m){return r.withStatusCode(409).withBody("{\"error\":\""+m+"\"}");}
}

前回からの変更内容をざっくりと記載します。
①JSONを扱う道具(ObjectMapper)を用意しています。

該当箇所
private final ObjectMapper mapper = new ObjectMapper();

②スイッチ分でメソッドごとに分岐させています。

該当箇所
            switch (method) {
                case "GET":
                    //テーブルをScanして id/name/count の一覧をJSONで返す
                case "POST": 
                    //リクエストBody(JSON)を読み取り、アイテムを新規登録(重複IDは409)
                case "PUT":
                    //id で既存アイテムを見つけ、name と/または count を更新
                case "DELETE":
                    //id のアイテムを削除し、削除した中身を返す

③JSONはObjectMapperで安全に文字列化しています。
(前回は文字列連結しておりましたが、①のJSONを扱う道具(ObjectMapper)に変更しました)

該当箇所
文字列連結でJSONを作らず、Map/オブジェクト→ObjectMapperで安全に文字列化

    private Map<String, Object> itemToDto(Map<String, AttributeValue> item) {
        return Map.<String, Object>of(
            "id",    item.getOrDefault("id", AttributeValue.fromS("")).s(),
            "name",  item.getOrDefault("name", AttributeValue.fromS("")).s(),
            "count", item.containsKey("count") ? Integer.parseInt(item.get("count").n()) : 0
        );
    }

④エラーはHTTPステータスで返しています。

該当箇所
//未対応のメソッドの場合は405
                default:
                    return response.withStatusCode(405).withBody("{\"error\":\"method not allowed\"}");

//入力情報が不足している場合は400 
    private APIGatewayProxyResponseEvent badRequest(APIGatewayProxyResponseEvent r,String m){return r.withStatusCode(400).withBody("{\"error\":\""+m+"\"}");}

//存在しないIDに対する操作の場合は404 
    private APIGatewayProxyResponseEvent notFound(APIGatewayProxyResponseEvent r,String m){return r.withStatusCode(404).withBody("{\"error\":\""+m+"\"}");}

//アイテム追加時にIDが重複している場合は409 
    private APIGatewayProxyResponseEvent conflict(APIGatewayProxyResponseEvent r,String m){return r.withStatusCode(409).withBody("{\"error\":\""+m+"\"}");}

pom.xmlの修正

pom.xml修正内容
pom.xml
    <dependencies>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-core</artifactId>
            <version>1.2.2</version>
        </dependency>
        <dependency>
          <groupId>com.amazonaws</groupId>
          <artifactId>aws-lambda-java-events</artifactId>
          <version>3.11.0</version>
        </dependency>
        <dependency>
          <groupId>junit</groupId>
          <artifactId>junit</artifactId>
          <version>4.13.2</version>
          <scope>test</scope>
        </dependency>
        <dependency>
          <groupId>org.mockito</groupId>
          <artifactId>mockito-core</artifactId>
          <version>5.12.0</version>
          <scope>test</scope>
        </dependency>
        <dependency>
          <groupId>software.amazon.awssdk</groupId>
          <artifactId>dynamodb</artifactId>
        </dependency>
+        <dependency>
+          <groupId>com.fasterxml.jackson.core</groupId>
+          <artifactId>jackson-databind</artifactId>
+          <version>2.17.2</version>
+        </dependency>
    </dependencies>

JSONを扱う道具(ObjectMapper)を使用するためにjacksonというJson処理ライブラリを追加しています。

ビルド&デプロイ

backendフォルダ配下でビルド&デプロイしてください。

ターミナル
cd backend
sam build
sam deploy

動作確認

デプロイが成功していれば、前回同様、以下を実行して動作確認してください。

①テーブルが空の状態でGET

まずは前回も実行したGET処理を改めて確認します。

ターミナル
curl.exe https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/items

以下のような結果が返ってきたら成功です。
(まだ何もアイテムを追加していないので返却値は空になっています。)

ターミナル
 { "items": [] }

②アイテムを追加

今回は「milk」を2つ購入したとします。POSTの際はcurl.exeだとjson形式で記載するのが少しややこしくなりますので「Invoke-RestMethod」を使います。
PowerShellで実行すれば正しいJson形式で送ってくれます。

ターミナル
Invoke-RestMethod -Method Post `
  -Uri "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/items" `
  -Headers @{"Content-Type"="application/json"} `
  -Body (@{ name = "milk"; count = 2 } | ConvertTo-Json -Compress)

以下のような結果が返ってきたら成功です。
(idを指定しなかったのでランダムなidが割り振られています。)
①のGETを再度実行すると正しく追加されていることがわかるかと思います。

ターミナル
name count id
---- ----- --
milk     2 ???

③アイテムを変更

牛乳を3つ買いましたが、3つとも腐ってしまったので名称を「kusatta milk」、
個数を「3」に変更します。
「???」には②で表示されたidを設定してください。

ターミナル
Invoke-RestMethod -Method Put `
  -Uri "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/items/???" `
  -Headers @{"Content-Type"="application/json"} `
  -Body (@{name="kusatta milk";count=3} | ConvertTo-Json -Compress)

以下のような結果が返ってきたら成功です。
①のGETを再度実行すると正しく変更されていることがわかるかと思います。

ターミナル
name         count id
----         ----- --
kusatta milk     3 ???

③アイテムを削除

腐った牛乳を削除します。
「???」には②で表示されたidを設定してください。

ターミナル
Invoke-RestMethod -Method Delete `
  -Uri "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/items/???" `
  -Headers @{"Content-Type"="application/json"}

以下のような結果が返ってきたら成功です。
削除されたアイテムが表示されます。

ターミナル
id  count name        
--  ----- ----
???     3 kusatta milk

上記実行後、①のGETを再度実行すると正しく削除されていることがわかるかと思います。

今後の予定

次回はCognitoを使った認証機能を追加します!

Discussion