Forms, Auth and Serverless Functions on Gatsby and Netlify

Publikováno: 31.5.2018

Abstracting infrastructure is in our DNA. Roads, schools, water supply networks—you get the idea. Web development is no exception: serverless architectures are a beautiful expression of that phenomenon. Static sites, in particular, are turning into dynamic, rich experiences.

Handling static forms, authentication, and backend functions on statically-generated sites is now a thing. Especially with the JAMstack pioneer platform that is Netlify. Recently, they announced support of AWS Lambda functions on front-end-centric sites and apps. I've been meaning …

The post Forms, Auth and Serverless Functions on Gatsby and Netlify appeared first on CSS-Tricks.

Celý článek

Abstracting infrastructure is in our DNA. Roads, schools, water supply networks—you get the idea. Web development is no exception: serverless architectures are a beautiful expression of that phenomenon. Static sites, in particular, are turning into dynamic, rich experiences.

Handling static forms, authentication, and backend functions on statically-generated sites is now a thing. Especially with the JAMstack pioneer platform that is Netlify. Recently, they announced support of AWS Lambda functions on front-end-centric sites and apps. I've been meaning to dive into their "backend" features since.

Today, I'm doing just that, using a static Gatsby site, Netlify's Forms, Identity, and Functions features. This tutorial will show you how to:

  • Add static forms to your site
  • Add user authentication for password-protected content
  • Create an AWS Lambda function

Ready to supercharge a static site with serverless features?

Consider checking out Netlify's React-powered static CMS after this post! And here’s a tutorial on a whole comment workflow, complete with an approval system, using the JAMstack.

Static site forms, auth, and AWS Lambda functions

Before diving into the code, let's detail our use case. I'll be using three different Netlify serverless features:

1. Identity

Identity will be used to create a password-protected, gated content section on the Gatsby site. Authentication without a backend has long been a pain on static sites. But this neat feature solves it elegantly, allowing developers to:

Manage signups, logins, password recovery, and more — all without rolling your own authentication service.

2. Forms

Forms will be used to enable user-submitted product reviews on the site. Dynamic forms can take many forms (see what I did there?), from simple contact forms to commenting, quoting, and review systems hooked to internal tools.

There's a plethora of solutions to handle interactive forms on static sites. But with Forms you can handle them directly within your building and hosting service (Netlify's core offering). No need for spam trap mailto: links, configuring your own server, setting up serverless functions, or integrating third parties like Formspree or FormKeep.

No JavaScript, APIs, or backend needed: just a simple HTML form tagged with the netlify HTML attribute.

3. Functions

Functions will be used to set up a reviews moderation workflow directly in Slack. Under this feature's hood are AWS Lambda functions—event-triggered, scalable backend code you can run without your own server. Deploying these with Netlify is as simple as adding a file to a Git repo.

Lambda functions are indeed powerful, but they normally require an AWS account and API gateway configuration. Like with Forms, Functions simplify your life by offloading the legwork to Netlify:

Your functions are version-controlled, built, and deployed along with the rest of your Netlify site, and the Netlify API gateway automatically handles service discovery. Plus, your functions benefit from the power of Deploy Previews and rollbacks.

In a nutshell, Functions allow you to enhance site interactivity + bridge the front and backend to let data flow between services.

Serverless Gatsby on Netlify: auth, static forms, and Lambda functions

I'll jumpstart this tutorial by using a stripped down version of a previous Gatsby site we built.

To learn Gatsby basics, check out the official tutorial. We also have two e-commerce tutorials with Gatsby here and here.

Prerequisites

For this tutorial, you'll need:

1. Forking the Gatsby project

Start by forking the repo:

View on GitHub

I suggest playing around with it to get familiar with the project. Products are in the src/data/products folder—all inside Markdown files. These files are loaded during the build time and used to inject the proper info in our templates. This is done inside the gatsby-node.js file.

2. Adding Identity for authentication

If you opened any product file, you probably saw there's a field we don't usually use in Snipcart demos: a private attribute. The goal is simple: show these "exclusive" products only when a user is logged in.

To handle that, I used Netlify's Identity widget, a simple way of adding authentication to a static website.

You can install the Identity package by running:

npm install --save netlify-identity-widget

Make sure to include it in your header! Inside the src/components/Header/index.js file, I added this line after the h1 closing tag:

<div data-netlify-identity-menu></div>

You can then import the widget at the top of your file with

const netlifyIdentity = require("netlify-identity-widget");

And declare a componentDidMount function as such:

componentDidMount(){    
  netlifyIdentity.init();
}

The Identity widget now injects proper login forms inside that <div>. Now that you have a static form to login, you need the appropriate logic to validate if a user is logged in or not.

I used this logic to show appropriate, password-protected products to logged in users. To do so, I created a products.js inside the src/pages folder, and defined the following component:

import React from 'react'
import Link from 'gatsby-link'
import styles from './products.module.css'
const netlifyIdentity = require("netlify-identity-widget");

export default class Products extends React.Component {
  constructor(data){
    super(data);

    this.state = {
      products: []
    }
  }

getProducts(){
  return netlifyIdentity.currentUser() != null
    ? this.props.data.allMarkdownRemark.edges
    : this.props.data.allMarkdownRemark.edges
      .filter(x => !x.node.frontmatter.private)  
}

updateProducts(){
this.setState({ products: this.getProducts() });
}

componentDidMount(){
netlifyIdentity.on("login", user => this.updateProducts());
netlifyIdentity.on("logout", () => this.updateProducts());
this.updateProducts();
}

render(){
return (
<div>
  <h1>Products</h1>
  <p>To login use the email: geeks@snipcart.com with password: admin</p>

  <ul className={styles.itemsList}>
    {this.state.products.map((o, index) =>
      <li key={index} className={styles.item}>
        <Link to={o.node.frontmatter.loc}>
          <figure>
            <img className={styles.image} src={o.node.frontmatter.image} alt={o.node.frontmatter.name}></img>
            <figcaption className={styles.figCaption}>Buy the {o.node.frontmatter.name} now</figcaption>
          </figure>
        </Link>
      </li>
    )}
  </ul>
    </div>)
  }
}

export const query = graphql`
query allProducts {
  allMarkdownRemark {
    edges {
      node {
        frontmatter {
          sku,
          loc,
          price,
          desc,
          private,
          name,
          image
        }
      }
    }
  }
`

I won't explain the GraphQL bit here. If you're interested, read more here.

The important part to understand is what's happening inside the componentDidMountlifecycle function. I'm binding myself to the widget 'login' and 'logout' events to update available products.

The end-result is pretty awesome:

3. Handling static forms for reviews

To add product reviews to the Gatsby site, I used Netlify's Forms. You can include their Forms on your own site by adding a 'data-netlify="true"' (or just netlify) attribute to a form declaration. I included it in my src/components/product.js file, after the last section tag.

You will also need to declare a formId variable before the return of your render function such as:

render(){
  if(this.props.data.markdownRemark.frontmatter.private
    && !this.state.loggedIn){
    return fourOfour();
  }

  var formId = `product-${this.props.data.markdownRemark.frontmatter.sku}`

  const button = this.props.data.markdownRemark.frontmatter.private ? (
    <button type="button" className={`${styles.buyButton}`}>
        SOLD OUT
    </button>
  ) : (
  <button type="button" className={`${styles.buyButton} snipcart-add-item`}
      data-item-name={this.props.data.markdownRemark.frontmatter.name}
      data-item-id={this.props.data.markdownRemark.frontmatter.sku}
      data-item-image={this.props.data.markdownRemark.frontmatter.image}
      data-item-url={`${NETLIFY_URL}${this.props.location.pathname}`}
      data-item-price={this.props.data.markdownRemark.frontmatter.price}
      data-item-description={this.props.data.markdownRemark.frontmatter.desc}>
      Buy it now for {this.props.data.markdownRemark.frontmatter.price}$
  </button>
);

return (
  <div>
    <h1>{this.props.data.markdownRemark.frontmatter.name}</h1>
    <div className={styles.breadcrumb}>
      <Link to='/'>Back to the products</Link>
    </div>
    <p>{this.props.data.markdownRemark.frontmatter.desc}</p>

    <section className="section__product">
      <figure className={styles.productFigure}>
        <img src={this.props.data.markdownRemark.frontmatter.image} />
      </figure>

      <article>
        {this.props.data.markdownRemark.frontmatter.description}
      </article>
      <div className={styles.actions}>
        {button}
      </div>
    </section>
    <section>
      <h3 className="reviews">Reviews</h3>
      <div className="reviews__list">
        {this.state.reviews.map((o) =>
          <p key={o.number}>
            <div className="review__name">{o.name}</div>
            <div>{o.data.message}</div>
          </p>
        )}
      </div>

      <form className="review__form" name={formId} method="POST" data-netlify-honeypot="bot-field" data-netlify="true">
        <input type="hidden" name="form-name" value={formId} />    
        <div className="field__form">
          <label>NAME</label>
          <input type="text" name="name"></input>
        </div>
        <div className="field__form">
          <label>EMAIL</label>
          <input type="email" name="email"></input>
        </div>
        <div className="field__form">
          <label>MESSAGE</label>
          <textarea name="message"></textarea>
        </div>

        <button className="button__form" type="submit">SEND</button>
      </form>
    </section>
  </div>)
}

Boom, static forms are on your site!

However, to show submissions coming in through these forms, you need a Netlify function to fetch and return user reviews. To do so, I created a netlify.toml file with the following content:

[build]
  functions = "functions"

I then put a functions folder directly in the root project. Inside it, I placed a fetchreviews.js file with:

const https = require('https');

exports.handler = function(event, context, callback) {
  var id = event.queryStringParameters.id;
  var token = process.env.netlify_access_token;

    if(id == undefined){
      callback('A product id must be specified.', {
        statusCode: 500
    })
  }

  var options = {
    hostname: 'api.netlify.com',
    port: 443,
    method: 'GET',
    headers: {        
      'Content-Type': 'application/json'
    }
  };
  
  var queryToken = `access_token=${token}`;
  var opts1 = Object.assign({}, options, { path: `/api/v1/sites/${process.env.site_id}/forms?${queryToken}`});

  var req = https.request(opts1, function(res) {

    res.setEncoding('utf8');
    var body = "";

    res.on('data', data => {
      body += data;
    });

    res.on('end', function () {
      body = JSON.parse(body);

      var form = body.filter(x => x.name == `product-${id}`)[0];
      var opts2 = Object.assign({}, options, { path: `/api/v1/forms/${form.id}/submissions?${queryToken}`});

      var req2 = https.request(opts2, function(res2) {
        res2.setEncoding('utf8');         
        var body2 = "";

        res2.on("data", (data) => {
          body2 += data;
        });

        res2.on('end', function () {
          callback(null, {
            statusCode: 200,
              headers: {
                "Access-Control-Allow-Origin" : "*",
                'Content-Type': 'application/json'
              },
              body: body2
            })
          });
        });

        req2.end();
      });
  });

  req.end();
}

The function checks whether a product ID was given as a query parameter. If there is an ID, it fetches the form with the name product-{product-id} to get all the reviews from it. This way I could show reviews in the front end.

I added two functions to product.js to do so:

constructor(props){
  super(props);

  this.state = {
    reviews: [],
    loggedIn: false
  }
}

componentDidMount(){
  fetch(`https://${NETLIFY_FUNC}/fetchreviews?id=${this.props.data.markdownRemark.frontmatter.sku}`)
    .then(x => x.json())
    .then(x => {
      this.setState({reviews: x})
    })

  if(netlifyIdentity.currentUser() != null){
    this.setState({loggedIn: true});
  }

  netlifyIdentity.on("login", user => this.setState({loggedIn: true}));
  netlifyIdentity.on("logout", () => this.setState({loggedIn: false}));
}

Then, just before the review form:

{this.state.reviews.map((o) =>
  <p key={o.number}>{o.name}: {o.data.message}</p>
)}

Above, the mounted component fetches the new functions to get specific product reviews. It also updates the state, and show them on the pages. You can also see that we decided to put a "Sold out" button for private products, this is because these are private and wouldn't pass our validation if we were to simply put the current URL, we could still do it but it would require a bit more work out of the scope of this demo.

If you want to test your functions without deploying to Netlify, use the netlify-lambda node package to do so locally. Once you've installed it (npm install netlify-lambda) run netlify-lambda serve ./ in your project folder. The function will run at http://localhost:9000/fetchreviews.

You can update the fetch route above and get the same behavior you'd have with a hosted function.

4. Configuring an AWS Lambda function with Slack

You will need Netlify Forms Pro in order to trigger a function on form submissions.

Last but not least: the reviews moderation workflow directly in Slack. The goal is simple: push review details and notification to Slack and allow to either *keep* or *reject* review from Slack.

To do so, I created 2 new functions in the functions folder: notifyslack.js and answerslack.js. The first is notified by Netlify's webhook of each form submission and is in charge of communicating this to Slack with appropriate action items. I created a small Slack app for this to work (reference).

Here are the permissions the app needs:

Interactive components config:

The Request URL field is where your Netlify function can be called.

With these set up, I installed my app and opened the Incoming webhooks tab. I copied the webhook URL, and got back to my project.

Inside functions, I created the notifyslack.js file with:

var https = require("https");

exports.handler = function(event, context, callback) {
  var body = JSON.parse(event.body);

  if(body != null && body.data != null){
    var data = body.data;

    var message = `New review from ${data.email} \n ${data.name}: ${data.message}`;
    var attach = [
      {
        "title": "Review ID",
        "text": body.id
      },
      {
        "title": "Do you want to keep the review?",
        "text": message,
        "fallback": "You can't take actions for this review.",
        "callback_id": "answer_netlify",
        "color": "#3AA3E3",
        "attachment_type": "default",
        "actions": [
        {
          "name": "response",
          "text": "Keep",
          "type": "button",
          "value": "keep"
        },
        {
          "name": "response",
          "text": "Reject",
          "type": "button",
          "style": "danger",
          "value": "reject",
          "confirm": {
            "title": "Are you sure?",
            "text": "Once it's done the review will be deleted",
            "ok_text": "Yes",
            "dismiss_text": "No"
          }
        }
        ]
      }
    ]

    var postData = JSON.stringify({
      attachments: attach
    });
  
    var options = {
      hostname: 'hooks.slack.com',
      port: 443,
      path: process.env.slack_webhook_url,
      method: 'POST',
      headers: {        
        'Content-Type': 'application/json'
      }
    };
      
    var req = https.request(options, function(res) {
  
      res.setEncoding('utf8');
        
      res.on('end', function () {
        callback(null, {
          statusCode: 200
        })
      });
    });
          
    req.on('error', function (e) {
      console.log('Problem with request:', e.message);
    });
  
    req.write(postData);
    req.end();
  
    callback(null, {
      statusCode: 200
    })
  }
}

Here, you need to update the path value of the options object with your corresponding Slack app webhook URL.

At the moment, this would only notify Slack—whatever action you chose wouldn't trigger anything else.

To make the Slack notification interactive, I created a 3rd function inside a file called answerslack.js. This function is probably the most complicated, but it's mostly requests overhead, so bear with me:

var https = require("https");
var qs = require('querystring')

function getURL(href) {
  var match = href.match(/^(https?\:)\/\/(([^:\/?#]*)(?:\:([0-9]+))?)([\/]{0,1}[^?#]*)(\?[^#]*|)(#.*|)$/);
  return match && {
    href: href,
    protocol: match[1],
    host: match[2],
    hostname: match[3],
    port: match[4],
    pathname: match[5],
    search: match[6],
    hash: match[7]
  }
}

exports.handler = function(event, context, callback) {
  var json = JSON.parse(qs.parse(event.body).payload);

  var answer = json.actions[0].value;
  var access_token = process.env.netlify_access_token;
  var id = json.original_message.attachments[0].text;
  
  if(answer == 'reject'){
    var options = {
      hostname: 'api.netlify.com',
      port: 443,
      path: `/api/v1/submissions/${id}?access_token=${access_token}`,
      method: 'DELETE',
      headers: {        
        'Content-Type': 'application/json'
      }
    };
    
    var req1 = https.request(options, function(res) {

      res.setEncoding('utf8');
            
      res.on('end', function () {
        console.log(`Review with id: ${id} was deleted successfully.`)
      });
    });
        
    req1.on('error', function (e) {
      console.log('Problem with request:', e.message);
    });

    req1.end(); 
  }

  var postData  = JSON.stringify({
    replace_original: true,
    attachments: [{
      text: answer == 'keep'
        ? `The review (${id}) was approved!`
        : `The review (${id}) was rejected.`
    }]
  });

  var url = getURL(json.response_url);

  var options = {
    hostname: url.hostname,
    path: url.pathname,
    method: 'POST',
    headers: {        
      'Content-Type': 'application/json'
    }
  };

  var req = https.request(options, function(res) {

    res.setEncoding('utf8');
        
    res.on('end', function () {
      callback(null, {
        statusCode: 200
      })
    });
  });
    
  req.on('error', function (e) {
    console.log('Problem with request:', e.message);
  });

  req.write(postData);
  req.end();

  callback(null, {
    statusCode: 200
  })    
}

I parse the event payload and check whether the action value was reject. If it's not, it's necessarily keep—nothing to do. But if it is, I need to call Netlify's API to delete the rejected review. I'm retrieving the review ID put earlier inside the first attachment text with the following line:

json.original_message.attachments[0].text;

Once done, I can delete it with one API call. I then give our Slack users feedback by calling the response URL.

I'm kind of proud of the final workflow here TBH:

5. Deploying the Gatsby site on Netlify

Here, I pushed everything to GitHub and hooked it up to Netlify using these settings:

You can see we've used some of the environment variables with the process.env.{variable} notation in the demo.

These are private settings we don't want to make public. To define yours directly, go to /settings/deploys in Netlify's dashboard, hit Edit variables, and input the following:

  • netlify_access_token: The token created earlier inside netlify
  • site_id: Your website url without the protocol
  • slack_webhook_url: Your Slack app webhook URL

Website deployed; time to play around!

Read this to learn how to deploy a site on Netlify.

Live demo and GitHub repo

GitHub Repo

Closing thoughts

This demo took me way more time than expected—a bit of docs misreading on my part, I'll admit.

The challenge was mostly to bundle all services together in good fashion. The biggest hurdle was that functions were not loading properly when launched to Netlify; they would error on each call. For some reason, my project node dependencies were not accessible. I decided to ditch the package I was using to make my requests and use the good ol' httpsnative package instead.

Also, constantly pushing to Netlify to test my functions for fear of them not behaving the same as locally was a bit of a pain.

Now, a few tweaks could be done to improve upon this demo, but overall, I'm super satisfied. Pretty dynamic as far as static sites go, wouldn't you say? :)

I sincerely hope this helps developers get started with Netlify's backend features. If you do build similar JAMstack projects, make sure to send them our way.

We'd love to dig in your code!

The post Forms, Auth and Serverless Functions on Gatsby and Netlify appeared first on CSS-Tricks.

Nahoru
Tento web používá k poskytování služeb a analýze návštěvnosti soubory cookie. Používáním tohoto webu s tímto souhlasíte. Další informace