Quickstart: Shopping Cart in JavaScript
Learn how to create a shopping cart in JavaScript, package it into a container, and run it on Kalix.
Before you begin
-
If you’re new to Kalix, create an account
so you can try it out for free.
-
Install the Kalix CLI.
-
For this quickstart, you’ll also need
If you want to bypass writing code and jump straight to the deployment, take a look at our prebuilt samples. |
Install Kalix SDK and dependencies
-
From the command line, create a directory for your project.
mkdir shoppingcart
-
Change into the project directory.
cd shoppingcart
-
Run the
npm init
command, accepting default valuesnpm init -y
-
Install the Kalix JavaScript SDK and dependencies:
npm install @kalix-io/kalix-javascript-sdk --save
-
Add these additional scripts to the
scripts
property in yourpackage.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": "kalix-scripts build", "package": "kalix-scripts package", "deploy": "kalix-scripts deploy" }
-
Add these additional configuration options to your
package.json
"config": { "dockerImage": "gcr.io/kalix-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.
-
In your project, create a
proto
directory.- Linux or macOS
-
mkdir -p ./proto
- Windows 10+
-
mkdir proto
-
Create a
shoppingcart_api.proto
file and save it in the proto directory of your project. -
Add declarations for:
-
The protobuf syntax version,
proto3
. -
The package name,
com.example.shoppingcart
. -
Import
google/protobuf/empty.proto
,google/api/annotations.proto
and Kalixkalix/annotations.proto
.syntax = "proto3"; package com.example.shoppingcart; import "google/api/annotations.proto"; import "google/protobuf/empty.proto"; import "kalix/annotations.proto";
-
-
Add the service endpoint
service ShoppingCartService { option (kalix.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 |
-
Add messages to define the fields that comprise a
Cart
object (and its compoundLineItem
)message Cart { repeated LineItem items = 1; } message LineItem { string product_id = 1; string name = 2; int32 quantity = 3; }
-
Add the messages that are the requests to the shopping cart service:
message AddLineItem { string cart_id = 1 [(kalix.field).entity_key = true]; string product_id = 2; string name = 3; int32 quantity = 4; } message RemoveLineItem { string cart_id = 1 [(kalix.field).entity_key = true]; string product_id = 2; } message GetShoppingCart { string cart_id = 1 [(kalix.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.
-
Create a
shoppingcart_domain.proto
file and save it in the proto directory of your project. -
Add declarations for the proto syntax, the Kalix annotations, and package name
syntax = "proto3"; package com.example.shoppingcart.domain; import "kalix/annotations.proto";
-
Add the
Cart
message with fields for entity data and theLineItem
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; }
-
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
-
Build and generate JavaScript sources
npm install npm run build
-
In your project, create a
src
directory.- Linux or macOS
-
mkdir -p ./src
- Windows 10+
-
mkdir src
-
Create a
shoppingcart.js
file and save it in the src directory of your project. -
Add the import statements for the JavaScript SDK to
shoppingcart.js
import { EventSourcedEntity } from "@kalix-io/kalix-javascript-sdk";
-
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 "@kalix-io/kalix-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. * * ShoppingCartService; a strongly typed extension of EventSourcedEntity derived from your proto source * @typedef { import("../lib/generated/shoppingcart").ShoppingCartService } ShoppingCartService * @typedef { import("../lib/generated/shoppingcart").ShoppingCartService.CommandHandlers } CommandHandlers * @typedef { import("../lib/generated/shoppingcart").ShoppingCartService.EventHandlers } EventHandlers */ /** * @type ShoppingCartService */ // tag::esentity[] const entity = new EventSourcedEntity( [ "shoppingcart_domain.proto", "shoppingcart_api.proto" ], "com.example.shoppingcart.ShoppingCartService", "eventsourced-shopping-cart", { includeDirs: ["./proto"] } ); // 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. * * @type CommandHandlers['AddItem'] */ // 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("Quantity for item " + addItem.productId + " must be greater than zero."); } 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. * * @type CommandHandlers['RemoveItem'] */ // 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. * * @type CommandHandlers['GetCart'] */ // tag::getcart[] function getCart(_request, cart) { // Simply return the shopping cart as is. return cart; } // end::getcart[] /** * Handler for item added events. * * @type EventHandlers['ItemAdded'] */ // 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. * * @type EventHandlers['ItemRemoved'] */ // 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[]
-
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");
-
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: []}));
-
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 } }; });
-
-
Add the
addItem
method to handle requests adding items to a shopping cart (this method emits anItemAddedEvent
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("Quantity for item " + addItem.productId + " must be greater than zero."); } 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 {}; } }
-
Add the
removeItem
method to handle requests removing items from a shopping cart (this method emits anItemRemovedEvent
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 {}; } }
-
Add the
getCart
method to handle requests to get a shopping cartfunction getCart(_request, cart) { // Simply return the shopping cart as is. return cart; }
-
Add the
itemAdded
method to handleItemAddedEvent
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; }
-
Add the
itemRemoved
method to handleItemRemovedEvent
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; }
-
At the bottom of the file, add an export statement so the
index.js
file can access your moduleexport default entity;
Create the index.js file
-
Create a
index.js
file and save it in the src directory of your project. -
Add the import statement for the JavaScript SDK to
index.js
import { Kalix } from "@kalix-io/kalix-javascript-sdk"; import generatedComponents from "../lib/generated/index.js";
-
Register and start the components
const server = new Kalix(); 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
-
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"]
-
Run the
docker build
command to build your container imagedocker build . -t <your container registry>/<your registry username>/<your projectname>
When you’re using Docker Hub, you only need to specify |
-
Run the
docker push
command to push the container image to a container registrydocker push <your container registry>/<your registry username>/<your projectname>
-
Run the
kalix services deploy
command to deploy your service using the container image from the container registry.kalix services deploy <name> <your container registry>/<your registry username>/<your projectname>
-
You can verify the status of the deployed service using:
kalix service list
Invoke your service
Once the service has started successfully, you can start a proxy locally to access the service:
kalix service proxy <service name> --grpcui
The --grpcui
option also starts and opens a gRPC web UI for exploring and invoking the service (available at http://127.0.0.1:8080/ui/).
Or you can use command line gRPC or HTTP clients, such as grpcurl
or curl
, to invoke the service through the proxy at localhost:8080
, using plaintext connections.
Items can be added to a shopping cart using the AddItem
method on the ShoppingCart
service, in the gRPC web UI, or with grpcurl
:
grpcurl \ -d '{ "cart_id": "abc123", "product_id": "AAPL", "name": "Apples", "quantity": 42 }' \ --plaintext localhost:8080 \ shopping.cart.api.ShoppingCart/AddItem
The GetCart
method can be used to retrieve this cart, in the gRPC web UI, or with grpcurl
:
grpcurl \ -d '{"cart_id": "abc123"}' \ --plaintext localhost:8080 \ shopping.cart.api.ShoppingCart/GetCart
You can expose the service to the internet. A generated hostname will be returned from the expose command:
kalix service expose <service name>
Next steps
-
You can learn more about Event Sourced Entities.