🐡

SvelteのフロントエンドをAWSのバックエンドと繋げてみる

2021/04/07に公開

はじめに

以前にSvelteで遊んでいる時に①TODOアプリ(フロントエンドだけ)を作ってて、そのあとでAWS Amplifyで遊び始めてから②AmplifyでTODOのバックエンドをつくったんですが、ここまできたら流れ的に①と②を繋いでみようかな、となります。そういう回です。

①と②の記事はこちら:
https://zenn.dev/masaino/articles/5750eccd109cdf
https://zenn.dev/masaino/articles/080f998d73351f

できあがり

お恥ずかしながら、できあがりのデモ画面を。

todo_demo

構成はこんな感じ

 ┌──────────┐     ┌──────────┐      ┌──────────┐     ┌──────────┐
 │ Frontend │     │  APIGW   │      │  Lambda  │     │          │
 │ (Svelte) ├─────┤► (REST)  ├──────►   with   ├─────► DynamoDB │
 │          │     │          │      │  RubySDK │     │          │
 └──────────┘     └──────────┘      └──────────┘     └──────────┘

コード

Svelte

前回のフロントエンドだけで動いていたやつをAPIコールするように変更したバージョンです

Todo.svelte
<script>
  const baseURL = 'https://<api-id>.execute-api.<region>.amazonaws.com/dev/tasks'

  let taskName = ""
  let checked = false
  let modalOn = false

  // sort
  function compare( a, b ) {
    console.log('compare called')
    if ( a['updated-at'] > b['updated-at'] ){ return -1; }
    if ( a['updated-at'] < b['updated-at'] ){ return 1; }
    return 0;
  }

  function sortByCreatedAt(todos){
    console.log('sortBy called')
    todos.then(data => {
      data = data.sort(compare);
    });
    return todos
  }

  function statusText(taskStatus){ return taskStatus ? "Active" : "Done" }

  const listTasks = async () => {
    console.log('listTasks called')
    const res = await fetch(baseURL);
    return await sortByCreatedAt(res.json())
  }

  const getTask = async (taskId) => {
    console.log('getTask called')
    const task = await fetch(baseURL + '/' +taskId);
    return await task.json()
  }

  const addTask = async (taskName) => {
    console.log('addTask called!')

    let taskData = {
      "task-id": Date.now().toString(36),
      "is-active": true,
      "task-name": "",
      "updated-at": Date.now().toString(),
      "created-at": Date.now().toString(),
      "user-id": "100"
    }

    taskData['task-name'] = taskName;
    
    const res = await fetch(baseURL, {
      method: 'POST',
      body: JSON.stringify(taskData)
    });
    todos = listTasks()
  }

  const deleteTask = async (taskId) => {
    console.log('deleteTask called!')
    const res = await fetch(baseURL + '/' + taskId, {
      method: 'DELETE',
    });
    todos = listTasks() // assignment to ignite the reactivity
  }

  const completeTask = async (taskId, status) => {
    const res = await fetch(baseURL + '/' + taskId, {
      method: 'PUT',
      body: JSON.stringify({ 'task-id': taskId, 'is-active': !status })
    });
    todos = listTasks()
  }

  let taskDetail = ''
  function showDetail(taskId) {
    console.log('showDetail called')
    modalOn = true
    //return getTask(taskId)
    taskDetail = getTask(taskId)
  }

  // format date
  function fd(date) {
    console.log('fd called')
    let d = new Date(parseInt(date))
    return d.getFullYear() + '/' + d.getMonth() + '/' + d.getDate() + ' ' + d.getHours() + ':' + d.getMinutes()
  }

  // todos
  let todos = listTasks(); // Initialized as Promise
  $: activeTodos =  checked ? todos : todos.then(data => data.filter(todo => todo['is-active']))

</script>

<main>

  <h1>HELLO TODO!</h1>
  <div class="container is-fluid">
    <div class="columns is-centered">
      <div class="field has-addons">
        <div class="control" style="margin-bottom: 5em;">
          <input class="input" type="text" placeholder="Add a task" bind:value={taskName}>
        </div>
        <div class="control">
          <a class="button is-danger" on:click={() => addTask(taskName)}><i class="fas fa-plus"></i></a>
        </div>
      </div>
    </div>

    <div class="columns" style="width: 200px; margin-left: 60%; margin-bottom: 3em">
      <div class="field">
        <input id="switchRoundedDanger" type="checkbox" name="switchRoundedDanger" class="switch is-rounded is-danger is-rtl" bind:checked={checked}>
        <label for="switchRoundedDanger">Show completed</label>
      </div>
    </div>

    <div class="columns is-centered">
        {#await activeTodos}
          <span class="spinner-loader" style="margin-top: 10em">Loading…</span>
        {:then tasks}
        <table class="table is-hoverable">
          <thead>
            <tr>
              <th>ID</th>
              <th>Name</th>
              <th>Created at</th>
              <th>Status</th>
              <th>Action</th>
            </tr>
          </thead>
          <tbody>
            {#each tasks as task}
              <tr>
                <td>{task['task-id']}</td>
                <td>{task['task-name']}</td>
                <td>{fd(task['created-at'])}</td>
                <td>{statusText(task['is-active'])}</td>
                <td>
                  <i class="fas fa-check" on:click={() => completeTask(task['task-id'], task['is-active'])}></i> 
                  <i class="fas fa-info" on:click={() => showDetail(task['task-id'])}></i>
                  <i class="far fa-trash-alt" on:click={() => deleteTask(task['task-id'])}></i>
                </td>
              </tr>
            {/each}
          </tbody>
        </table>
        {/await}
    </div>

    <div class="modal { modalOn ? 'is-active' : ''}">
      <div class="modal-background"></div>
      <div class="modal-content">
        <dev class="box">
        {#await taskDetail}
          <span class="spinner-loader" style="margin-top: 10em">Loading…</span>
        {:then td}
        <table class="table is-fullwidth">
          <thead>
            <tr><td>key</td><td>value</td></tr>
          </thead>
          <tbody>
            <tr><td>task-id</td><td><span class="tag">{td['task-id']}</span></td></tr>
            <tr><td>task-name</td><td><span class="tag">{td['task-name']}</span></td></tr>
            <tr><td>created-at</td><td><span class="tag">{fd(td['created-at'])}</span></td></tr>
            <tr><td>updated-at</td><td><span class="tag">{fd(td['updated-at'])}</span></td></tr>
            <tr><td>is-active</td><td><span class="tag">{td['is-active']}</span></td></tr>
            <tr><td>user-id</td><td><span class="tag">{td['user-id']}</span></td></tr>
          </tbody>
        </table>
        {/await}
        </dev>
      </div>
      <button class="modal-close is-large" aria-label="close" on:click="{() => modalOn = false}"></button>
    </div>

  </div>
</main>

<style>
  main {
    text-align: center;
    padding: 1em;
    max-width: 240px;
  }

  h1 {
    color: #ff3e00;
    text-transform: uppercase;
    font-size: 4em;
    font-weight: 100;
    margin-bottom: 1.5em;
  }

  td i {
    padding: 5px;
    cursor: pointer;
    color: gray;
  }

  label { font-family: monospace; }
  table { font-family: monospace; }

  @media (min-width: 640px) {
    main {
      max-width: none;
    }
  }
</style>

Lambda

以前の②のポストの内容とほぼ同じですが...

lambda_handler.rb
require 'json'
require 'json/add/exception'
require 'aws-sdk-dynamodb'

CORS_HEADER = {
  "Access-Control-Allow-Headers": "Content-Type",
  "Access-Control-Allow-Origin": '*',
  "Access-Control-Allow-Methods": "OPTIONS,POST,PUT,GET"
}

def add_task(table, body)
  begin
    table.put_item({ item: body })  
    { statusCode: 200, headers: CORS_HEADER, body: JSON.generate(body) }
  rescue => e
    { statusCode: 500, body: e.to_json }
  end
end

def delete_task(table, task_id)
  begin
    params = { table_name: table, key: { 'task-id': task_id } }
    table.delete_item(params)
    list_task(table)
  rescue => e
    { statusCode: 500, body: e.to_json }
  end
end

def update_task(table, body)
  begin
    params = {
      table_name: table,
      key: { 'task-id': body['task-id'] },
      attribute_updates: {
        'is-active': { value: body['is-active'], action: "PUT" },
      }
    }
    table.update_item(params) 
    { statusCode: 200, headers: CORS_HEADER, body: JSON.generate(body) }
  rescue => e
    { statusCode: 500, headers: CORS_HEADER, body: e.to_json }
  end
end

def list_task(table)
  begin
    scan_output = table.scan({ limit: 50, select: "ALL_ATTRIBUTES" })
    { statusCode: 200, headers: CORS_HEADER, body: JSON.generate(scan_output['items']) }
  rescue => e
    { statusCode: 500, body: e.to_json }
  end
end

def get_task(table, task_id)
  begin
    params = { key: { 'task-id': task_id } }
    task = table.get_item(params)
    { statusCode: 200, headers: CORS_HEADER, body: JSON.generate(task['item']) }
  rescue => e
    { statusCode: 500, body: e.to_json }
  end
end

def lambda_handler(event:, context:)
 
  begin
    http_method = event['httpMethod']
    dynamodb = Aws::DynamoDB::Resource.new(region: 'us-east-2')
    table = dynamodb.table('todoTable-dev')
    
    case http_method
      when 'GET'
        path_param = event.dig('pathParameters', 'proxy')
        if path_param.nil?
          list_task(table)
        else
          get_task(table, path_param) 
        end
      when 'PUT'    then update_task(table, JSON.parse(event['body']))
      when 'POST'   then result = add_task(table, JSON.parse(event['body']))
      when 'DELETE' then delete_task(table, event['pathParameters']['proxy'])
      else 0
    end
  rescue => e
    { statusCode: 500, body: e.to_json }
  end

end

学び的なもの、もしくはただの感想

単なるTODOアプリでもバカにしてはいけない。新しいフレームワーク勉強するなら特に

Svelte編

1. Await block が変態的だがいい感じ

初見では、おっ、テンプレでawaitするのか!って思う書き方ですが、いいですね。

    <h1>Task list</h1>
    {#await todos} 
      <p>Loading...</p>
    {:then tasks}
      {#each tasks as task}
        <p>{task['task-id']}, {task['task-name']}</p>
      {/each}
    {/await}

2. on:clickで発火する関数に引数を渡す方法

引数がない関数のコールはこれでいいけど

 <button on:click="{listTask}"></button>

引数がある場合はこう書いてしまうとクリック時じゃなくて即時実行になってしまう

 <button on:click="{getTask(taskId)}"></button>

代わりにこう書く

 <button on:click="{() => getTask(taskId)}"></button>

https://stackoverflow.com/questions/58262380/how-to-pass-parameters-to-onclick-in-svelte

3. 一つのアクションで二つの関数はコールできない

Vueみたいにこんな風にかけるかと思ったがだめだった。まあ、前の関数の中で後の関数を呼べばいいんだよな。

 <button on:click="{listTask; getTask}"></button>

https://github.com/sveltejs/svelte/issues/2109

4. selectedなどのショートハンドがいけてる

チエックボックスやモーダル開く時にselectedなどのclassを操作することがあります。

<style>
  /* ...other CSS... */
  span.cell.selected {
    outline-color: lightblue;
    outline-style: dotted;
  }
</style>

<span class="cell {selected === true ? 'selected' : ''}">
  {value}
</span>

というのは、以下のように書くことができますが

<span class="cell" class:selected="{selected}">
  {value}
</span>

さらにショートハンドでこうなる

<span class="cell" class:selected>
  {value}
</span>

いいですね。ただし、class名にハイフンを使ったりすると動かなくて困ります。

出典はここ

https://flaviocopes.com/svelte-dynamically-apply-css/

CORS編

1. fetchno-corsは万能ではない。PUTだと動かない

CORS周りはハマるとかなしい。そんなときno-corsで逃げられるとは限らない

2. エラーメッセージにもCORSヘッダつけておいた方がデバッグしやすい

これはAPIGW側で500の場合は、とかで一括でつけれると思う

JavaScript編

1. JSのDateは相変わらずつらい

書きたくもない

2. Promiseからは逃げられない

中身だけが欲しいと思って、どんなに中身だけを切り出そうと頑張っても、返ってくるのはいつもPromise。仲良くしないといけない

LoadingのCSSにはこれを使え編

こういう細かいところで雰囲気が変わって、ちょっとだけ作るモチベーションが長持ちするもんだ。

http://www.css-spinners.com/

DynamoDB編

1. ソートはクエリでやるのではなく、テーブルにソートキーというのをつけるみたいだ...

Firebaseにすればよかった...

2. ソートキーをつけ忘れたら、テーブル作り直しな

DynamoDB doesn't allow to add a sort key for an already existing table. Your only option is to create a new table with redefined key attributes and then copy the data from the existing table to the newly created table.

まじか.. フロントでソートしたわ。

3. :return_valuesはいまいち

更新系の処理をした時に、値を返してくれるが更新前の値(ALL_OLDとか)だったりしていまいち使いずらい。どういうアプリを想定しているの?

Lambda編

最初はクライアントが楽かなと思って、Lambda側でPOSTやPUT受け付けたら、更新後のタスクリストをGETして返すようなことをやっていたが、やめた方がいい。各リクエストはAtomicにして、フロント側から操作する方がややこしくならない

おつかれさまです

Discussion