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": {
    "build": "compile-descriptor shoppingcart_api.proto shoppingcart_domain.proto",
    "pretest": "npm run build",
    "test": "mocha",
    "start": "node index.js",
}

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. 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";
      
      import "akkaserverless/annotations.proto";
      import "google/api/annotations.proto";
      import "google/protobuf/empty.proto";
      
      package com.example.shoppingcart;
  2. Add the service endpoint

    service ShoppingCart {
        rpc AddItem(AddLineItem) returns (google.protobuf.Empty) {
            option (google.api.http) = {
                post: "/cart/{user_id}/items/add"
                body: "*"
            };
        }
    
        rpc RemoveItem(RemoveLineItem) returns (google.protobuf.Empty) {
            option (google.api.http) = {
                post: "/cart/{user_id}/items/{product_id}/remove";
            };
        }
    
        rpc GetCart(GetShoppingCart) returns (Cart) {
            option (google.api.http) = {
                get: "/carts/{user_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 user_id = 1 [(akkaserverless.field).entity_key = true];
        string product_id = 2;
        string name = 3;
        int32 quantity = 4;
    }
    
    message RemoveLineItem {
        string user_id = 1 [(akkaserverless.field).entity_key = true];
        string product_id = 2;
    }
    
    message GetShoppingCart {
        string user_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 root directory of your project.

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

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

    message CartState {
        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:

    message ItemAdded {
        LineItem item = 1;
    }
    
    message ItemRemoved {
        string productId = 1;
    }

Implement your business logic

  1. Create a cart.js file and save it in the root directory of your project.

  2. Add the import statements for the JavaScript SDK to cart.js

    const EventSourcedEntity = require("@lightbend/akkaserverless-javascript-sdk").EventSourcedEntity;
    const { replies } = require("@lightbend/akkaserverless-javascript-sdk");
  3. Create the EventSourcedEntity object

    const entity = new EventSourcedEntity(
        ['shoppingcart_api.proto', 'shoppingcart_domain.proto'],
        'com.example.shoppingcart.ShoppingCartService',
        'cart'
    );
  4. Create objects for the internal and external representations of your carts

    const domainPkg = 'com.example.shoppingcart.domain.';
    const domain = {
        ItemAddedEvent: entity.lookupType(domainPkg + 'ItemAdded'),
        ItemRemovedEvent: entity.lookupType(domainPkg + 'ItemRemoved'),
        CartState: entity.lookupType(domainPkg + 'Cart')
    }
    
    const apiPkg = 'com.example.shoppingcart.';
    const api = {
        Cart: entity.lookupType(apiPkg + 'Cart')
    }
  5. Create the "initial state" for the entities (this method is called when no other data can be found for your entity)

    entity.setInitial(userId => Cart.create({ items: [] }));
  6. 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 {
              commandHandlers: {
                  AddItem: addItem,
                  RemoveItem: removeItem,
                  GetCart: getCart
              },
              eventHandlers: {
                  ItemAdded: itemAdded,
                  ItemRemoved: itemRemoved
              }
          };
      });
  7. 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) {
        if (addItem.quantity < 1) {
            ctx.fail('Cannot add negative quantity to item ' + addItem.productId);
        } else {
            ctx.emit(domain.ItemAddedEvent.create({
                item: {
                    productId: addItem.productId,
                    name: addItem.name,
                    quantity: addItem.quantity
                }
            }));
            return replies.noReply();
        }
    }
  8. 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) {
        const existing = cart.items.find(item => {
            return item.productId === removeItem.productId;
        });
        if (!existing) {
            ctx.fail('Item ' + removeItem.productId + ' not in cart');
        } else {
            ctx.emit(domain.ItemRemovedEvent.create({
                productId: removeItem.productId
            }));
            return replies.noReply();
        }
    }
  9. Add the getCart method to handle requests to get a shopping cart

    function getCart(request, cart) {
        let apiCart = cartStateToCartApi(cart);
        return replies.message(apiCart);
    }
  10. Add the itemAdded method to handle ItemAddedEvent events (this modifies the state of the cart)

    function itemAdded(added, cart) {
        const existing = cart.items.find(item => {
            return item.productId === added.item.productId;
        });
    
        if (existing) {
            existing.quantity = existing.quantity + added.item.quantity;
        } else {
            cart.items.push(added.item);
        }
    
        return cart;
    }
  11. Add the itemRemoved method to handle ItemRemovedEvent events (this modifies the state of the cart)

    function itemRemoved(removed, cart) {
        cart.items = cart.items.filter(item => {
            return item.productId !== removed.productId;
        });
    
        return cart;
    }
  12. At the bottom of the file, add a method cartStateToCartApi to convert the state of the cart to a response message for your external API and add an export statement so the index.js file can access your module

    function cartStateToCartApi(cartState) {
        // right now these two have the same fields so conversion is easy
        return api.Cart.create(cartState)
    }
    
    module.exports = entity;

Create the index.js file

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

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

    const AkkaServerless = require("@lightbend/akkaserverless-javascript-sdk").AkkaServerless
  3. Register and start the components

    console.log("Starting Event Sourced Entity")
    const server = new AkkaServerless();
    server.addComponent(require("./cart"));
    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
    
    # Set the working directory
    WORKDIR /home/node
    
    # Install app dependencies
    COPY package*.json ./
    RUN npm ci
    
    # Copy sources and build the app
    COPY . .
    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/*.js \
        /home/node/*.proto \
        /home/node/user-function.desc \
        ./
    
    # 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.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