Super Easy Formで超簡単にサーバーレスなHTMLフォームを作ってみる

2020.04.23

CX事業本部の阿部です。

同僚から教えてもらったサーバーレスなバックエンドをもつフォーム作成用のツールを試してみたので、その内容をまとめます。

Super Easy Form is 何

サーバーレスなバックエンドを持ったレスポンシブなHTMLフォームを簡単に作るためのツールです。

Super Easy Forms

特徴

公式のランディングページより。

  • CLIでプロジェクト作成からデプロイまで可能
  • HTMLのカスタマイズが可能(iframe未使用)
  • e-mailでの通知が可能
  • サブミットしたフォームのデータをCSVやJSONにエクスポート可能
  • オプショナルでCAPTCHAが使える

システム要件

  • Node.jsのバージョンが10.x以上
  • AWSのアカウントを持っていること(デプロイに必要)

使ってみる

コマンドラインツールのインストール

npmでインストールが可能です。ランディングページに記載の以下のコマンドでグローバルにインストールしましょう。

npm i -g super-easy-forms-cli

インストール後のコマンドは sef です。インストールされたか確認してみます。

$ npm i -g super-easy-forms-cli
/usr/local/bin/sef -> /usr/local/lib/node_modules/super-easy-forms-cli/bin/run
+ super-easy-forms-cli@1.1.0
added 79 packages from 38 contributors in 6.388s

$ sef --version
super-easy-forms-cli/1.1.0 darwin-x64 node-v12.12.0

ちゃんとインストールされたようです。

プロジェクトを作成する

Getting Startedの流れでプロジェクトを作成してみます。

今回は sef-test というプロジェクト名でフォームを作成してデプロイまでしてみましょう。 オレゴンリージョン us-west-1 に私の開発用のプロファイル personal_dev を使ってデプロイするようにプロジェクトを作成します。 なお、オレゴンリージョンを選んだ理由は、フォーム作成時に通知用のemail送信のためSESを利用していて、このリージョンがSESに対応している必要があるからです。

実行前に build サブコマンドのヘルプを確認してみます。

$ sef build --help
Builds the required base files and directories.

USAGE
  $ sef build

OPTIONS
  -p, --profile=profile  The name of the iam profile/user that you want to create
  -r, --region=region    The desired AWS region were your forms infrastructure will be deployed

これを見ると profile オプションはこのフォーム作成に合わせて新しくプロファイルを作る場合に必要なようです。 今回は私の検証環境のプロファイルをそのまま使う予定なので、このオプションは外て実行します。

$ sef build -r=us-west-2
Created the ./forms directory
Created the ./settings.json file
$ ls -lart
total 24
drwxr-xr-x  6 abe.shinsuke  staff  192  4  9 09:28 ..
drwxr-xr-x  2 abe.shinsuke  staff   64  4  9 09:28 forms
-rw-r--r--  1 abe.shinsuke  staff    2  4  9 09:28 settings.json
-rw-r--r--  1 abe.shinsuke  staff   46  4  9 09:28 .env
drwxr-xr-x  6 abe.shinsuke  staff  192  4  9 09:28 .
-rw-r--r--  1 abe.shinsuke  staff    4  4  9 09:28 .gitignore

各種ファイルが生成されたようです。 forms というサブディレクトリがあるので、一つのプロジェクトで複数のフォームが管理できるようですね。 なお、この段階では、 forms は空ディレクトリです。

また、seggings.jsonにも何も記載がありません。

$ cat settings.json 
{}

フォームを作ってみる

Getting Startedの内容でフォームを作ってみます。以下のコマンドを流します。 なお、このコマンドを流す際には、SESはサンドボックスから出る必要があります。

$ sef fullform myform --email=私のメールアドレス --fields=fullName=text=required,email=email=required,message=text=required

なお、この時に試してみましたが、 フォーム名は英数字以外NG でした。

$ sef fullform myform --email=私のメールアドレス --fields=fullName=text=required,email=email=required,message=text=required
Error: ENOENT: no such file or directory, open './forms/myform/config.json'
    at FullformCommand.run (/usr/local/lib/node_modules/super-easy-forms-cli/src/commands/fullform.js:98:24)
    at FullformCommand._run (/usr/local/lib/node_modules/super-easy-forms-cli/node_modules/@oclif/command/lib/command.js:42:31)

おや?

./forms/myform/config.json がないと言われますね。 確かにないです。 ちょっと fullform のヘルプを参照してみましょう。

$ sef fullform --help
Generates an html form and saves it in the formNames folder

USAGE
  $ sef fullform NAME

ARGUMENTS
  NAME  name of the form - must be unique

OPTIONS
  -c, --captcha                Adds recaptcha elements and scripts to the form and lambda function
  -e, --email=email            Email address that will be used to send emails
  -f, --fields=fields          Desired form formFields
  -l, --labels                 Automatically add labels to your form
  -m, --message=message        the email message body. you can use html and you can use <FormOutput> to include the information from the form submission
  -r, --recipients=recipients  Recipients that will recieve emails on your behalf.
  -s, --subject=subject        the subject of the email message

Generates an html form and saves it in the formNames folder とあるので、フォーム名のディレクトリも一緒に作成してくれそうなものですがそうではないのでしょうか? 他にその前に使えそうなサブコマンドがないかみてみます。

$ sef --help
a CLI for super-easy-forms

VERSION
  super-easy-forms-cli/1.1.3 darwin-x64 node-v12.12.0

USAGE
  $ sef [COMMAND]

COMMANDS
  build        Builds the required base files and directories.
  delete       Deletes all resources in the AWS cloud for the desired form
  deploy       Deploys your stack in the AWS Cloud
  email        Verifies/validates your email with AWS SES
  form         Builds an html form
  fullform     Generates an html form and saves it in the formNames folder
  help         display help for sef
  iam          the --create flag will open up a window with the AWS console so that you confirm the creation of a user with the entered name.
  init         Creates a config file with empty values for your form.
  lambda       Creates or updates a lambda function and optionally zips and uploads it into an AWS s3 bucket.
  submissions  export or list all of the suibmissions you have had to date for a selected form
  template     validate/create/update your cloudformation template saved locally
  variable     Builds an html form

init が使えそうですね。 というわけで sef init を実行してから再度 sef fullform を実行します。 これがバグなのかGetting Startedの記載不備なのかは、現時点では判断つかず。

bash-3.2$ sef fullform myform --email=私のメールアドレス --fields=fullName=text=required,email=email=required,message=text=required
Setting up... done
Verifying email... done
Generating your lambda function... done
Cloudformation template for form myform has been saved
Generating your cloudformation template... done
Creating your stack in the AWS cloud... done
Fetching your API enpoint URL... done
Generating your form... done

なお、メールアドレスのverifyが飛んできますが、ここは時間がかかるのか少し待たされました。

フォームのコード生成と同時にCloudFormationのスタックを作ってデプロイもしてくれるようで、全て終了したらフォームのURL(ローカルにあるHTMLファイル)もブラウザで開いてくれました。

フォームにデータを送信してみる

入力してフォームを送信してみます。

DynamoDBを確認。データは登録されているようですね。

フォームの構成をみてみる

$ ls -lart
total 440
drwxr-xr-x  3 abe.shinsuke  staff      96  4 23 15:36 ..
drwxr-xr-x  4 abe.shinsuke  staff     128  4 23 15:45 myformFunction
-rw-r--r--  1 abe.shinsuke  staff  207088  4 23 15:45 myformFunction.zip
-rw-r--r--  1 abe.shinsuke  staff    5384  4 23 15:45 template.json
-rw-r--r--  1 abe.shinsuke  staff     560  4 23 15:46 config.json
drwxr-xr-x  7 abe.shinsuke  staff     224  4 23 15:46 .
-rw-r--r--@ 1 abe.shinsuke  staff    3474  4 23 15:46 myform.html

myformsFunction の配下をみてみましょう。

$ ls -lart
total 8
drwxr-xr-x  7 abe.shinsuke  staff   224  4 23 15:45 node_modules
drwxr-xr-x  4 abe.shinsuke  staff   128  4 23 15:45 .
-rw-r--r--  1 abe.shinsuke  staff  1634  4 23 15:45 index.js
drwxr-xr-x  7 abe.shinsuke  staff   224  4 23 15:46 ..

index.js の中身はこのようになっています。

const uuidv1 = require('uuid/v1');
    const axios = require('axios').default;
    var AWS = require('aws-sdk');
    var ses = new AWS.SES({apiVersion: '2010-12-01'});
    var dynamodb = new AWS.DynamoDB({apiVersion: '2012-08-10'});

    exports.handler = (event, context, callback) => {
        let obj = {"id":"id","fullName":"fullName","email":"email","message":"message"}; 
        let uid = uuidv1();

                let dbobj = {};
                Object.keys(obj).map(function(key, index) {
                    obj[key] = event[key];
                    dbobj[key] = {S:event[key]};
                })
                obj['id'] = uid;
                dbobj['id'] = {S:uid};
                let params = {
                    Item: dbobj, 
                    TableName: "myform",
                };
                var formOutput = '<br>';
                for(let item in obj){   
                    formOutput += '<span><b>' + item + ': </b>' + obj[item] + '</span><br>'; 
                }
                var emailBody = ''.replace('<FormOutput>', formOutput)
                dynamodb.putItem(params, function(err, data) {
                    if (err) {
                        callback(err);
                    }
                    else {
                        var params = {
                            Destination: {
                                ToAddresses: ["私のメールアドレス"]
                            }, 
                            Message: {
                                Body: {
                                    Html: {
                                        Charset: "UTF-8", 
                                        Data: emailBody
                                    }, 
                                    Text: {
                                        Charset: "UTF-8", 
                                        Data: "empty"
                                    }
                                }, 
                                Subject: {
                                    Charset: "UTF-8", 
                                    Data: ""
                                }
                            }, 
                            ReplyToAddresses: [], 
                            Source: "私のメールアドレス", 
                        };
                        ses.sendEmail(params, function(err, data) {
                            if (err) {
                                callback(err);
                            } 
                            else {
                                callback(null, 'Success');
                            }   
                        });
                    }
                });

    };

イベントからフォームを取得して、DynamoDBにput、その後SESで指定したメールアドレスに送る、という流れのシンプルなLambda Functionです。

そのほかのファイルも確認します。

config.json を見てみる

整形して表示します。

{
    "email": "私のメールアドレス",
    "emailSubject": "",
    "emailMessage": "",
    "recipients": [],
    "formFields": {
        "fullName": {
            "type": "text",
            "label": "Full Name",
            "required": true
        },
        "email": {
            "type": "email",
            "label": "Email",
            "required": true
        },
        "message": {
            "type": "text",
            "label": "Message",
            "required": true
        }
    },
    "captcha": false,
    "zip": true,
    "functionBucket": true,
    "endpointUrl": "APIのエンドポイント",
    "stackId": "CloudFormationのStackID",
    "restApiId": "REST APIのID"
}

フォーム作成時に設定した内容と作成したフォームのAPIのエンドポイントなどの情報が書かれています。

template.json を見てみる

これも長いので整形しました。 作成されるリソースはAPI Gateway/Lamdba/DynamoDBのテーブルとその関連リソース、およびIAMですね。

フォームの静的リソースはこのコマンドではデプロイしないようです。

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Resources": {
        "RestApi": {
            "Type": "AWS::ApiGateway::RestApi",
            "Properties": {
                "Name": "myformRestApi",
                "Description": "The REST API for for your Super Easy Form",
                "EndpointConfiguration": {
                    "Types": [
                        "REGIONAL"
                    ]
                }
            }
        },
        "ApiModel": {
            "Type": "AWS::ApiGateway::Model",
            "Properties": {
                "ContentType": "application/json",
                "Name": "myformApiModel",
                "RestApiId": {
                    "Ref": "RestApi"
                },
                "Schema": {
                    "$schema": "http://json-schema.org/draft-04/schema#",
                    "title": "myform",
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "id": {
                            "type": "string"
                        },
                        "fullName": {
                            "type": "string"
                        },
                        "email": {
                            "type": "string"
                        },
                        "message": {
                            "type": "string"
                        }
                    },
                    "required": [
                        "id",
                        "fullName",
                        "email",
                        "message"
                    ]
                }
            }
        },
        "ApiValidator": {
            "Type": "AWS::ApiGateway::RequestValidator",
            "Properties": {
                "RestApiId": {
                    "Ref": "RestApi"
                },
                "Name": "myformValidation",
                "ValidateRequestBody": true,
                "ValidateRequestParameters": true
            }
        },
        "ApiPostMethod": {
            "Type": "AWS::ApiGateway::Method",
            "Properties": {
                "AuthorizationType": "NONE",
                "HttpMethod": "POST",
                "ResourceId": {
                    "Fn::GetAtt": [
                        "RestApi",
                        "RootResourceId"
                    ]
                },
                "RestApiId": {
                    "Ref": "RestApi"
                },
                "ApiKeyRequired": false,
                "Integration": {
                    "Type": "AWS",
                    "IntegrationHttpMethod": "POST",
                    "Uri": {
                        "Fn::Join": [
                            "",
                            [
                                "arn:aws:apigateway:",
                                {
                                    "Ref": "AWS::Region"
                                },
                                ":lambda:path/2015-03-31/functions/",
                                {
                                    "Fn::GetAtt": [
                                        "LambdaFunction",
                                        "Arn"
                                    ]
                                },
                                "/invocations"
                            ]
                        ]
                    },
                    "IntegrationResponses": [
                        {
                            "ResponseTemplates": {
                                "application/json": "$input.json('$.body')"
                            },
                            "ResponseParameters": {
                                "method.response.header.Link": "integration.response.body.headers.next",
                                "method.response.header.Access-Control-Allow-Origin": "'*'"
                            },
                            "StatusCode": 200
                        }
                    ]
                },
                "RequestValidatorId": {
                    "Ref": "ApiValidator"
                },
                "MethodResponses": [
                    {
                        "ResponseModels": {
                            "application/json": {
                                "Ref": "ApiModel"
                            }
                        },
                        "ResponseParameters": {
                            "method.response.header.Link": true,
                            "method.response.header.Access-Control-Allow-Origin": false
                        },
                        "StatusCode": 200
                    }
                ]
            },
            "DependsOn": [
                "LambdaFunction"
            ]
        },
        "ApiOptionsMethod": {
            "Type": "AWS::ApiGateway::Method",
            "Properties": {
                "AuthorizationType": "NONE",
                "HttpMethod": "OPTIONS",
                "ResourceId": {
                    "Fn::GetAtt": [
                        "RestApi",
                        "RootResourceId"
                    ]
                },
                "RestApiId": {
                    "Ref": "RestApi"
                },
                "ApiKeyRequired": false,
                "Integration": {
                    "IntegrationHttpMethod": "OPTIONS",
                    "Type": "MOCK",
                    "RequestTemplates": {
                        "application/json": "{\n \"statusCode\": 200\n}"
                    },
                    "PassthroughBehavior": "WHEN_NO_MATCH",
                    "TimeoutInMillis": 29000,
                    "CacheNamespace": {
                        "Fn::GetAtt": [
                            "RestApi",
                            "RootResourceId"
                        ]
                    },
                    "Uri": {
                        "Fn::Join": [
                            "",
                            [
                                "arn:aws:apigateway:",
                                {
                                    "Ref": "AWS::Region"
                                },
                                ":lambda:path/2015-03-31/functions/",
                                {
                                    "Fn::GetAtt": [
                                        "LambdaFunction",
                                        "Arn"
                                    ]
                                },
                                "/invocations"
                            ]
                        ]
                    },
                    "IntegrationResponses": [
                        {
                            "ResponseTemplates": {
                                "application/json": ""
                            },
                            "ResponseParameters": {
                                "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'",
                                "method.response.header.Access-Control-Allow-Methods": "'POST,OPTIONS'",
                                "method.response.header.Access-Control-Allow-Origin": "'*'"
                            },
                            "StatusCode": 200
                        }
                    ]
                },
                "MethodResponses": [
                    {
                        "ResponseModels": {
                            "application/json": "Empty"
                        },
                        "ResponseParameters": {
                            "method.response.header.Access-Control-Allow-Headers": false,
                            "method.response.header.Access-Control-Allow-Methods": false,
                            "method.response.header.Access-Control-Allow-Origin": false
                        },
                        "StatusCode": 200
                    }
                ]
            },
            "DependsOn": [
                "LambdaFunction"
            ]
        },
        "DynamoDbTable": {
            "Type": "AWS::DynamoDB::Table",
            "Properties": {
                "AttributeDefinitions": [
                    {
                        "AttributeName": "id",
                        "AttributeType": "S"
                    }
                ],
                "KeySchema": [
                    {
                        "AttributeName": "id",
                        "KeyType": "HASH"
                    }
                ],
                "TableName": "myform",
                "BillingMode": "PAY_PER_REQUEST"
            }
        },
        "LambdaFunction": {
            "Type": "AWS::Lambda::Function",
            "Properties": {
                "Code": {
                    "S3Bucket": "myformfunction",
                    "S3Key": "myformFunction.zip"
                },
                "Description": "This Lambda Function Adds your contact info. to a Dynamo DB table and then sends you an email.",
                "Environment": {
                    "Variables": {
                        "RECAPTCHA_SECRET": "undefined"
                    }
                },
                "FunctionName": "myformFunction",
                "Handler": "index.handler",
                "MemorySize": 128,
                "Role": {
                    "Fn::GetAtt": [
                        "IamRole",
                        "Arn"
                    ]
                },
                "Runtime": "nodejs10.x",
                "Tags": [
                    {
                        "Key": "formName",
                        "Value": "myform"
                    }
                ],
                "Timeout": 30
            },
            "DependsOn": [
                "DynamoDbTable",
                "IamRole"
            ]
        },
        "LambdaPermission": {
            "Type": "AWS::Lambda::Permission",
            "Properties": {
                "Action": "lambda:InvokeFunction",
                "FunctionName": {
                    "Ref": "LambdaFunction"
                },
                "Principal": "apigateway.amazonaws.com",
                "SourceArn": {
                    "Fn::Join": [
                        "",
                        [
                            "arn:aws:execute-api:",
                            {
                                "Ref": "AWS::Region"
                            },
                            ":",
                            {
                                "Ref": "AWS::AccountId"
                            },
                            ":",
                            {
                                "Ref": "RestApi"
                            },
                            "/*/POST/"
                        ]
                    ]
                }
            },
            "DependsOn": [
                "ApiPostMethod"
            ]
        },
        "IamPolicy": {
            "Type": "AWS::IAM::Policy",
            "Properties": {
                "PolicyDocument": {
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Effect": "Allow",
                            "Action": [
                                "dynamodb:GetItem",
                                "dynamodb:PutItem",
                                "dynamodb:UpdateItem"
                            ],
                            "Resource": {
                                "Fn::GetAtt": [
                                    "DynamoDbTable",
                                    "Arn"
                                ]
                            }
                        },
                        {
                            "Effect": "Allow",
                            "Resource": {
                                "Fn::Join": [
                                    "",
                                    [
                                        "arn:aws:ses:",
                                        {
                                            "Ref": "AWS::Region"
                                        },
                                        ":",
                                        {
                                            "Ref": "AWS::AccountId"
                                        },
                                        ":identity/abe.shinsuke@classmethod.jp"
                                    ]
                                ]
                            },
                            "Action": [
                                "SES:SendEmail",
                                "SES:SendRawEmail"
                            ]
                        }
                    ]
                },
                "PolicyName": "myformPolicy",
                "Roles": [
                    {
                        "Ref": "IamRole"
                    }
                ]
            }
        },
        "IamRole": {
            "Type": "AWS::IAM::Role",
            "Properties": {
                "AssumeRolePolicyDocument": {
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Effect": "Allow",
                            "Principal": {
                                "Service": "lambda.amazonaws.com"
                            },
                            "Action": "sts:AssumeRole"
                        }
                    ]
                },
                "Description": "Role that allows the Lambda function to interact with the IAM policy",
                "RoleName": "myformFormRole"
            }
        },
        "ApiDeployment": {
            "Type": "AWS::ApiGateway::Deployment",
            "Properties": {
                "Description": "deployment of the REST API for the myform form",
                "RestApiId": {
                    "Ref": "RestApi"
                },
                "StageName": "DeploymentStage"
            },
            "DependsOn": [
                "ApiPostMethod"
            ]
        }
    }
}

その他のファイルについて

myform.htmlmyformFunction.zip があります。

myform.html はフォームのHTMLファイルです。 Super Easy FormsのCustomize Your Formsのセクション にある通り、フォームのデザインなどを変更したいときは、このファイルを直接編集すればいいようです。 実際の運用では、S3に置いてCloudFrontでフォームを提供する、という形になるのではないかと思います。 REST APIは作られているので、上記構成に頼らなくてもAPIにアクセスできるのであれば、どこでもいいでしょうが。

myformFunction.zip はCloudFormationが使うLambda Functionのコードのリソースです。

まとめ

名前の通り非常に簡単に作成することができました。

フォームのカスタマイズなどはしていませんが、デザインのカスタマイズであればバックエンドを意識することなくできそうです。