Quickstart: Shopping Cart in JavaScript

Learn how to create a shopping cart in JavaScript, package it into a container, and run it on Akka Serverless.

Before you begin

Create your project

  1. From the command line, create a directory for your project.

    mkdir shoppingcart
  2. Change into the project directory.

    cd shoppingcart
  3. Run the npm init command, accepting default values

    npm init -y
  4. Install the Akka Serverless JavaScript SDK and dependencies:

    npm install @lightbend/akkaserverless-javascript-sdk --save
  5. Add these additional scripts to the scripts property in your package.json

"scripts": {
    "start": "node src/index.js",
    "test": "mocha ./test",
    "integration-test": "mocha ./integration-test",
    "test-all": "npm run test && npm run integration-test",
    "build": "akkasls-scripts build",
    "package": "akkasls-scripts package",
    "deploy": "akkasls-scripts deploy"
}
  1. Add these additional configuration options to your package.json

"config": {
    "dockerImage": "gcr.io/akkaserverless-public/samples-js-event-sourced-shopping-cart",
    "sourceDir": "./src",
    "testSourceDir": "./test",
    "protoSourceDir": "./proto",
    "generatedSourceDir": "./lib/generated",
    "compileDescriptorArgs": []
}

Define the external API

The Shopping Cart service will store shopping carts for your customers, including the items in those carts. The shoppingcart_api.proto will contain the external API your clients will invoke.

  1. In your project, create a proto directory.

    Linux or macOS
    mkdir -p ./proto
    Windows 10+
    mkdir proto
  2. Create a shoppingcart_api.proto file and save it in the proto directory of your project.

  3. Add declarations for:

    • The protobuf syntax version, proto3.

    • The package name, com.example.shoppingcart.

    • Import google/protobuf/empty.proto, google/api/annotations.proto and Akka Serverless akkaserverless/annotations.proto.

      syntax = "proto3";
      
      package com.example.shoppingcart;
      
      import "google/api/annotations.proto";
      import "google/protobuf/empty.proto";
      import "akkaserverless/annotations.proto";
  4. Add the service endpoint

    service ShoppingCartService {
      option (akkaserverless.service) = {
        type: SERVICE_TYPE_ENTITY
        component: ".domain.ShoppingCart"
      };
    
      rpc AddItem(AddLineItem) returns (google.protobuf.Empty) {
        option (google.api.http) = {
          post: "/cart/{cart_id}/items/add"
          body: "*"
        };
      }
    
      rpc RemoveItem(RemoveLineItem) returns (google.protobuf.Empty) {
        option (google.api.http) = {
          post: "/cart/{cart_id}/items/{product_id}/remove"
        };
      }
    
      rpc GetCart(GetShoppingCart) returns (Cart) {
        option (google.api.http) = {
          get: "/carts/{cart_id}"
        };
      }
    }

Each of the API endpoints will be available over HTTP and gRPC

  1. Add messages to define the fields that comprise a Cart object (and its compound LineItem)

    message Cart {
      repeated LineItem items = 1;
    }
    
    message LineItem {
      string product_id = 1;
      string name = 2;
      int32 quantity = 3;
    }
  2. Add the messages that are the requests to the shopping cart service:

    message AddLineItem {
      string cart_id = 1 [(akkaserverless.field).entity_key = true];
      string product_id = 2;
      string name = 3;
      int32 quantity = 4;
    }
    
    message RemoveLineItem {
      string cart_id = 1 [(akkaserverless.field).entity_key = true];
      string product_id = 2;
    }
    
    message GetShoppingCart {
      string cart_id = 1 [(akkaserverless.field).entity_key = true];
    }

Define the domain model

The shoppingcart_domain.proto contains all the internal data objects (Entities). The Event Sourced Entity in this quickstart keeps all events sent for a specific shopping cart in a journal.

  1. Create a shoppingcart_domain.proto file and save it in the proto directory of your project.

  2. Add declarations for the proto syntax, the Akka Serverless annotations, and package name

    syntax = "proto3";
    
    package com.example.shoppingcart.domain;
    
    import "akkaserverless/annotations.proto";
  3. Add the Cart message with fields for entity data and the LineItem message that defines the compound line item:

    // The shopping cart state.
    message Cart {
      repeated LineItem items = 1;
    }
    
    message LineItem {
      string productId = 1;
      string name = 2;
      int32 quantity = 3;
    }
  4. Event Sourced entities work based on events. Add the events that can occur in this quickstart:

    // The item added event.
    message ItemAdded {
      LineItem item = 1;
    }
    
    // The item removed event.
    message ItemRemoved {
      string productId = 1;
    }

Implement your business logic

  1. Build and generate JavaScript sources

    npm install
    npm run build
  2. In your project, create a src directory.

    Linux or macOS
    mkdir -p ./src
    Windows 10+
    mkdir src
  3. Create a shoppingcart.js file and save it in the src directory of your project.

  4. Add the import statements for the JavaScript SDK to shoppingcart.js

    import {EventSourcedEntity} from "@lightbend/akkaserverless-javascript-sdk";
  5. Create the EventSourcedEntity object

    /*
     * Copyright 2021 Lightbend Inc.
     *
     * Licensed under the Apache License, Version 2.0 (the "License");
     * you may not use this file except in compliance with the License.
     * You may obtain a copy of the License at
     *
     *     http://www.apache.org/licenses/LICENSE-2.0
     *
     * Unless required by applicable law or agreed to in writing, software
     * distributed under the License is distributed on an "AS IS" BASIS,
     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     * See the License for the specific language governing permissions and
     * limitations under the License.
     */
    // tag::imports[]
    import {EventSourcedEntity} from "@lightbend/akkaserverless-javascript-sdk";
    // end::imports[]
    /**
     * Type definitions.
     * These types have been generated based on your proto source.
     * A TypeScript aware editor such as VS Code will be able to leverage them to provide hinting and validation.
     *
     * State; the serialisable and persistable state of the entity
     * @typedef { import("../lib/generated/shoppingcartservice").State } State
     *
     * Event; the union of all possible event types
     * @typedef { import("../lib/generated/shoppingcartservice").Event } Event
     *
     * ShoppingCartService; a strongly typed extension of EventSourcedEntity derived from your proto source
     * @typedef { import("../lib/generated/shoppingcartservice").ShoppingCartService } ShoppingCartService
     */
    
    /**
     * @type ShoppingCartService
     */
    // tag::esentity[]
    const entity = new EventSourcedEntity(
      [
        "shoppingcart_domain.proto",
        "shoppingcart_api.proto"
      ],
      "com.example.shoppingcart.ShoppingCartService",
      "eventsourced-shopping-cart",
      {
        includeDirs: ["./proto"],
        serializeFallbackToJson: true
      }
    );
    // end::esentity[]
    /*
     * Here we load the Protobuf types. When emitting events or setting state, we need to return
     * protobuf message objects, not just ordinary JavaScript objects, so that the framework can
     * know how to serialize these objects when they are persisted.
     *
     * Note this shows loading them dynamically, they could also be compiled and statically loaded.
     */
    // tag::espersistence[]
    const pkg = "com.example.shoppingcart.domain.";
    const ItemAdded = entity.lookupType(pkg + "ItemAdded");
    const ItemRemoved = entity.lookupType(pkg + "ItemRemoved");
    const Cart = entity.lookupType(pkg + "Cart");
    // end::espersistence[]
    /*
     * Set a callback to create the initial state. This is what is created if there is no
     * snapshot to load.
     *
     * We can ignore the cartId parameter if we want, it's the id of the entity, which is
     * automatically associated with all events and state for this entity.
     */
    // tag::initialstate[]
    entity.setInitial(cartId => Cart.create({items: []}));
    // end::initialstate[]
    // tag::behavior[]
    entity.setBehavior(cart => {
      return {
        // Command handlers. The name of the command corresponds to the name of the rpc call in
        // the gRPC service that this entity offers.
        commandHandlers: {
          AddItem: addItem,
          RemoveItem: removeItem,
          GetCart: getCart
        },
        // Event handlers. The name of the event corresponds to the (unqualified) name of the
        // persisted protobuf message.
        eventHandlers: {
          ItemAdded: itemAdded,
          ItemRemoved: itemRemoved
        }
      };
    });
    // end::behavior[]
    
    
    /**
     * Handler for add item commands.
     */
    // tag::additem[]
    function addItem(addItem, cart, ctx) {
      // Validation:
      // Make sure that it is not possible to add negative quantities
      if (addItem.quantity < 1) {
        ctx.fail("Cannot add negative quantity to item " + addItem.productId);
      } else {
        // Create the event.
        const itemAdded = ItemAdded.create({
          item: {
            productId: addItem.productId,
            name: addItem.name,
            quantity: addItem.quantity
          }
        });
        // Emit the event.
        ctx.emit(itemAdded);
        return {};
      }
    }
    // end::additem[]
    /**
     * Handler for remove item commands.
     */
    // tag::removeitem[]
    function removeItem(removeItem, cart, ctx) {
      // Validation:
      // Check that the item that we're removing actually exists.
      const existing = cart.items.find(item => {
        return item.productId === removeItem.productId;
      });
    
      // If not, fail the command.
      if (!existing) {
        ctx.fail("Item " + removeItem.productId + " not in cart");
      } else {
        // Otherwise, emit an item removed event.
        const itemRemoved = ItemRemoved.create({
          productId: removeItem.productId
        });
        ctx.emit(itemRemoved);
        return {};
      }
    }
    // end::removeitem[]
    /**
     * Handler for get cart commands.
     */
    // tag::getcart[]
    function getCart(request, cart) {
      // Simply return the shopping cart as is.
      return cart;
    }
    // end::getcart[]
    /**
     * Handler for item added events.
     */
    // tag::itemadded[]
    function itemAdded(added, cart) {
      // If there is an existing item with that product id, we need to increment its quantity.
      const existing = cart.items.find(item => {
        return item.productId === added.item.productId;
      });
    
      if (existing) {
        existing.quantity = existing.quantity + added.item.quantity;
      } else {
        // Otherwise, we just add the item to the existing list.
        cart.items.push(added.item);
      }
    
      // And return the new state.
      return cart;
    }
    // end::itemadded[]
    /**
     * Handler for item removed events.
     */
    // tag::itemremoved[]
    function itemRemoved(removed, cart) {
      // Filter the removed item from the items by product id.
      cart.items = cart.items.filter(item => {
        return item.productId !== removed.productId;
      });
    
      // And return the new state.
      return cart;
    }
    // end::itemremoved[]
    
    // tag::export[]
    export default entity;
    // end::export[]
  6. Create objects for the internal and external representations of your carts

    const pkg = "com.example.shoppingcart.domain.";
    const ItemAdded = entity.lookupType(pkg + "ItemAdded");
    const ItemRemoved = entity.lookupType(pkg + "ItemRemoved");
    const Cart = entity.lookupType(pkg + "Cart");
  7. Create the "initial state" for the entities (this method is called when no other data can be found for your entity)

    entity.setInitial(cartId => Cart.create({items: []}));
  8. Create the behavior for your shopping cart, which consists of Command Handlers and Event Handlers.

    • Command Handlers, as the name suggests, handle incoming requests before persisting them as events.

    • Event Handlers, react to persisted events and modify the state of your cart.

      entity.setBehavior(cart => {
        return {
          // Command handlers. The name of the command corresponds to the name of the rpc call in
          // the gRPC service that this entity offers.
          commandHandlers: {
            AddItem: addItem,
            RemoveItem: removeItem,
            GetCart: getCart
          },
          // Event handlers. The name of the event corresponds to the (unqualified) name of the
          // persisted protobuf message.
          eventHandlers: {
            ItemAdded: itemAdded,
            ItemRemoved: itemRemoved
          }
        };
      });
  9. Add the addItem method to handle requests adding items to a shopping cart (this method emits an ItemAddedEvent event to modify the state)

    function addItem(addItem, cart, ctx) {
      // Validation:
      // Make sure that it is not possible to add negative quantities
      if (addItem.quantity < 1) {
        ctx.fail("Cannot add negative quantity to item " + addItem.productId);
      } else {
        // Create the event.
        const itemAdded = ItemAdded.create({
          item: {
            productId: addItem.productId,
            name: addItem.name,
            quantity: addItem.quantity
          }
        });
        // Emit the event.
        ctx.emit(itemAdded);
        return {};
      }
    }
  10. Add the removeItem method to handle requests removing items from a shopping cart (this method emits an ItemRemovedEvent event to modify the state)

    function removeItem(removeItem, cart, ctx) {
      // Validation:
      // Check that the item that we're removing actually exists.
      const existing = cart.items.find(item => {
        return item.productId === removeItem.productId;
      });
    
      // If not, fail the command.
      if (!existing) {
        ctx.fail("Item " + removeItem.productId + " not in cart");
      } else {
        // Otherwise, emit an item removed event.
        const itemRemoved = ItemRemoved.create({
          productId: removeItem.productId
        });
        ctx.emit(itemRemoved);
        return {};
      }
    }
  11. Add the getCart method to handle requests to get a shopping cart

    function getCart(request, cart) {
      // Simply return the shopping cart as is.
      return cart;
    }
  12. Add the itemAdded method to handle ItemAddedEvent events (this modifies the state of the cart)

    function itemAdded(added, cart) {
      // If there is an existing item with that product id, we need to increment its quantity.
      const existing = cart.items.find(item => {
        return item.productId === added.item.productId;
      });
    
      if (existing) {
        existing.quantity = existing.quantity + added.item.quantity;
      } else {
        // Otherwise, we just add the item to the existing list.
        cart.items.push(added.item);
      }
    
      // And return the new state.
      return cart;
    }
  13. Add the itemRemoved method to handle ItemRemovedEvent events (this modifies the state of the cart)

    function itemRemoved(removed, cart) {
      // Filter the removed item from the items by product id.
      cart.items = cart.items.filter(item => {
        return item.productId !== removed.productId;
      });
    
      // And return the new state.
      return cart;
    }
  14. At the bottom of the file, add an export statement so the index.js file can access your module

    export default entity;

Create the index.js file

  1. Create a index.js file and save it in the src directory of your project.

  2. Add the import statement for the JavaScript SDK to index.js

    import { AkkaServerless } from "@lightbend/akkaserverless-javascript-sdk";
    import generatedComponents from "../lib/generated/index.js";
  3. Register and start the components

    const server = new AkkaServerless();
    
    generatedComponents.forEach((component) => {
      server.addComponent(component);
    });
    
    server.start();

Package and deploy your service

To compile, build the container image, and publish it to your container registry, follow these steps

  1. Create a dockerfile in the root directory of your project

    # This Dockerfile uses multi-stage build process.
    # See https://docs.docker.com/develop/develop-images/multistage-build/
    
    # Stage 1: Downloading dependencies and building the application
    FROM node:14.17.0-buster-slim AS builder
    
    RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
    
    # Set the working directory
    WORKDIR /home/node
    
    # Install app dependencies
    COPY package*.json ./
    RUN npm ci
    
    # Copy sources and build the app
    COPY --chown=node . .
    RUN npm run build
    
    # Remove dev packages
    # (the rest will be copied to the production image at stage 2)
    RUN npm prune --production
    
    # Stage 2: Building the production image
    FROM node:14.17.0-buster-slim
    
    # Set the working directory
    WORKDIR /home/node
    
    # Copy dependencies
    COPY --from=builder --chown=node /home/node/node_modules node_modules/
    
    # Copy the app
    COPY --from=builder --chown=node \
        /home/node/package*.json \
        /home/node/user-function.desc \
        ./
    COPY --from=builder --chown=node /home/node/proto ./proto
    COPY --from=builder --chown=node /home/node/src ./src
    COPY --from=builder --chown=node /home/node/lib ./lib
    
    # Run the app as an unprivileged user for extra security.
    USER node
    
    # Run
    EXPOSE 8080
    CMD ["npm", "start"]
  2. Run the docker build command to build your container image

    docker build . -t <your container registry>/<your registry username>/<your projectname>

When you’re using Docker Hub, you only need to specify <your registry username>/<your projectname> (like myuser/myproject)

  1. Run the docker push command to push the container image to a container registry

    docker push <your container registry>/<your registry username>/<your projectname>
  2. Sign in to your Akka Serverless account at: https://console.akkaserverless.lightbend.com/

  3. If you do not have a project, click Add Project to create one otherwise choose the project you want to deploy your service to.

  4. On the project dashboard click the "+" next to services to start the deployment wizard

  5. Choose a name for your service and click Next

  6. Enter the container image URL from the above step and click Next

  7. Click Next (no environment variables are needed for these samples)

  8. Check both Add a route to this service and Enable CORS and click Next

  9. Click Finish to start the deployment

  10. Click Go to Service to see your newly deployed service

Invoke your service

Now that you have deployed your service, the next step is to invoke it using cURL

  1. From the "Service Explorer" click on the method you want to invoke

  2. Click on "cURL"

  3. In the bottom section of the dialog, fill in the values you want to send to your service

  4. In the top section of the dialog, click the "Copy to clipboard" button

  5. Open a new command line and paste the content you just copied