🐥

Svelte から AWS Lambda を API Gateway をトリガーに呼び出す

2022/02/15に公開

Svelte で書かれた、氏名とメールアドレスを入力する欄があるだけのシンプルなフォームの Submit ボタンが押されたとき、AWS Lambda 関数を呼び出す場合を考えます。

Svelte プロジェクト作成

以下のコマンドを実行して、プロジェクトを作成し、バリデーションのfelteをインストールします。<project-name>は、任意の名前です。

$ npx degit sveltejs/template <project-name>
$ cd <project-name>
$ npm install
$ npm install --save felte yup @felte/validator-yup

ちなみにnpm installnpm iに、npm --savenpm -Sに置き換えられます。

src直下にあるApp.svelteを以下に書き換えます。felte を読み込んでいます。

App.svelte
<script>
  import { createForm } from 'felte'
  import { validator } from '@felte/validator-yup'
  import * as yup from 'yup'
</script>

<main>
</main>

バリデーションの効いたフォームの作成

yup.objectでバリデーションをする要素を設定することができます。
設定するkey<input>要素に追加するname属性と同じ名前を設定する必要があり、これをschemaという定数で定義します。
次のcreateFormでは、これらのバリデーションを管理していきます。書き換える部分はonSubmit内です。この中にはSubmitボタンが押されたときに実行したい処理を書くことができます。ここでは、フォームで入力された内容を AWS Lambda 関数に送信するための、API コールをする関数を呼び出しています(後ほど実装)。引数には、フォームの内容をJSON.stringifyメソッドでstringにしたデータを渡しています。

App.svelte
<script>
  const schema = yup.object({
    email: yup.string().email().required(),
    name: yup.string().required(),
  })

  const { form, errors } = createForm({
    extend: validator,
    validateSchema: schema,
    onSubmit: async (values) => {
      sendContact(JSON.stringify(values, null, 2))
    },
  })
</script>

<main>
  <form use:form on:submit|preventDefault>
    <input type="text" name="name" />
    {#if $errors.name}
      <span>Error Message</span>
    {/if}

    <input type="email" name="email" />
    {#if $errors.email}
      <span>Error Message</span>
    {/if}

    <button type="submit">Submit</button>
  </form>
</main>

フォームは<form use:form on:submit|preventDefault>とします。
この中に書かれた、対応するname属性の要素についてバリデーションを行い、独自のエラーメッセージを表示させることができます。エラーメッセージはカスタマイズすることができ、例えば<input>要素のタイプがemailの場合であれば、「入力されていない場合」と「正しいメールの形式ではない場合」の 2 つのエラーメッセージを発生させたい場合が考えられます。それぞれを別のエラーとして分けるには、felteが出力するエラーメッセージで分岐させる必要があり「入力されていない場合」はemail is a required field、「正しいメールの形式ではない場合」はemail must be a valid emailというエラーメッセージが出力されます。このエラーメッセージ内のemailというのは、<input>要素内に設定されたname属性の値です。なので、このメッセージによって表示させたいエラーメッセージを分けることができ、以下のよう書くことができます。

App.svelte
{#if $errors.email == 'email must be a valid email'}
  Please enter a valid email address
{:else if $errors.email == 'email is a required field'}
  Please enter your email address
{/if}

これを適応したときのApp.svelte

App.svelte
<script>
  const schema = yup.object({
    email: yup.string().email().required(),
    name: yup.string().required(),
  })

  const { form, errors } = createForm({
    extend: validator,
    validateSchema: schema,
    onSubmit: async (values) => {
      sendContact(JSON.stringify(values, null, 2))
    },
  })
</script>

<main>
  <form use:form on:submit|preventDefault>
    <input type="text" name="name" />
    {#if $errors.name}
      <span>Please enter your name</span>
    {/if}

    <input type="email" name="email" />
    {#if $errors.email == 'email must be a valid email'}
      <span>Please enter a valid email address</span>
    {:else if $errors.email == 'email is a required field'}
      <span>Please enter your email address</span>
    {/if}

    <button type="submit">Submit</button>
  </form>
</main>

これでバリデーションが適応されたフォームを作成することができました。
バリデーションから外れている場合にはonSubmit内の処理が実行されることはありません。

Lambda 関数を呼び出す

今回の API の呼び出しはcurlコマンドだと、氏名とメールアドレスを送るため、-dオプションを使い、以下のように書くことができます。

curl <api endpoint> -d "{\"name\": \"xxx\", \"email\": \"xxx@yyy.zzz\"}"

<api endpoint>は AWS API Gateway で作成した URL です(後ほど作成)。次に-Hオプションでヘッダー情報にContent-Type: application/jsonを乗せると、コマンド全体は以下のようになります ↓

$ curl <api endpoint> -d "{\"name\": \"xxx\", \"email\": \"xxx@yyy.zzz\"}" -H "Content-Type: application/json"

このコマンドをcurlconverterNode.js(fetch)を指定して変換すると以下のコードが得られます。

var fetch = require("node-fetch");

fetch("<api endpoint>", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "xxx", email: "xxx@yyy.zzz" }),
});

これを関数として実行できるようにし、返値を取得できるようにすると、以下のコードになります。mode: "cors"は CORS を許可するために追加しています。-dオプションはbodyで、フォームの内容を関数の引数として受け取り、そのまま流すようにしています。

let promise;
const sendContact = (bodyContent) => {
  promise = fetch('<api-endpoint>', {
    mode: 'cors",
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: bodyContent,
  }).then((x) => x.json());
};

処理が実行されてから、終了するまでに実行したい処理は以下のように書くことができます。
ここでは、レスポンスが帰ってくるまではローディングのアニメーションを実行し、
レスポンスが正常に帰ってきた場合は、そのデータを表示させ、エラーが発生した場合は、
独自のエラーメッセージを表示させています。

App.svelte
{#await promise}
  <span><i class="fas fa-circle-notch fa-spin"></i></span>
{:then data}
  {#if data}
    <pre><code>{data}</code></pre>
  {:else}
    &nbsp;
  {/if}
{:catch error}
  <span>An error occurred</span>
{/await}

これで Svelte 側の処理を実装することができました。
これまでのコード ↓

App.svelte
<script>
  import { createForm } from 'felte'
  import { validator } from '@felte/validator-yup'
  import * as yup from 'yup'

  let promise;
  const sendContact = (bodyContent) => {
    promise = fetch("<api-endpoint>", {
      mode: "cors",
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: bodyContent,
    }).then((x) => x.json());
  };

  const schema = yup.object({
    email: yup.string().email().required(),
    name: yup.string().required(),
  })

  const { form, errors } = createForm({
    extend: validator,
    validateSchema: schema,
    onSubmit: async (values) => {
      sendContact(JSON.stringify(values, null, 2))
    },
  })
</script>

<main>
  <form use:form on:submit|preventDefault>
    <input type="text" name="name" />
    {#if $errors.name}
      <span>Please enter your name</span>
    {/if}

    <input type="email" name="email" />
    {#if $errors.email == 'email must be a valid email'}
      <span>Please enter a valid email address</span>
    {:else if $errors.email == 'email is a required field'}
      <span>Please enter your email address</span>
    {/if}

    <button type="submit">Submit</button>
  </form>

  {#await promise}
    <span><i class="fas fa-circle-notch fa-spin"></i></span>
  {:then data}
    {#if data}
      <pre><code>{data}</code></pre>
    {:else}
      &nbsp;
    {/if}
  {:catch error}
    <span>An error occurred</span>
  {/await}
</main>

Lambda 関数の実装

まず Lambda 関数を作ります。今回は test という名前で作成しました。

img1.png

作成した Lambda 関数を選択すると以下ような画面に移動します。

img2.png

次に「トリガーを追加」を選択します。API Gateway を選択して、以下の設定にします。Svelte の<api endpoint>は、作成した API Gateway のエンドポイント URL に書き換えてください。

img3.png

次に作成した API Gateway の CORS を有効にします。「CORS の有効化」を選択します。

img4.png

次に「CORS を有効にして既存の CORS ヘッダーを置換」を選択します。

img6.png

手順:「アクション」→「CORS の有効化」→「CORS を有効にして既存の CORS ヘッダーを置換」

これで API Gateway の CORS を有効にできました。

次に Lambda 関数の CORS を有効にします。

Lambda 関数は、レスポンスヘッダーに、以下のようにAccess-Control-Allow-HeadersAccess-Control-Allow-OriginAccess-Control-Allow-Methodsを追加することで CORS を有効にすることができます。

lambda_handler.py
import ast
import json


def lambda_handler(event, content):
    data = ast.literal_eval(event['body'])
    name = data['name']
    email = data['email']
    body_content = f'Name: {name}, Email: {email}'

    return {
        'statusCode': 200,
        'headers': {
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'OPTIONS,POST'
        },
        'body': json.dumps(body_content)
    }

関数内の初めの 4 行では、送られてきたデータを Python で扱える形にしています。

def lambda_handler(event, content):
    data = ast.literal_eval(event['body'])
    name = data['name']
    email = data['email']
    body_content = f'Name: {name}, Email: {email}'

    ...

Python の標準ライブラリにあるastliteral_evalメソッドは、文字列の辞書や配列を、オブジェクトとしての辞書や配列に変換してくれます。

ast --- 抽象構文木 — Python 3.10.0b2 ドキュメント

送られてきたデータが Python で変換できない時があります(JavaScript ではtrueだが、Python ではTrueのような時)。例えば、チェックボックスのチェックの有無を Lambda 関数に送信したとき、Python では、これを変換することができずにエラーになってしまいます。なので、JavaScript 側であらかじめtrueを文字列に変換するか、(0, 1)で表現する必要があります。もしtrueを Python のTrueに変換する場合は、標準ライブラリdistutilsにあるutil.strtoboolを使うことができます。これは(0, 1)を返します。

>>> from distutils.util import strtobool
>>> strtobool("true")
1

9. API リファレンス — Python 3.10.0b2 ドキュメント

おまけ

SendGridを Lambda 関数で使う場合と、SlackIncoming Webhooksを使う場合を紹介します。

SendGrid

sendgridをインストールします。

$ pip3 install sendgrid

SENDGRID_APIKEYは作成した API のキー、FROMは SendGrid で送信用に登録したメールアドレスです。to_emailsにはメールを送信するリストを指定します。メールの内容は、plain_text_contentではなくhtml_contentを指定することも可能です。

import os

from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail


SENDGRID_APIKEY = os.environ['SENDGRID_APIKEY']
FROM = os.environ['FROM']


mail_data = Mail(
    from_email=(FROM, 'me'),
    subject='Hello, World!',
    to_emails=[
        ('bob@example.com', 'Bob'),
        ('alice@example.com', 'Alice')
    ],
    plain_text_content='Hello, World!'
)

sendgrid = SendGridAPIClient(SENDGRID_APIKEY)
sendgrid.send(mail_data)

Incoming Webhooks

requestsをインストールします。

$ pip3 install requests

コード ↓

import os

import requests


WEBHOOK_URL = os.environ['WEBHOOK_URL']


response = requests.post(
        WEBHOOK_URL,
        data=json.dumps({
            'text' : 'Hello, World!',
            'username' : 'Bot'
            }
        )
    )

どちらの場合も、Lambda で実行する場合には外部パッケージを含めたフォルダをアップロードする必要があります。

ここからは SendGrid と Incoming Webhooks の両方を Lambda 関数で使う場合を扱います。以下のコマンドを実行します。

$ mkdir <package name>
$ pip3 install sendgrid requests -t ./<package name>
$ touch ./<package name>/lambda_function.py
$ zip -r ./<package name>.zip ./<package name>

lambda_function.pyには先ほどのメール送信のプログラムを書きます。

img2.png

「アップロード元」から「.zip ファイル」をクリックして、先ほど作成した<package name>.zipを選択します。ここで左のフォルダツリーはtest/<package name>/lambda_function.pyとなるので、test/lambda_function.pyとなるように移動します。この時、外部パッケージのフォルダ(sendgrid, requests)も全てtest直下に移動します。

これで Lambda 関数が正常に動作します。

Discussion