Getting started with Fauna and Fastly Compute@Edge

What we’re building

In this tutorial, we explore building a REST API in a fully serverless manner by leveraging Fastly’s Compute@Edge service and Fauna. The procedure below walks you through building the API, including a /products resource and its GET, POST, PUT, and DELETE methods.

On the Fauna side, we package our database workload into User-defined functions with associated User-defined roles. This ensures that the queries run with the minimum access privileges necessary for them to perform their functions.

Using UDFs instead of ad hoc queries also helps to simplify the code which parses API requests as they pass back and forth from Fastly to Fauna. UDFs allow us to shape data into objects which are easier to deal with than the raw results of database queries.

Prerequisites

Setting up Fauna

  1. Create a new database

    Create a new database named client-serverless-api with the Fauna Dashboard. Select your Region Group from the REGION GROUP menu and click CREATE.

  2. Create a new collection

    From the left-side navigation, select COLLECTIONS and click NEW COLLECTION. Name the collection Products and click SAVE.

    The 'Create a new collection' panel in the Dashboard

  3. Create a role with privileges on the Products collection

    The following Shell query creates a role named productsCRUD with read, write, delete, and create privileges on the Products collection. Navigate to the shell by clicking SHELL in the dashboard’s left-side navigation.

    CreateRole({
      name: "productsCRUD",
      privileges: [
        {
          resource: Collection("Products"),
          actions: {
            read: true,
            write: true,
            create: true,
            delete: true,
          }
        }
      ]
    })
  4. Create UDFs

    Rather than issuing database queries directly from the client, it’s more secure to package them into UDFs. The following Shell queries create UDFs for creating, updating, retrieving, and deleting product documents.

    Note that the GetProduct, DeleteProduct, and UpdateProduct functions use the Exists function to perform error handling. If they are called with a non-existent product ID, they use the Abort function to return an error message.

    CreateFunction({
      name: "CreateProduct",
      body: Query(
        Lambda(
          ["body"],
          Let(
            {
              dataForCreate: {
                serialNumber: Select(["serialNumber"], Var("body"), null),
                title: Select(["title"], Var("body"), null),
                weightLbs: Select(["weightLbs"], Var("body"), null),
                quantity: Select(["quantity"], Var("body"), null)
              },
              result: Create(Collection("Products"), { data: Var("dataForCreate") })
            },
            Merge({ id: Select(["ref", "id"], Var("result")) }, Var("dataForCreate"))
          )
        )
      ),
      role: Role("productsCRUD")
    })
    
    CreateFunction({
      name: "UpdateProduct",
      body: Query(
        Lambda(
          ["refID", "body"],
          Let(
            {
              updateData: {
                serialNumber: Select(["serialNumber"], Var("body")),
                title: Select(["title"], Var("body")),
                weightLbs: Select(["weightLbs"], Var("body")),
                quantity: Select(["quantity"], Var("body"))
              },
              exists: Exists(Ref(Collection("Products"), Var("refID")))
            },
            If(
              Var("exists"),
              Do(
                Update(Ref(Collection("Products"), Var("refID")), {
                  data: Var("updateData")
                }),
                Merge({ id: Var("refID") }, Var("updateData"))
              ),
              Abort("instance not found")
            )
          )
        )
      ),
      role: Role("productsCRUD")
    })
    
    CreateFunction({
      name: "GetProduct",
      body: Query(
        Lambda(
          ["refID"],
          Let(
            {
              ref: Ref(Collection("Products"), Var("refID")),
              exists: Exists(Var("ref")),
              doc: If(Var("exists"), Get(Var("ref")), {})
            },
            If(
              Var("exists"),
              Merge(
                { id: Select(["ref", "id"], Var("doc")) },
                Select(["data"], Var("doc"))
              ),
              Abort("instance not found")
            )
          )
        )
      ),
      role: Role("productsCRUD")
    })
    
    CreateFunction({
      name: "DeleteProduct",
      body: Query(
        Lambda(
          ["refID"],
          Let(
            { ref: Ref(Collection("Products"), Var("refID")) },
            If(
              Exists(Var("ref")),
              Do(Delete(Var("ref")), "Item deleted."),
              Abort("instance not found")
            )
          )
        )
      ),
      role: Role("productsCRUD")
    })
  5. Create a role to call the UDFs

    Now we can create a role with no other privileges than to call the newly created UDFs. The following Shell query creates a role named APIrole with call permission on our four UDFs.

    CreateRole({
      name: "APIrole",
      privileges: [
        {
          resource: Function("CreateProduct"),
          actions: { call: true }
        },
        {
          resource: Function("UpdateProduct"),
          actions: { call: true }
        },
        {
          resource: Function("GetProduct"),
          actions: { call: true }
        },
        {
          resource: Function("DeleteProduct"),
          actions: { call: true }
        }
      ]
    })
  6. Test your UDFs

    You can test your UDFs by selecting APIrole from the RUN AS dropdown menu at the bottom of the shell interface.

    The following Shell query creates a new document in the Products collection:

    Call(Function("CreateProduct"),
      {
        serialNumber: 81398,
        title: "24 inch monitor",
        weightLbs: 3,
        quantity: 20
      }
    )
    {
      serialNumber: 81398,
      title: "24 inch monitor",
      id: "328689527477502146",
      weightLbs: 3,
      quantity: 20
    }

    You can test the other UDFs in the same way.

  7. Create an API key

    Fauna API keys provide anonymous, role-based remote access to your Fauna data. Use the following Shell query to create an API key with access limited to the APIrole role:

    CreateKey({
      role: Role('APIrole')
    })
    {
      ref: Key("328054342539018433"),
      ts: 1649115850890000,
      role: Role("productsCRUD"),
      secret: "fnAEjXudojkeRWaz5lxL2wWuqHd8k690edbKNYZz",
      hashed_secret: "$2a$05$TGr5F3JzriWbRUXlKMlykerq1nnYzEUr4euwrbrLUcWgLhvWmnW6S"
    }

    Copy the secret value and save it in a safe place. If you lose the secret, you must create a new key; there is no way to retrieve the secret.

Setting up Fastly

With the Fauna side of your application ready, you can move on to the Compute@Edge side by setting up a new Fastly service.

  1. Create a new service

    Sign in to Fastly. Navigate to the COMPUTE tab at the top of the screen and click CREATE A COMPUTE SERVICE.

    After the screen refreshes, update the default service name by clicking on it. Change it to something descriptive, such as client-serverless-api.

  2. Configure the service

    1. Click the SERVICE CONFIGURATION tab.

    2. In the left navigation pane, select ORIGINS > HOSTS.

    3. In the HOSTS text box, enter the value db.fauna.com and click ADD.

    4. Click on the newly created host to edit its display name. Rename the host to db_fauna_com and click UPDATE at the bottom of the screen. Leave the other values unchanged.

  3. Get a personal API token

    1. From the top right corner of your Fastly dashboard, click on your username to activate a dropdown menu. Choose ACCOUNT to enter the Account Settings page.

    2. In the left-side navigation, click on PERSONAL API TOKENS.

    3. Click CREATE TOKEN.

    4. Use the following settings:

      • Name: client-serverless-api

      • Service Access: A specific service. Select the service you created earlier from the dropdown menu.

      • Scope: Global API access

      • Expiration: Never expire

    5. Click CREATE TOKEN.

    6. Copy the generated value and store it securely.

  4. Create a local Fastly project

    1. Open a terminal window.

    2. Create a project directory and navigate into it.

    3. Install the Fastly CLI.

    4. Run the following command:

      fastly compute init
    5. When prompted for Language, choose Javascript.

    6. When prompted for Starter kit, select the default option.

    7. Use your preferred IDE to edit the files in your project directory. Create a new file named utils.js in the src directory with the following contents:

      /*
       * Fauna embeds its own error “codes” (actually string text) in the response body.
       * This function parses out the error codes and translate it back to HTTP error codes.
       */
      export function getFaunaError(response) {
        try {
          const errors = response.errors[0]
          let { code, description, cause } = errors;
          let status;
      
          try {
            // report on the inner errors if they exist
            code = cause[0].code;
            description = cause[0].description;
            if (code == 'transaction aborted') {
              // Inside UDFs use 'transaction aborted' status to bubble up the actual error code in the description.
              code = description;
            }
          } catch {
            // no error causes
          }
      
          switch (code) {
            case 'instance not found':
              status = 404;
              break;
            case 'instance not unique':
              status = 409;
              break;
            case 'permission denied':
              status = 403;
              break;
            case 'unauthorized':
            case 'authentication failed':
              status = 401;
              break;
            default:
              status = 500;
          }
          return { code, description, status };
        } catch {
          // no errors in response
          return false;
        }
      }
      
      export function resolveBackend(request) {
        try {
          const bearerToken = request.headers.get('Authorization').split('Bearer ')[1];
      
          let backend = 'db_fauna_com';
          let backendUrl = 'https://db.fauna.com';
      
          return { backend, backendUrl, bearerToken };
        } catch (e) {
          console.log(`${e}`);
          throw e;
        }
      }
      
      /*
       * Fauna’s UDF needs to distinguish arguments between scalar and object types.
       * Objects must be wrapped with "object".
       * Example: a UDF input argument of type object:
       * {
       *   foo: {
       *     bar: {
       *        key: 'value'
       *     }
       *   }
       * }
       * ...must be formatted for REST call:
       * object: {
       *   foo: {
       *     object: {
       *       bar: {
       *         object: {
       *           key: 'value'
       *         }
       *       }
       *     }
       *   }
       * }
       */
      export function wrapWithObject(obj) {
        let result = {};
        for (const [key, value] of Object.entries(obj)) {
          if (typeof value === 'object') {
            result[key] = {
              object: wrapWithObject(value)
            }
          } else {
            result[key] = value;
          }
        }
        return result;
      }
      
      /*
       * Translates Call(Function('name')) to REST
       */
      export function formatFaunaCallFunction(functionName, id, requestBody) {
        let payload = {
          call: { function: functionName },
          arguments: []
        };
        if (id) {
          payload.arguments.push(id);
        }
        if (requestBody) {
          payload.arguments.push({ object: wrapWithObject(requestBody) });
        }
        return payload;
      }
      
      export function badRequest() {
        return new Response('Bad request', {
          headers: { "access-control-allow-origin": "*" },
          status: 400
        });
      }
    8. Edit the file /src/index.js and replace its contents with the following:

      import {
        badRequest, getFaunaError,
        resolveBackend, formatFaunaCallFunction
      } from './utils.js';
      
      addEventListener('fetch', event => event.respondWith(handleRequest(event)));
      
      async function handleRequest(event) {
        const req = event.request;
      
        const VALID_METHODS = ["GET", "POST", "PUT", "DELETE"];
        if (!VALID_METHODS.includes(req.method)) {
          const response = new Response("This method is not allowed", {
            status: 405
          });
          return response;
        }
      
        const method = req.method;
        const url = new URL(event.request.url);
        const pathname = url.pathname;
        const productId = decodeURI(pathname.split('/')[2]);
      
        // POST /products
        if (method == "POST") {
          try {
            const reqBody = await req.json();
            return await callUDF(req, () => {
              return formatFaunaCallFunction('CreateProduct', null, reqBody);
            });
          } catch {
            return badRequest();
          }
        }
      
        // PUT /products/{id}
        if (method == "PUT") {
          try {
            const reqBody = await req.json();
            return await callUDF(req, () => {
              return formatFaunaCallFunction('UpdateProduct', productId, reqBody);
            });
          } catch {
            return badRequest();
          }
        }
      
        // GET /products/{id}
        if (method == "GET") {
          try {
            return await callUDF(req, () => {
              return formatFaunaCallFunction('GetProduct', productId, null);
            });
          } catch {
            return badRequest();
          }
        }
      
        // DELETE /products/{id}
        if (method == "DELETE") {
          try {
            return await callUDF(req, () => {
              return formatFaunaCallFunction('DeleteProduct', productId, null);
            });
          } catch {
            return badRequest();
          }
        }
      
        return new Response("The page you requested could not be found", {
          status: 404
        });
      };
      
      async function callUDF(request, formatHandler) {
        try {
          const { backend, backendUrl, bearerToken } = resolveBackend(request);
      
          // formatHandler translates REST request into FQL "Call(Function('name'))" equivalent
          const body = formatHandler();
      
          const headers = new Headers({
            "Authorization": `Bearer ${bearerToken}`,
            "Content-Type": "application/json"
          });
      
          const faunaRest = new Request(backendUrl, {
            method: "POST",
            headers: headers,
            body: JSON.stringify(body)
          });
      
          const res = await fetch(faunaRest, { backend: backend });
      
          let response = await res.json();
      
          // If FQL throws an error, return error
          const faunaErrors = getFaunaError(response);
          if (faunaErrors) {
            return new Response(
              faunaErrors.description, {
              headers: {
                "content-type": "application/json;charset=UTF-8"
              },
              status: faunaErrors.status
            });
          } else {
            return new Response(
              JSON.stringify(response.resource), {
              headers: {
                "content-type": "application/json;charset=UTF-8"
              },
              status: 200
            });
          }
        } catch (e) {
          console.log(`${e}`);
          return new Response(`${e}`, { status: 500 });
        }
      }

      The above code implements POST, PUT, GET and DELETE endpoints and a function to call Fauna UDFs, as well as error handling.

  5. Deploy the Fastly service

    1. Set a local environment variable to the value of the Personal API token you created earlier.

      export FASTLY_API_TOKEN=<token>
    2. Locate the service ID for your Fastly service by navigating to the COMPUTE tab in the Fastly dashboard. Each service is listed with its ID.

    3. Edit the file named fastly.toml and update the service_id attribute with the ID for your Fastly service.

    4. Build the project locally with the following command:

      fastly compute build
    5. Deploy the project with the following command:

      fastly compute deploy
    6. When prompted for a domain, accept the default value. Take note of the domain string; you’ll use it to make API requests to the service.

      Once deployed, give it about 30 seconds to fully propagate, and then you can test the live API.

  6. Test the API

    You can make API calls to your service with curl or Postman. The following example commands use curl. Be sure to replace the placeholder values with the correct values for your application.

    curl --location --request POST '<your-domain-string>/products' \
    --header 'Authorization: Bearer <your-productsCRUD-api-key-secret>' \
    --header 'Content-Type: application/json' \
    --data-raw '{
        "serialNumber": 81373,
        "title": "printer",
        "weightLbs": 6,
        "quantity": 50
    }'
    {
      "serialNumber": 82657,
      "quantity": 20,
      "id": "328689539730112705",
      "weightLbs": 10,
      "title": "table"
    }

    If the command completes successfully, you can navigate to the COLLECTIONS page of the Fauna Dashboard and see the newly created document. You can also use the GET endpoint to retrieve the new document:

    curl --location --request GET '<your-domain-string>/products/<product-id>' \
    --header 'Authorization: Bearer <your-productsCRUD-api-key-secret>' \
    {
      "serialNumber": 82657,
      "quantity": 20,
      "id": "328689539730112705",
      "weightLbs": 10,
      "title": "table"
    }

    Calls to the PUT endpoint, which calls the UpdateProduct UDF, look similar to the POST calls, but they update the product document specified by its ID.

    curl --location --request PUT '<your-domain-string>/products/<product-id>' \
    --header 'Authorization: Bearer <your-productsCRUD-api-key-secret>' \
    --header 'Content-Type: application/json' \
    --data-raw '{
        "serialNumber": 81373,
        "title": "printer",
        "weightLbs": 8,
        "quantity": 70
    }'
    {
      "serialNumber": 81373,
      "title": "printer",
      "quantity": 20,
      "id": "328689539730112705",
      "weightLbs": 8,
      "title": "printer"
    }

Is this article helpful? 

Tell Fauna how the article can be improved:
Visit Fauna's forums or email docs@fauna.com

Thank you for your feedback!