React + API Gateway + Lambda + DynamoDBでTodoアプリ

# React
フロントエンドはReactで適当にTodoアプリを作っておきました。
`process.env.REACT_APP_BASE_URL`　にはこの後作成するAPI Gatewayのエンドポイントが入ります。
```javascript:App.js
import { useEffect, useState } from 'react';
import Todo from './components/Todo';
import AddForm from './components/AddForm';
import axios from 'axios';

const App = () => {
  const [todos, setTodos] = useState([])

  const getTodos = () => {
    axios.get(`${process.env.REACT_APP_BASE_URL}/todo`
    ).then(res => {
      setTodos(res.data)
    }).catch(e => {
      console.log(e)
    })
  }

  useEffect(() => {
    getTodos()
  },[])

  const updateTodos = (newTodos) => {
    setTodos(newTodos)
    localStorage.setItem('todos', JSON.stringify(newTodos))
  }

  const handleAddFormSubmit = (title) => {
    if (!title) return

    axios.post(`${process.env.REACT_APP_BASE_URL}/todo`, {
      title
    }).then(() => {
      getTodos()
    }).catch(e => {
      console.log(e)
    })
  }

  const handleTodoCheckboxChange = (id, isCompleted) => {
    axios.patch(`${process.env.REACT_APP_BASE_URL}/todo?id=${id}`, {
      isCompleted
    }).then(() => {
      getTodos()
    }).catch(e => {
      console.log(e)
    })
  }

  const handleTodoDeleteClick = (id) => {
    axios.delete(`${process.env.REACT_APP_BASE_URL}/todo?id=${id}`
    ).then(() => {
      getTodos()
    }).catch(e => {
      console.log(e)
    })
  }

  const todoItems = todos.map((todo) => {
    return (
      <Todo
        key={todo.id}
        todo={todo}
        onDeleteClick={handleTodoDeleteClick}
        onCheckboxChange={handleTodoCheckboxChange}
      />
    )
  })

  return (
    <div className="container">
      <h1>Todoリスト</h1>
      <ul>
        {todoItems}
      </ul>
      <AddForm
        onSubmit={handleAddFormSubmit}
      />
    </div>
  )
}

export default App;
```

```react:AddForm.jsx
import { useRef, useState } from "react"

const AddForm = (props) => {
  const [title, setTitle] = useState('')
  const inputRef = useRef(null)
  const handleTextChange = (e) => {
    setTitle(e.currentTarget.value)
  }
  const handleSubmit = (e) => {
    e.preventDefault();
    props.onSubmit(title)
    setTitle('')
    inputRef.current.focus()
  }
  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="text"
        value={title}
        onChange={handleTextChange}
        ref={inputRef}
      />
      <button>Add</button>
    </form>
  )
}

export default AddForm
```

```react:Todo.jsx
const Todo = (props) => {
  const handleDeleteClick = () => {
    props.onDeleteClick(props.todo.id)
  }

  const handleChecboxChange = () => {
    props.onCheckboxChange(props.todo.id, !props.todo.isCompleted)
  }

  return (
    <li>
      <label>
        <input 
          type="checkbox" 
          checked={props.todo.isCompleted}
          onChange={handleChecboxChange}
        />
        <span>
          {props.todo.title}
        </span>
      </label>
      <button onClick={handleDeleteClick}>Del</button>
    </li>
  )
}

export default Todo
```

# Lambda
Reactで叩くAPIから呼ばれるLambda関数を作成します。
この後作成するDynamoDBのtodoテーブルに対するCRUD処理を作成します。
```ruby:getTodo.rb
require 'json'
require 'aws-sdk-dynamodb'

def client
  @client ||= Aws::DynamoDB::Client.new
end

def dynamo_resource
  @dynamo_resource ||= Aws::DynamoDB::Resource.new(client: client)
end

def todo_table
  @todo_table ||= dynamo_resource.table('todo')
end

def scan_items
  response = todo_table.scan()
rescue Aws::DynamoDB::Errors::ServiceError => e
  puts("Couldn't scan for todos")
  puts("\t#{e.code}: #{e.message}")
  raise
else
  response.items.sort_by { |item| item['id'] }
end

def lambda_handler(event:, context:)
    todos = scan_items
    { 
        statusCode: 200,
        body: JSON.generate(todos), 
        headers: {
            "Access-Control-Allow-Origin": "http://localhost:3000"
        }
        
    }
end
```

```ruby:createTodo.rb
require 'json'
require 'aws-sdk-dynamodb'

def client
  @client ||= Aws::DynamoDB::Client.new
end

def dynamo_resource
  @dynamo_resource ||= Aws::DynamoDB::Resource.new(client: client)
end

def todo_table
  @todo_table ||= dynamo_resource.table('todo')
end

def sequence_table
  @sequence_table ||= dynamo_resource.table('sequence')
end

def add_item(id, title)
  todo_table.put_item(
    item: {
      id: id,
      title: title,
      isCompleted: false
    }
  )
rescue Aws::DynamoDB::Errors::ServiceError => e
  puts("Couldn't add todo id: #{id}, title: #{title}")
  puts("\t#{e.code}: #{e.message}")
  raise
end

def get_next_id
  response = sequence_table.update_item(
    key: { tablename: 'todo' },
    update_expression: "add seq :r",
    expression_attribute_values: { ":r" => 1 },
    return_values: "UPDATED_NEW"
  )
rescue Aws::DynamoDB::Errors::ServiceError => e
  puts("Couldn't update sequence")
  puts("\t#{e.code}: #{e.message}")
  raise
else
  response.attributes["seq"]
end

def lambda_handler(event:, context:)
  next_id = get_next_id
  body = JSON.parse(event["body"])
  title = body["title"]
  add_item(next_id, title)
  { 
    statusCode: 200,
    headers: {
        "Access-Control-Allow-Origin": "http://localhost:3000"
    }
  }
end
```

```ruby:updateTodo.rb
require 'json'
require 'aws-sdk-dynamodb'

def client
  @client ||= Aws::DynamoDB::Client.new
end

def dynamo_resource
  @dynamo_resource ||= Aws::DynamoDB::Resource.new(client: client)
end

def todo_table
  @todo_table ||= dynamo_resource.table('todo')
end

def update_item(id, isCompleted)
  response = todo_table.update_item(
    key: {"id" => id},
    update_expression: "set isCompleted = :r",
    expression_attribute_values: { ":r" => isCompleted }
  )
rescue Aws::DynamoDB::Errors::ServiceError => e
  puts("Couldn't update todos")
  puts("\t#{e.code}: #{e.message}")
  raise
end

def lambda_handler(event:, context:)
  id = event["queryStringParameters"]["id"].to_f.to_i
  body = JSON.parse(event["body"])
  isCompleted = body["isCompleted"]
  update_item(id, isCompleted)
  { 
    statusCode: 200,
    headers: {
        "Access-Control-Allow-Origin": "http://localhost:3000"
    }
  }
end
```

```ruby:deleteTodo.rb
require 'json'
require 'aws-sdk-dynamodb'

def client
  @client ||= Aws::DynamoDB::Client.new
end

def dynamo_resource
  @dynamo_resource ||= Aws::DynamoDB::Resource.new(client: client)
end

def todo_table
  @todo_table ||= dynamo_resource.table('todo')
end

def delete_item(id)
  todo_table.delete_item(key: {"id" => id},)
rescue Aws::DynamoDB::Errors::ServiceError => e
  puts("Couldn't delete todos")
  puts("\t#{e.code}: #{e.message}")
  raise
end

def lambda_handler(event:, context:)
  id = event["queryStringParameters"]["id"].to_f.to_i
  delete_item(id)
  { 
    statusCode: 200,
    headers: {
        "Access-Control-Allow-Origin": "http://localhost:3000"
    }
  }
end
```
DynamoDBは項目を追加するときに自動的にidをインクリメントして挿入することができないので
`createTodo.rb`の`get_next_id`メソッドで次のidを取得する処理も実装しています。
DynamoDBのsequenceテーブルのseqカラムを使って実現しています。

https://qiita.com/reneice/items/62472deb25622b9acb82


# SAM
AWS SAMを使ってデプロイするために、API Gateway, Lambda, DynamoDBのtempleteファイルを作成します。
```yaml:templete.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
  GetTodo:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samGetTodo
      Handler: getTodo.lambda_handler
      Runtime: ruby3.2
      Timeout: 10
      CodeUri: ./todo/get
      MemorySize: 128
      Policies:
        - AmazonDynamoDBFullAccess
      Events:
        GetApi:
          Type: Api
          Properties:
            Path: /todo
            Method: get
            RestApiId: !Ref API
  CreateTodo:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samCreateTodo
      Handler: createTodo.lambda_handler
      Runtime: ruby3.2
      Timeout: 10
      CodeUri: ./todo/create
      MemorySize: 128
      Policies:
        - AmazonDynamoDBFullAccess
      Events:
        GetApi:
          Type: Api
          Properties:
            Path: /todo
            Method: post
            RestApiId: !Ref API
  UpdateTodo:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samUpdateTodo
      Handler: updateTodo.lambda_handler
      Runtime: ruby3.2
      Timeout: 10
      CodeUri: ./todo/update
      MemorySize: 128
      Policies:
        - AmazonDynamoDBFullAccess
      Events:
        GetApi:
          Type: Api
          Properties:
            Path: /todo
            Method: patch
            RestApiId: !Ref API
  DeleteTodo:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samDeleteTodo
      Handler: deleteTodo.lambda_handler
      Runtime: ruby3.2
      Timeout: 10
      CodeUri: ./todo/delete
      MemorySize: 128
      Policies:
        - AmazonDynamoDBFullAccess
      Events:
        GetApi:
          Type: Api
          Properties:
            Path: /todo
            Method: delete
            RestApiId: !Ref API
  API:
    Type: AWS::Serverless::Api
    Properties:
      Name: sam-todo-api
      EndpointConfiguration: REGIONAL
      StageName: dev
      # 不要な「Stage」ステージが作られるのを防ぐ
      OpenApiVersion: 3.0.2
  TodoDB:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: id
        Type: Number
      TableName: todo
  SequenceDB:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: tablename
        Type: String
      TableName: sequence
```
`sam deploy`でデプロイすることができます。

# 感想
筆者はAWS資格のSAAとDVAを取得した後にこのアプリを作成しました。
資格の勉強だけしても実際アプリを作ってたりしないとAWSを使えるようにならないと思うので
やはり手を動かしてみることは大事だと感じました。
後、SAMでAWSリソースを作成する前に一回マネジメントコンソール画面から手動でリソースを作成するなどするとより理解が深まると思います。