Implement the shopping cart

Create the Javascript logic that implements the shopping cart service.

1. Create the cart.js file

  1. Create a cart.js file in the shoppingcart directory.

  2. Import the language SDK and the EventSourcedEntity type from the SDK.

    import as from '@lightbend/akkaserverless-javascript-sdk';
    const EventSourcedEntity = as.EventSourcedEntity;
  3. Create the Event Sourced entity:

    const entity = new EventSourcedEntity( (1)
      ['cart.proto', 'domain.proto'], (2)
      'com.example.shoppingcart.CartService', (3)
      'cart',
      {
        snapshotEvery: 100, (4)
        includeDirs: ['./'], (5)
        serializeAllowPrimitives: true, (6)
        serializeFallbackToJson: true (7)
      }
    );
    1 The Event Sourced entity.
    2 An array that connects the implementation to the cart.proto and domain.proto Protobuf files.
    3 The fully qualified name of the service that includes this entity’s interface and the entity type name for all Event Sourced entities of this type.
    4 A snapshot will be persisted every time this many events are emitted.
    5 The directories to include when looking up imported protobuf files.
    6 Whether serialization of primitives should be supported when serializing events and snapshots.
    7 Whether serialization should fallback to using JSON if the state can’t be serialized as a protobuf.

    These parameters are set to values appropriate for this tutorial. For details about the possible options and value assignments see the EventSourcedEntitynew tab options.

  4. Add constants that load the protobuf types needed to serialize the events and state objects when persisted:

    const pkg = 'ecommerce.persistence.';
    const ItemAdded = entity.lookupType(pkg + 'ItemAdded');
    const ItemRemoved = entity.lookupType(pkg + 'ItemRemoved');
    const Cart = entity.lookupType(pkg + 'Cart');
  5. Add the code for the initial state. The Cart.create method comes from the Cart message defined in the cart.proto file. This method creates a new Cart object.

    entity.setInitial(userId => Cart.create({items: []}));
  6. Set a callback to get the current behavior for the current state. This callback is invoked after an event is handled. The commandHandlers point to the remote procedure calls defined in Create proto files.

    entity.setBehavior(cart => {
      return {
        commandHandlers: { (1)
          AddItem: addItem,
          RemoveItem: removeItem,
          GetCart: getCart
        },
        eventHandlers: { (2)
          ItemAdded: itemAdded,
          ItemRemoved: itemRemoved
        }
      };
    });
    1 The command handlers. The name of the command corresponds to the name of the rpc call in the gRPC service that this entity offers.
    2 The event handlers. The name of the event corresponds to the (unqualified) name of the persisted protobuf message.

2. Add functions for the remote procedure calls

  1. Add a function named addItem that handles the AddItem remote procedure call:

    function addItem(addItem, cart, ctx) {
      if (addItem.quantity < 1) {
        ctx.fail('Cannot add negative quantity to item ' + addItem.productId);
      } else {
        const itemAdded = ItemAdded.create({
          item: {
            productId: addItem.productId,
            name: addItem.name,
            quantity: addItem.quantity
          }
        });
        ctx.emit(itemAdded);
        return {};
      }
    }
    This example function takes addItem, cart, and ctx as parameters. The addItem parameter contains the data that is passed in from the remote procedure call.
  2. Add a function removeItem that handles the RemoveItem remote procedure call:

    function removeItem(removeItem, cart, ctx) {
      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 {};
      }
    }
  3. Add a function named getCart that handles the GetCart remote procedure call:

    function getCart(request, cart) {
      return cart;
    }
  4. Add a function named itemAdded that handles the ItemAdded event:

    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;
    }
  5. Add a function named itemRemoved that handles the ItemRemoved event:

    function itemRemoved(removed, cart) {
      /**
       * Filter the removed item from the items by product id and return the new state
       */
    
      cart.items = cart.items.filter(item => {
        return item.productId !== removed.productId;
      });
    
      return cart;
    }
  6. Add an export at the end of the file to export the entity object:

    export default entity;

3. Implement the entry point

An entry point tells the Node engine where to find the Entity implementation. Use index.js as the entry point to start the shopping cart:

  1. Create a index.js file in the shoppingcart directory.

  2. The first two lines import the language SDK and the AkkaServerless type from the SDK.

    import as from '@lightbend/akkaserverless-javascript-sdk';
    import entity from './cart.js';
  3. Create a new Akka Serverless object and bind it to the entity from cart.js. The service listens on port 8080 and binds to all interfaces. This makes it possible to run your service locally and deploy it to the Akka Serverless cloud platform without any changes.

    const server = new as.AkkaServerless();
    server.addComponent(entity);
    server.start({ bindAddress: '0.0.0.0', bindPort: '8080' });

The complete cart.js file

The completed cart.js example:

/**
 * 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.
 */

/**
 * This service uses the EventSourced state model in Akka Serverless.
 */
import as from '@lightbend/akkaserverless-javascript-sdk';
const EventSourcedEntity = as.EventSourcedEntity;

/**
 * Create a new EventSourced entity with parameters
 * * An array of protobuf files where the entity can find message definitions
 * * The fully qualified name of the service that provides this entities interface
 * * The entity type name for all event source entities of this type. This will be prefixed
 *   onto the entityId when storing the events for this entity.
 */
const entity = new EventSourcedEntity(
  ['cart.proto', 'domain.proto'],
  'com.example.shoppingcart.CartService',
  'cart',
  {
    // A snapshot will be persisted every time this many events are emitted.
    snapshotEvery: 100,

    // The directories to include when looking up imported protobuf files.
    includeDirs: ['./'],

    // Whether serialization of primitives should be supported when serializing events
    // and snapshots.
    serializeAllowPrimitives: true,

    // Whether serialization should fallback to using JSON if the state can't be serialized
    // as a protobuf.
    serializeFallbackToJson: true
  }
);

/**
 * The events and state that are stored in Akka Serverless are in Protobuf format. To make it
 * easier to work with, you can load the protobuf types (as happens in the below code). The
 * Protobuf types are needed so that Akka Serverless knowns how to serialize these objects when
 * they are persisted.
 */
const pkg = 'ecommerce.persistence.';
const ItemAdded = entity.lookupType(pkg + 'ItemAdded');
const ItemRemoved = entity.lookupType(pkg + 'ItemRemoved');
const Cart = entity.lookupType(pkg + 'Cart');


/**
 * Set a callback to create the initial state. This is what is created if there is no snapshot
 * to load, in other words when the entity is created and nothing else exists for it yet. The
 * method Cart.create comes from the above Protobuf definition and created a new Cart object
 * to store. The definition of the object itself is stored in domain.proto
 *
 * The userId parameter can be ignored, it's the id of the entity which is automatically
 * associated with all events and state for this entity.
 */
entity.setInitial(userId => Cart.create({items: []}));

/**
 * Set a callback to create the behavior given the current state. Since there is no state
 * machine like behavior transitions for this entity, we just return one behavior, but
 * you could return multiple different behaviors depending on the state.
 *
 * This callback will be invoked after each time that an event is handled to get the current
 * behavior for the current state.
 */
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
    }
  };
});

/**
 * The commandHandlers respond to requests coming in from the gRPC gateway.
 * They are responsible to make sure events are created that can be handled
 * to update the actual status of the entity.
**/

/**
 * addItem is the entry point for the API to add a new item to the cart and emits an
 * ItemAdded event to modify the state.
 * @param {*} addItem the item to add to the cart
 * @param {*} cart the cart to add the item to
 * @param {*} ctx the Akka Serverless context object
 * @returns
 */
function addItem(addItem, cart, ctx) {
  /**
   * Perform some validation so you cannot add negative quantities. If the
   * validation is successful, a new ItemAdded event is created and emitted
   * using the ctx object.
   */
  if (addItem.quantity < 1) {
    ctx.fail('Cannot add negative quantity to item ' + addItem.productId);
  } else {
    const itemAdded = ItemAdded.create({
      item: {
        productId: addItem.productId,
        name: addItem.name,
        quantity: addItem.quantity
      }
    });
    ctx.emit(itemAdded);
    return {};
  }
}

/**
 * removeItem is the entry point for the API to remove an item from the cart and emits an
 * ItemRemoved event to modify the state.
 * @param {*} addItem the item to remove from the cart
 * @param {*} cart the cart to remove the item from
 * @param {*} ctx the Akka Serverless context object
 * @returns
 */
function removeItem(removeItem, cart, ctx) {
  /**
   * Perform some validation so you cannot remove items that aren't in your cart.
   * If the validation is successful, a new ItemRemoved event is created and emitted
   * using the ctx object.
   */
  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 {};
  }
}

/**
 * getCart is the entry point for the API to get a cart for a specific user
 * @param {*} request the request from the API
 * @param {*} cart the cart that was requested
 * @returns
 */
function getCart(request, cart) {
  /**
   * This simply returns the cart without making any modifications
   */
  return cart;
}

/**
 * The eventHandlers respond to events emitted by the commandHandlers and manipulate
 * the actual state of the entities. The return items of these eventHandlers contain
 * the new state that subsequent messages will act on.
**/

/**
 * itemAdded reacts to the ItemAdded events emitted and adds the item to the state of the cart
 * @param {*} added the item to be added
 * @param {*} cart the current cart
 * @returns
 */
function itemAdded(added, cart) {
  /**
   * If there is an existing item with that product id, we need to increment its quantity.
   * Otherwise, we just add the item to the existing list, and return the new state
   */

  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;
}

/**
 * itemRemoved reacts to the ItemRemoved events emitted and removes the item from the state of the cart
 * @param {*} added the item to be added
 * @param {*} cart the current cart
 * @returns
 */
function itemRemoved(removed, cart) {
  /**
   * Filter the removed item from the items by product id and return the new state
   */

  cart.items = cart.items.filter(item => {
    return item.productId !== removed.productId;
  });

  return cart;
}

/**
 * Export the entity so it can be imported by index.js
 */
export default entity;

The complete index.js file

import as from '@lightbend/akkaserverless-javascript-sdk';
import entity from './cart.js';
const server = new as.AkkaServerless();
server.addComponent(entity);
server.start({ bindAddress: '0.0.0.0', bindPort: '8080' });