【Python】AWS Lambda + DynamoDBでTodoリスト用REST APIを構築した際にハマったこと

# はじめに

AWS Lambda + DynamoDBでTodoリスト用REST APIを構築した際にハマったことのメモ。

構築したもの：https://github.com/r-wakatsuki/kadai4todo

# ハマったこと

以下、ハマった際に遭遇した事象と解決方法を記載していく。
DynamoDBをまともに利用したのが初めてだったため、DynamoDBについてがほとんどである。

## 1. DynamoDBに登録するデータの値には空文字は指定できないがnullは登録可能。ただしその場合は同じプロパティにNULL型とその他の型が混在することになる。

### 事象

`content`プロパティに空文字が指定されている場合、空文字をString型のままDynamoDBに登録しようとすると、`ClientError: An error occurred (ValidationException) when calling the PutItem operation: One or more parameter values were invalid: An AttributeValue may not contain an empty string`というエラーとなる。

```py
InputEvent:
{
  "httpMethod": "POST",
  "resource": "/todo",
  "body": "{\"title\": \"たいとるですよ\",\"content\": \"\"}"
}


API:<Todo登録>

    #〜中略〜

    #要素定義：content
    req_content = req_body_dict.get('content','')
    if req_content == None:
        req_content = ''

    #〜中略〜

    #<Todo登録>実行
    item = {
        "todoid": str(uuid.uuid4()),
        "title": req_title,
        "content": req_content,
        "priority": req_priority,
        "done": req_done,
        "created_at": current_date,
        "updated_at": current_date
    }
    table.put_item(
        Item = item
    )

    #<Todo登録>実行結果リターン
    return(return200(item))


Response:
{
  "errorMessage": "An error occurred (ValidationException) when calling the PutItem operation: One or more parameter values were invalid: An AttributeValue may not contain an empty string",
  "errorType": "ClientError",
  "stackTrace": [
    "  File \"/var/task/lambda_function.py\", line 72, in lambda_handler\n    Item = item\n",
    "  File \"/var/runtime/boto3/resources/factory.py\", line 520, in do_action\n    response = action(self, *args, **kwargs)\n",
    "  File \"/var/runtime/boto3/resources/action.py\", line 83, in __call__\n    response = getattr(parent.meta.client, operation_name)(**params)\n",
    "  File \"/var/runtime/botocore/client.py\", line 320, in _api_call\n    return self._make_api_call(operation_name, kwargs)\n",
    "  File \"/var/runtime/botocore/client.py\", line 623, in _make_api_call\n    raise error_class(parsed_response, operation_name)\n"
  ]
}
```

`content`プロパティが空文字の場合にNULL型に変換する処理を入れ、NULL型でDynamoDBへの登録を試みると、登録処理は問題なく行われる。

```py
InputEvent:
{
  "httpMethod": "POST",
  "resource": "/todo",
  "body": "{\"title\": \"たいとるですよ\",\"content\": \"\"}"
}


API:<Todo登録>

    #〜中略〜

    #要素定義：content
    req_content = req_body_dict.get('content')
    if req_content == '':#空文字の場合はnull型を入れるようにする。
        req_content = None

    #〜中略〜

    #<Todo登録>実行
    item = {
        "todoid": str(uuid.uuid4()),
        "title": req_title,
        "content": req_content,
        "priority": req_priority,
        "done": req_done,
        "created_at": current_date,
        "updated_at": current_date
    }
    table.put_item(
        Item = item
    )

    #<Todo登録>実行結果リターン
    return(return200(item))


Response:
{
  "statusCode": 200,
  "body": {
    "todoid": "0b19d07a-7f41-4d58-b179-45889e236743",
    "title": "たいとるですよ",
    "content": null,
    "priority": "normal",
    "done": 0,
    "created_at": "2019-09-07T22:39:00",
    "updated_at": "2019-09-07T22:39:00"
  }
}
```

しかし、DynamoDBのコンソール画面からテーブルを見ると登録したデータの`content`プロパティの値が`true`となっている。
さらにDynamoDBのテーブルにデータが一つも登録されていない状態であったため、`content`プロパティの型が`NULL`型となっている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258416/001a8c66-877b-078c-966e-21ef756d7c4d.png)

この状態で`content`プロパティにいくつか値を指定して登録し、取得してみると次のような結果となった。

| ユーザーが登録しようとする値（型） | DynamoDBに登録する際の値（型） | DynamoDB管理画面上の値 | 実際にDynamoDBに登録された値（型） |
| --- | --- | --- | --- |
| （String） | null（NULL） | true | null（NULL） |
| true（String） | true（String） | true | true（String） |
| 本文ですよ。（String） | 本文ですよ。（String） | 本文ですよ。 | 本文ですよ。（String） |
| None（String） | None（String） | None | None（String） |

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258416/ac95c704-8642-e137-2663-152c0dcfecc2.png)

要するに登録時のデータの型をDynamoDB側で保持してくれている動作となる。これでも運用できなくはないがString型とNULL型が同じプロパティに混在しているのは気持ち悪い。

### 解決

Todoデータの登録時や更新時に`content`プロパティの末尾に`</end>`タグを必ず付与して、String型のみを扱うようにした。

```py
API:<Todo登録>

    #〜中略〜

    #要素定義：content
    req_content = req_body_dict.get('content')
    if req_content == None:
        req_content = '</end>'
    else:
        req_content = req_content + '</end>'

    #〜中略〜

    #<Todo登録>実行
    item = {
        "todoid": str(uuid.uuid4()),
        "title": req_title,
        "content": req_content,
        "priority": req_priority,
        "done": int(req_done),  #明示的にint型に設定
        "created_at": current_date,
        "updated_at": current_date
    }
    table.put_item(
        Item = item
    )
```

Todoデータの取得時には`content`属性の末尾から`</end>`タグを除去する処理を入れた。

```py
import re

API:<Todo個別取得>

    ##DBからTodoをtodoidで取得
    todo = table.get_item(
        Key={
             'todoid': req_todoid
        }
    )['Item']

    #contentから</end>タグ削除
    todo['content'] = re.sub('</end>$', '', todo['content'])
```

## 2. DynamoDBにint型で登録した値が取り出し時にDecimal型になる

### 事象

Todoステータス`done`プロパティの値は「未完了`0`」と「完了`1`」の２パターンとしている。
そこでDynamoDBへのTodo登録時の処理ではTodoの`done`プロパティの値を明示的にint型に設定してDynamoDBに登録するようにした。

```py
InputEvent:
{
  "httpMethod": "POST",
  "resource": "/todo",
  "body": "{\"title\": \"内部定例\",\"done\": 0}"
}


API:<Todo登録>

    #〜中略〜

    #<Todo登録>実行
    item = {
        "todoid": str(uuid.uuid4()),
        "title": req_title,
        "content": req_content,
        "priority": req_priority,
        "done": int(req_done),  #明示的にint型に設定
        "created_at": current_date,
        "updated_at": current_date
    }
    table.put_item(
        Item = item
    )


Response:
{
  "todoid": "35f047ba-1121-4473-8587-b893f0938389",
  "title": "内部定例",
  "content": "",
  "priority": "normal",
  "done": 0,
  "created_at": "2019-09-07T16:27:54",
  "updated_at": "2019-09-07T16:27:54"
}
```

しかし、登録したデータをDynamoDBから取得すると`done`プロパティの値がDecimal型`<class 'decimal.Decimal'>`となってしまっている。
また、取得したデータをレスポンスするために`json.dumps`しようとすると`TypeError:Object of type Decimal is not JSON serializable`となりJSONシリアライズできない。

```py
InputEvent:
{
  "httpMethod": "GET",
  "resource": "/todo/{todoid}",
  "pathParameters": "{\"todoid\": \"35f047ba-1121-4473-8587-b893f0938389\"}"
}


API:<Todo個別取得>

    #〜中略〜

    ##DBからTodoをtodoidで取得
    todo = table.get_item(
        Key={
             'todoid': req_todoid
        }
    )['Item']
    print(type(todo['done']))#型確認

    #〜中略〜

    #<Todo個別取得>実行結果リターン
    return{
        'statusCode': 200,
        'body': json.dumps(todo)
    }

Fanction Logs:
<class 'decimal.Decimal'>


Response:
{
  "errorMessage": "Object of type Decimal is not JSON serializable",
  "errorType": "TypeError",
  "stackTrace": [
    "  File \"/var/task/lambda_function.py\", line 209, in lambda_handler\n    'body': json.dumps(item)\n",
    "  File \"/var/lang/lib/python3.7/json/__init__.py\", line 231, in dumps\n    return _default_encoder.encode(obj)\n",
    "  File \"/var/lang/lib/python3.7/json/encoder.py\", line 199, in encode\n    chunks = self.iterencode(o, _one_shot=True)\n",
    "  File \"/var/lang/lib/python3.7/json/encoder.py\", line 257, in iterencode\n    return _iterencode(o, 0)\n",
    "  File \"/var/lang/lib/python3.7/json/encoder.py\", line 179, in default\n    raise TypeError(f'Object of type {o.__class__.__name__} '\n"
  ]
}
```

### 解決

`json.dumps`時にDecimal型の値があればint型に変換するようにした。

```py
InputEvent:
{
  "httpMethod": "GET",
  "resource": "/todo/{todoid}",
  "pathParameters": "{\"todoid\": \"35f047ba-1121-4473-8587-b893f0938389\"}"
}


API:<Todo個別取得>

    ##DBからTodoをtodoidで取得
    todo = table.get_item(
        Key={
             'todoid': req_todoid
        }
    )['Item']

    #〜中略〜

    #Decimal型の場合はint型に変換
    def decimal_default_proc(obj):
        if isinstance(obj, Decimal):
            return int(obj)
        raise TypeError

    #<Todo個別取得>実行結果リターン
    return{
        'statusCode': 200,
        'body': json.dumps(todo,default=decimal_default_proc)
    }


Response:
{
  "todoid": "35f047ba-1121-4473-8587-b893f0938389",
  "title": "内部定例",
  "content": "",
  "priority": "normal",
  "done": 0,
  "created_at": "2019-09-07T16:27:54",
  "updated_at": "2019-09-07T16:27:54"
}
```

上記ではDecimal -> int変換用の関数を利用しているが、関数を使わなくてもDecimal型になることが事前に分かっている値は個別に`int(値)`すればよい。

### 参考

https://qiita.com/ekzemplaro/items/5fa8900212252ab554a3
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/GettingStarted.Python.03.html

## 3.DynamoDBに登録されるデータのプロパティの並び順を定義できない。

### 事象

例えば、以下のようにプロパティの順番を整列させたTodoをDynamoDBに登録する。

```py
API:<Todo登録>

    #〜中略〜

    #<Todo登録>実行
    item = {
        "todoid": str(uuid.uuid4()),
        "title": req_title,
        "content": req_content,
        "priority": req_priority,
        "done": req_done,
        "created_at": current_date,
        "updated_at": current_date
    }
    table.put_item(
        Item = item
    )
```

登録したデータをDynamoDBから取得するとプロパティの並び順が整列されていない。

```py
API:<Todo個別取得>

    ##DBからTodoをtodoidで取得
    todo = table.get_item(
        Key={
             'todoid': req_todoid
        }
    )['Item']

    #〜中略〜

    #<Todo個別取得>実行結果リターン
    return{
        'statusCode': 200,
        'body': json.dumps(todo)
    }


Response:
{
  "updated_at": "2019-09-01T21:38:37",
  "content": "定期券の更新ができた",
  "todoid": "6f9cd0e6-fbf0-48be-ac64-b4763c482d4d",
  "created_at": "2019-09-01T20:32:30",
  "priority": "normal",
  "done": 1,
  "title": "定期更新"
}
```

DynamoDB自体の設定でもプロパティの並び順をあらかじめ定義する設定は見当たらない。

### 解決

取得後にプロパティの並び順を整列する処理を入れる。

```py
API:<Todo個別取得>

    ##DBからTodoをtodoidで取得
    todo = table.get_item(
        Key={
             'todoid': req_todoid
        }
    )['Item']

    #〜中略〜

    #プロパティ並び順整列
    item = {
        "todoid": todo['todoid'],
        "title": todo['title'],
        "content": todo['content'],
        "priority": todo['priority'],
        "done": todo['done'],
        "created_at": todo['created_at'],
        "updated_at": todo['updated_at']
    }

    #<Todo個別取得>実行結果リターン
    return{
        'statusCode': 200,
        'body': json.dumps(item)
    }


Response:
{
  "todoid": "6f9cd0e6-fbf0-48be-ac64-b4763c482d4d",
  "title": "定期更新",
  "content": "定期券の更新ができた",
  "priority": "normal",
  "done": 1,
  "created_at": "2019-09-01T20:32:30",
  "updated_at": "2019-09-01T21:38:37"
}
```

以上