diff --git a/.circleci/config.yml b/.circleci/config.yml index 8ed64dce..318641ad 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -242,6 +242,21 @@ jobs: npm install npm run build npm test + - run: + name: "Test quickstart: JS Shopping Cart" + working_directory: "~/tmp" + command: | + # use the bundled quickstart to check against what the user will download + unzip -d js-shopping-cart ~/project/docs/build/src/managed/modules/javascript/attachments/js-shopping-cart-quickstart.zip + cd js-shopping-cart + export KALIX_NPMJS_CODEGEN_BINARY="${HOME}/project/codegen/js-gen-cli/target/native-image/kalix-codegen-js" + npm install --save \ + "$HOME/project/sdk/kalix-io-kalix-javascript-sdk-0.0.0.tgz" \ + "$HOME/project/npm-js/kalix-scripts/kalix-io-kalix-scripts-1.0.0.tgz" \ + "$HOME/project/testkit/kalix-io-testkit-0.0.0.tgz" + npm install + npm run build + npm test - run: name: "Test quickstart: TS Customer Registry" working_directory: "~/tmp" @@ -257,6 +272,21 @@ jobs: npm install npm run build npm test + - run: + name: "Test quickstart: TS Shopping Cart" + working_directory: "~/tmp" + command: | + # use the bundled quickstart to check against what the user will download + unzip -d ts-shopping-cart ~/project/docs/build/src/managed/modules/javascript/attachments/ts-shopping-cart-quickstart.zip + cd ts-shopping-cart + export KALIX_NPMJS_CODEGEN_BINARY="${HOME}/project/codegen/js-gen-cli/target/native-image/kalix-codegen-js" + npm install --save \ + "$HOME/project/sdk/kalix-io-kalix-javascript-sdk-0.0.0.tgz" \ + "$HOME/project/npm-js/kalix-scripts/kalix-io-kalix-scripts-1.0.0.tgz" \ + "$HOME/project/testkit/kalix-io-testkit-0.0.0.tgz" + npm install + npm run build + npm test - run: name: "Test JS Value Entity Counter sample" command: | @@ -401,6 +431,24 @@ jobs: npm install npm run build npm run integration-test + - run: + name: "integration tests: js-shopping-cart-quickstart" + working_directory: "~/tmp" + command: | + export VERSION_CHECK_ON_STARTUP=false + export KALIX_NPMJS_CODEGEN_BINARY="${HOME}/project/codegen/js-gen-cli/target/native-image/kalix-codegen-js" + source /opt/circleci/.nvm/nvm.sh + pushd ~/project/sdk && nvm install && popd + # use the bundled quickstart to check against what the user will download + unzip -d js-shopping-cart ~/project/docs/build/src/managed/modules/javascript/attachments/js-shopping-cart-quickstart.zip + cd js-shopping-cart + npm install --save \ + "$HOME/project/sdk/kalix-io-kalix-javascript-sdk-0.0.0.tgz" \ + "$HOME/project/npm-js/kalix-scripts/kalix-io-kalix-scripts-1.0.0.tgz" \ + "$HOME/project/testkit/kalix-io-testkit-0.0.0.tgz" + npm install + npm run build + npm run integration-test - run: name: "integration tests: ts-customer-registry-quickstart" working_directory: "~/tmp" @@ -419,6 +467,24 @@ jobs: npm install npm run build npm run integration-test + - run: + name: "integration tests: ts-shopping-cart-quickstart" + working_directory: "~/tmp" + command: | + export VERSION_CHECK_ON_STARTUP=false + export KALIX_NPMJS_CODEGEN_BINARY="${HOME}/project/codegen/js-gen-cli/target/native-image/kalix-codegen-js" + source /opt/circleci/.nvm/nvm.sh + pushd ~/project/sdk && nvm install && popd + # use the bundled quickstart to check against what the user will download + unzip -d ts-shopping-cart ~/project/docs/build/src/managed/modules/javascript/attachments/ts-shopping-cart-quickstart.zip + cd ts-shopping-cart + npm install --save \ + "$HOME/project/sdk/kalix-io-kalix-javascript-sdk-0.0.0.tgz" \ + "$HOME/project/npm-js/kalix-scripts/kalix-io-kalix-scripts-1.0.0.tgz" \ + "$HOME/project/testkit/kalix-io-testkit-0.0.0.tgz" + npm install + npm run build + npm run integration-test - run: name: "integration tests: samples/js/js-customer-registry" command: | diff --git a/docs/Makefile b/docs/Makefile index db1c37ee..ee80ddf3 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -46,12 +46,14 @@ examples: rsync -a --exclude-from=.examplesignore ../samples/js/js-doc-snippets/ "${managed_examples}/js-doc-snippets/" rsync -a --exclude-from=.examplesignore ../samples/js/js-customer-registry/ "${managed_examples}/js-customer-registry/" rsync -a --exclude-from=.examplesignore ../samples/js/js-customer-registry-quickstart/ "${managed_examples}/js-customer-registry-quickstart/" + rsync -a --exclude-from=.examplesignore ../samples/js/js-shopping-cart-quickstart/ "${managed_examples}/js-shopping-cart-quickstart/" rsync -a --exclude-from=.examplesignore ../samples/js/js-valueentity-shopping-cart/ "${managed_examples}/js-valueentity-shopping-cart/" rsync -a --exclude-from=.examplesignore ../samples/js/valueentity-counter/ "${managed_examples}/valueentity-counter/" rsync -a --exclude-from=.examplesignore ../samples/js/js-eventsourced-shopping-cart/ "${managed_examples}/js-eventsourced-shopping-cart/" rsync -a --exclude-from=.examplesignore ../samples/js/js-replicated-entity-shopping-cart/ "${managed_examples}/js-replicated-entity-shopping-cart/" rsync -a --exclude-from=.examplesignore ../samples/ts/ts-customer-registry/ "${managed_examples}/ts-customer-registry/" rsync -a --exclude-from=.examplesignore ../samples/ts/ts-customer-registry-quickstart/ "${managed_examples}/ts-customer-registry-quickstart/" + rsync -a --exclude-from=.examplesignore ../samples/ts/ts-shopping-cart-quickstart/ "${managed_examples}/ts-shopping-cart-quickstart/" rsync -a --exclude-from=.examplesignore ../samples/ts/ts-valueentity-shopping-cart/ "${managed_examples}/ts-valueentity-shopping-cart/" rsync -a --exclude-from=.examplesignore ../samples/ts/ts-valueentity-counter/ "${managed_examples}/ts-valueentity-counter/" rsync -a --exclude-from=.examplesignore ../samples/ts/ts-eventsourced-shopping-cart/ "${managed_examples}/ts-eventsourced-shopping-cart/" @@ -60,8 +62,8 @@ examples: bundles: bin/bundle.sh --zip "${managed_attachments}/js-customer-registry-quickstart.zip" ../samples/js/js-customer-registry-quickstart bin/bundle.sh --zip "${managed_attachments}/ts-customer-registry-quickstart.zip" ../samples/ts/ts-customer-registry-quickstart - bin/bundle.sh --zip "${managed_attachments}/js-eventsourced-shopping-cart.zip" ../samples/js/js-eventsourced-shopping-cart - bin/bundle.sh --zip "${managed_attachments}/ts-eventsourced-shopping-cart.zip" ../samples/ts/ts-eventsourced-shopping-cart + bin/bundle.sh --zip "${managed_attachments}/js-shopping-cart-quickstart.zip" ../samples/js/js-shopping-cart-quickstart + bin/bundle.sh --zip "${managed_attachments}/ts-shopping-cart-quickstart.zip" ../samples/ts/ts-shopping-cart-quickstart dev: clean managed validate-xrefs dev-html diff --git a/docs/src/modules/javascript/pages/quickstart/cr-value-entity-javascript.adoc b/docs/src/modules/javascript/pages/quickstart/cr-value-entity-javascript.adoc index 474e40da..4108350f 100644 --- a/docs/src/modules/javascript/pages/quickstart/cr-value-entity-javascript.adoc +++ b/docs/src/modules/javascript/pages/quickstart/cr-value-entity-javascript.adoc @@ -66,9 +66,11 @@ mkdir proto . Add declarations for: + +-- * The protobuf syntax version, `proto3`. * The package name, `customer.api`. * Import `google/protobuf/empty.proto` and Kalix `kalix/annotations.proto`. +-- + [source,proto,indent=0] .proto/customer_api.proto @@ -240,4 +242,4 @@ kalix service expose customer-registry == Next steps * You can learn more about xref:javascript:value-entity.adoc[Value Entities]. -* Do another https://docs.kalix.io/quickstart/sc-eventsourced-entity-javascript.html[Quickstart] to learn about Event Sourcing and xref:javascript:eventsourced.adoc[Event Sourced Entities]. +* Do another xref:javascript:quickstart/sc-eventsourced-entity-javascript.adoc[Quickstart] to learn about Event Sourcing and xref:javascript:eventsourced.adoc[Event Sourced Entities]. diff --git a/docs/src/modules/javascript/pages/quickstart/cr-value-entity-typescript.adoc b/docs/src/modules/javascript/pages/quickstart/cr-value-entity-typescript.adoc index 46523682..f6fce74d 100644 --- a/docs/src/modules/javascript/pages/quickstart/cr-value-entity-typescript.adoc +++ b/docs/src/modules/javascript/pages/quickstart/cr-value-entity-typescript.adoc @@ -66,9 +66,11 @@ mkdir proto . Add declarations for: + +-- * The protobuf syntax version, `proto3`. * The package name, `customer.api`. * Import `google/protobuf/empty.proto` and Kalix `kalix/annotations.proto`. +-- + [source, proto, indent=0] .proto/customer_api.proto @@ -240,4 +242,4 @@ kalix service expose customer-registry == Next steps * You can learn more about xref:javascript:value-entity.adoc[Value Entities]. -* Do another https://docs.kalix.io/quickstart/sc-eventsourced-entity-javascript.html[Quickstart] to learn about Event Sourcing and xref:javascript:eventsourced.adoc[Event Sourced Entities]. +* Do another xref:javascript:quickstart/sc-eventsourced-entity-typescript.adoc[Quickstart] to learn about Event Sourcing and xref:javascript:eventsourced.adoc[Event Sourced Entities]. diff --git a/docs/src/modules/javascript/pages/quickstart/sc-eventsourced-entity-javascript.adoc b/docs/src/modules/javascript/pages/quickstart/sc-eventsourced-entity-javascript.adoc new file mode 100644 index 00000000..bf367715 --- /dev/null +++ b/docs/src/modules/javascript/pages/quickstart/sc-eventsourced-entity-javascript.adoc @@ -0,0 +1,313 @@ += Quickstart: Shopping Cart in JavaScript + +include::ROOT:partial$include.adoc[] +include::javascript:partial$attributes.adoc[] + +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, https://console.kalix.io[create an account{tab-icon}, window="new"] so you can try it out for free. +* You'll also need to install the https://docs.kalix.io/kalix/install-kalix.html[Kalix CLI, window="new-doc"] to deploy from a terminal window. +* For this quickstart, you'll also need +** https://docs.docker.com/engine/install[Docker {minimum_docker_version} or higher, window="new"] +** https://nodejs.org/en/download/[Node.js {minimum_node_version}, window="new"] +** https://github.com/fullstorydev/grpcurl#installation[`grpcurl`, window="new"] + +[NOTE] +==== +If you want to bypass writing code and jump straight to the deployment: + +. Download the source code using the Kalix CLI: ++ +[source, command line] +---- +kalix quickstart download shopping-cart-javascript +---- + +. Skip to <>. +==== + +== Create the project structure and install dependencies + +. From the command line, create a directory with the basic structure for your project using a template: ++ +[source, command line] +---- +npx @kalix-io/create-kalix-entity@latest shopping-cart --template basic +---- + +. Change into the project directory: ++ +[source, command line] +---- +cd shopping-cart +---- + +. Download and install project dependencies: ++ +[source, command line] +---- +npm install +---- + +== 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. + +. Create a `proto` directory. ++ +[source, command line] +---- +mkdir proto +---- + +. Create a `shopping_cart_api.proto` file and save it in the `proto` directory. + +. Add declarations for: ++ +-- +* The protobuf syntax version, `proto3`. +* The package name, `shopping.cart.api`. +* Import `google/api/annotations.proto`, `google/protobuf/empty.proto`, and Kalix `kalix/annotations.proto`. +-- ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$js-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=declarations] +---- + +. Add the service endpoint. The service endpoint is annotated with `kalix.codegen` indicating we want to generate an Event Sourced Entity for this service. ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$js-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=service] +---- + +. Add messages to define the fields that comprise a `Cart` object (and its compound `LineItem`): ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$js-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=messages] +---- + +. Add the messages that are the requests to the shopping cart service: ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$js-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=method-messages] +---- + +== Define the domain model + +The `shopping_cart_domain.proto` contains all the internal data objects (https://docs.kalix.io/reference/glossary.html#entity[Entities, window="new"]). The https://docs.kalix.io/reference/glossary.html#event_sourced_entity[Event Sourced Entity, window="new"] in this Quickstart keeps all events sent for a specific shopping cart in a journal. + +. Create a `shopping_cart_domain.proto` file and save it in the `proto` directory. + +. Add declarations for the proto syntax and domain package. ++ +[source, proto, indent=0] +.proto/shopping_cart_domain.proto +---- +include::javascript:example$js-shopping-cart-quickstart/proto/shopping_cart_domain.proto[tag=declarations] +---- + +. Add the `CartState` message with fields for entity data and the `LineItem` message that defines the compound line item: ++ +[source, proto, indent=0] +.proto/shopping_cart_domain.proto +---- +include::javascript:example$js-shopping-cart-quickstart/proto/shopping_cart_domain.proto[tag=state] +---- + +. Event Sourced entities work based on events. Add the events that can occur in this Quickstart: ++ +[source, proto, indent=0] +---- +include::javascript:example$js-shopping-cart-quickstart/proto/shopping_cart_domain.proto[tag=events] +---- + +== Generate JavaScript implementation stubs + +Run code generation to build JavaScript implementation stubs from your external API and domain model proto files. + +[source, command line] +---- +npm run build +---- + +== Implement shopping cart business logic + +Fill in the implementation for the behavior of your shopping cart, which consists of Command Handlers and Event Handlers. + +* Command Handlers, as the name suggests, handle incoming API requests. State is not updated directly by command handlers. + Instead, if state should be updated, an event is persisted that describes the intended transaction. +* Event Handlers maintain the state of an entity by sequentially applying the effects of events to the local state. + +// + +. Open `src/shoppingcart.js`. + +. Fill in the implementation for the behavior of your shopping cart, which will be implemented by functions added below: ++ +[source, javascript, indent=0] +.src/shoppingcart.js +---- +include::javascript:example$js-shopping-cart-quickstart/src/shoppingcart.js[tag=setBehavior] +---- + +. Add the `addItem` function to handle requests to add items to a shopping cart. ++ +-- +* This function will handle an incoming `AddItem` request, and emit an `ItemAdded` event. +* The current state of the shopping cart is passed to the function but is not used. +* It fails the command for an invalid quantity (needs to be at least one item). +* Or it persists an `ItemAdded` event using the context `emit` effect. +-- ++ +[source, javascript, indent=0] +.src/shoppingcart.js +---- +include::javascript:example$js-shopping-cart-quickstart/src/shoppingcart.js[tag=addItem] +---- + +. Add the `removeItem` function to handle requests to remove items from a shopping cart. ++ +-- +* This function will handle an incoming `RemoveItem` request, and emit an `ItemRemoved` event. +* The current state of the shopping cart is passed and used to check the item exists. +* It fails if the item to be removed is not found in the shopping cart. +* If the item exists, it persists an `ItemRemoved` event using the context `emit` effect. +-- ++ +[source, javascript, indent=0] +.src/shoppingcart.js +---- +include::javascript:example$js-shopping-cart-quickstart/src/shoppingcart.js[tag=removeItem] +---- + +. Add the `getCart` function to handle requests to retrieve a shopping cart. ++ +-- +* This function takes the current internal state and converts it to the external API model. +* The conversion between the domain and the external API is straigtforward, as they have the same fields. +-- ++ +[source, javascript, indent=0] +.src/shoppingcart.js +---- +include::javascript:example$js-shopping-cart-quickstart/src/shoppingcart.js[tag=getCart] +---- + +. Add the `itemAdded` function to update the state for emitted `ItemAdded` events. ++ +-- +* This function first checks for an existing line item for the newly added product. +* If an existing item is found, its quantity is adjusted. +* Otherwise, the new item is added directly to the cart. +* Finally, the updated cart state is returned. +-- ++ +[source, javascript, indent=0] +.src/shoppingcart.js +---- +include::javascript:example$js-shopping-cart-quickstart/src/shoppingcart.js[tag=itemAdded] +---- + +. Add the `itemRemoved` function to update the state for emitted `ItemRemoved` events. ++ +-- +* This function removes an item from the cart, by filtering it from the cart items. +* The updated cart state is then returned. +-- ++ +[source, javascript, indent=0] +.src/shoppingcart.js +---- +include::javascript:example$js-shopping-cart-quickstart/src/shoppingcart.js[tag=itemRemoved] +---- + +[NOTE] +==== +The `src/index.js` file already contains the required code to start your service and register it with Kalix. +==== + +== Package and deploy your service + +To build and publish the container image and then deploy the service, follow these steps: + +. If you haven't done so yet, sign in to your Kalix account. If this is your first time using Kalix, this will let you register an account, https://docs.kalix.io/projects/create-project.html[create your first project], and set this project as the default. ++ +[source, command line] +---- +kalix auth login +---- + +. Update the `config.dockerImage` setting in the `package.json` file with your container registry. + +. Use the `deploy` script to build the container image, publish it to the container registry as configured in the `package.json` file, and then automatically https://docs.kalix.io/services/deploy-service.html#_deploy[deploy the service] to Kalix using `kalix`: ++ +[source, command line] +---- +npm run deploy +---- + +. You can https://docs.kalix.io/services/deploy-service.html#_verify_service_status[verify the status of the deployed service] using: ++ +[source, command line] +---- +kalix service list +---- + +== Invoke your service + +Once the service has started successfully, you can https://docs.kalix.io/services/invoke-service.html#_testing_and_development[start a proxy locally] to access the service: + +[source, command line] +---- +kalix service proxy shopping-cart --grpcui +---- + +The `--grpcui` option also starts and opens a https://docs.kalix.io/services/invoke-service.html#_using_the_built_in_graphical_client[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`: + +[source, command line] +---- +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`: + +[source, command line] +---- +grpcurl \ + -d '{"cart_id": "abc123"}' \ + --plaintext localhost:8080 \ + shopping.cart.api.ShoppingCart/GetCart +---- + +You can https://docs.kalix.io/services/invoke-service.html#_exposing_services_to_the_internet[expose the service to the internet]. A generated hostname will be returned from the expose command: + +[source, command line] +---- +kalix service expose shopping-cart +---- + +== Next steps + +* You can learn more about xref:javascript:eventsourced.adoc[Event Sourced Entities]. diff --git a/docs/src/modules/javascript/pages/quickstart/sc-eventsourced-entity-typescript.adoc b/docs/src/modules/javascript/pages/quickstart/sc-eventsourced-entity-typescript.adoc new file mode 100644 index 00000000..da1bb0d3 --- /dev/null +++ b/docs/src/modules/javascript/pages/quickstart/sc-eventsourced-entity-typescript.adoc @@ -0,0 +1,321 @@ += Quickstart: Shopping Cart in TypeScript + +include::ROOT:partial$include.adoc[] +include::javascript:partial$attributes.adoc[] + +Learn how to create a shopping cart in TypeScript, package it into a container, and run it on Kalix. + +== Before you begin + +* If you're new to Kalix, https://console.kalix.io[create an account{tab-icon}, window="new"] so you can try it out for free. +* You'll also need to install the https://docs.kalix.io/kalix/install-kalix.html[Kalix CLI, window="new-doc"] to deploy from a terminal window. +* For this quickstart, you'll also need +** https://docs.docker.com/engine/install[Docker {minimum_docker_version} or higher, window="new"] +** https://nodejs.org/en/download/[Node.js {minimum_node_version}, window="new"] +** https://github.com/fullstorydev/grpcurl#installation[`grpcurl`, window="new"] + +[NOTE] +==== +If you want to bypass writing code and jump straight to the deployment: + +. Download the source code using the Kalix CLI: ++ +[source, command line] +---- +kalix quickstart download shopping-cart-typescript +---- + +. Skip to <>. +==== + +== Create the project structure and install dependencies + +. From the command line, create a directory with the basic structure for your project using a template: ++ +[source, command line] +---- +npx @kalix-io/create-kalix-entity@latest shopping-cart --typescript --template basic +---- + +. Change into the project directory: ++ +[source, command line] +---- +cd shopping-cart +---- + +. Download and install project dependencies: ++ +[source, command line] +---- +npm install +---- + +== 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. + +. Create a `proto` directory. ++ +[source, command line] +---- +mkdir proto +---- + +. Create a `shopping_cart_api.proto` file and save it in the `proto` directory. + +. Add declarations for: ++ +-- +* The protobuf syntax version, `proto3`. +* The package name, `shopping.cart.api`. +* Import `google/api/annotations.proto`, `google/protobuf/empty.proto`, and Kalix `kalix/annotations.proto`. +-- ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$ts-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=declarations] +---- + +. Add the service endpoint. The service endpoint is annotated with `kalix.codegen` indicating we want to generate an Event Sourced Entity for this service. ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$ts-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=service] +---- + +. Add messages to define the fields that comprise a `Cart` object (and its compound `LineItem`): ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$ts-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=messages] +---- + +. Add the messages that are the requests to the shopping cart service: ++ +[source, proto, indent=0] +.proto/shopping_cart_api.proto +---- +include::javascript:example$ts-shopping-cart-quickstart/proto/shopping_cart_api.proto[tag=method-messages] +---- + +== Define the domain model + +The `shopping_cart_domain.proto` contains all the internal data objects (https://docs.kalix.io/reference/glossary.html#entity[Entities, window="new"]). The https://docs.kalix.io/reference/glossary.html#event_sourced_entity[Event Sourced Entity, window="new"] in this Quickstart keeps all events sent for a specific shopping cart in a journal. + +. Create a `shopping_cart_domain.proto` file and save it in the `proto` directory. + +. Add declarations for the proto syntax and domain package. ++ +[source, proto, indent=0] +.proto/shopping_cart_domain.proto +---- +include::javascript:example$ts-shopping-cart-quickstart/proto/shopping_cart_domain.proto[tag=declarations] +---- + +. Add the `CartState` message with fields for entity data and the `LineItem` message that defines the compound line item: ++ +[source, proto, indent=0] +.proto/shopping_cart_domain.proto +---- +include::javascript:example$ts-shopping-cart-quickstart/proto/shopping_cart_domain.proto[tag=state] +---- + +. Event Sourced entities work based on events. Add the events that can occur in this Quickstart: ++ +[source, proto, indent=0] +---- +include::javascript:example$ts-shopping-cart-quickstart/proto/shopping_cart_domain.proto[tag=events] +---- + +== Generate TypeScript implementation stubs + +Run code generation to build TypeScript implementation stubs from your external API and domain model proto files. + +[source, command line] +---- +npm run build +---- + +== Implement shopping cart business logic + +Fill in the implementation for the behavior of your shopping cart, which consists of Command Handlers and Event Handlers. + +* Command Handlers, as the name suggests, handle incoming API requests. State is not updated directly by command handlers. + Instead, if state should be updated, an event is persisted that describes the intended transaction. +* Event Handlers maintain the state of an entity by sequentially applying the effects of events to the local state. + +// + +. Open `src/shoppingcart.ts`. + +. Add imports for API and domain types, which will be used later: ++ +[source, typescript, indent=0] +.src/shoppingcart.ts +---- +include::javascript:example$ts-shopping-cart-quickstart/src/shoppingcart.ts[tag=imports] +---- + +. Fill in the implementation for the behavior of your shopping cart, which will be implemented by functions added below: ++ +[source, typescript, indent=0] +.src/shoppingcart.ts +---- +include::javascript:example$ts-shopping-cart-quickstart/src/shoppingcart.ts[tag=setBehavior] +---- + +. Add the `addItem` function to handle requests to add items to a shopping cart. ++ +-- +* This function will handle an incoming `AddItem` request, and emit an `ItemAdded` event. +* The current state of the shopping cart is passed to the function but is not used. +* It fails the command for an invalid quantity (needs to be at least one item). +* Or it persists an `ItemAdded` event using the context `emit` effect. +-- ++ +[source, typescript, indent=0] +.src/shoppingcart.ts +---- +include::javascript:example$ts-shopping-cart-quickstart/src/shoppingcart.ts[tag=addItem] +---- + +. Add the `removeItem` function to handle requests to remove items from a shopping cart. ++ +-- +* This function will handle an incoming `RemoveItem` request, and emit an `ItemRemoved` event. +* The current state of the shopping cart is passed and used to check the item exists. +* It fails if the item to be removed is not found in the shopping cart. +* If the item exists, it persists an `ItemRemoved` event using the context `emit` effect. +-- ++ +[source, typescript, indent=0] +.src/shoppingcart.ts +---- +include::javascript:example$ts-shopping-cart-quickstart/src/shoppingcart.ts[tag=removeItem] +---- + +. Add the `getCart` function to handle requests to retrieve a shopping cart. ++ +-- +* This function takes the current internal state and converts it to the external API model. +* The conversion between the domain and the external API is straigtforward, as they have the same fields. +-- ++ +[source, typescript, indent=0] +.src/shoppingcart.ts +---- +include::javascript:example$ts-shopping-cart-quickstart/src/shoppingcart.ts[tag=getCart] +---- + +. Add the `itemAdded` function to update the state for emitted `ItemAdded` events. ++ +-- +* This function first checks for an existing line item for the newly added product. +* If an existing item is found, its quantity is adjusted. +* Otherwise, the new item is added directly to the cart. +* Finally, the updated cart state is returned. +-- ++ +[source, typescript, indent=0] +.src/shoppingcart.ts +---- +include::javascript:example$ts-shopping-cart-quickstart/src/shoppingcart.ts[tag=itemAdded] +---- + +. Add the `itemRemoved` function to update the state for emitted `ItemRemoved` events. ++ +-- +* This function removes an item from the cart, by filtering it from the cart items. +* The updated cart state is then returned. +-- ++ +[source, typescript, indent=0] +.src/shoppingcart.ts +---- +include::javascript:example$ts-shopping-cart-quickstart/src/shoppingcart.ts[tag=itemRemoved] +---- + +[NOTE] +==== +The `src/index.ts` file already contains the required code to start your service and register it with Kalix. +==== + +== Package and deploy your service + +To build and publish the container image and then deploy the service, follow these steps: + +. If you haven't done so yet, sign in to your Kalix account. If this is your first time using Kalix, this will let you register an account, https://docs.kalix.io/projects/create-project.html[create your first project], and set this project as the default. ++ +[source, command line] +---- +kalix auth login +---- + +. Update the `config.dockerImage` setting in the `package.json` file with your container registry. + +. Use the `deploy` script to build the container image, publish it to the container registry as configured in the `package.json` file, and then automatically https://docs.kalix.io/services/deploy-service.html#_deploy[deploy the service] to Kalix using `kalix`: ++ +[source, command line] +---- +npm run deploy +---- + +. You can https://docs.kalix.io/services/deploy-service.html#_verify_service_status[verify the status of the deployed service] using: ++ +[source, command line] +---- +kalix service list +---- + +== Invoke your service + +Once the service has started successfully, you can https://docs.kalix.io/services/invoke-service.html#_testing_and_development[start a proxy locally] to access the service: + +[source, command line] +---- +kalix service proxy shopping-cart --grpcui +---- + +The `--grpcui` option also starts and opens a https://docs.kalix.io/services/invoke-service.html#_using_the_built_in_graphical_client[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`: + +[source, command line] +---- +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`: + +[source, command line] +---- +grpcurl \ + -d '{"cart_id": "abc123"}' \ + --plaintext localhost:8080 \ + shopping.cart.api.ShoppingCart/GetCart +---- + +You can https://docs.kalix.io/services/invoke-service.html#_exposing_services_to_the_internet[expose the service to the internet]. A generated hostname will be returned from the expose command: + +[source, command line] +---- +kalix service expose shopping-cart +---- + +== Next steps + +* You can learn more about xref:javascript:eventsourced.adoc[Event Sourced Entities]. diff --git a/samples/js/js-shopping-cart-quickstart/.bundleignore b/samples/js/js-shopping-cart-quickstart/.bundleignore new file mode 100644 index 00000000..8484ead5 --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/.bundleignore @@ -0,0 +1,5 @@ +.gitignore +lib +node_modules +package-lock.json +user-function.desc diff --git a/samples/js/js-shopping-cart-quickstart/.dockerignore b/samples/js/js-shopping-cart-quickstart/.dockerignore new file mode 100644 index 00000000..6a020972 --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/.dockerignore @@ -0,0 +1,5 @@ +/README.md +/docker-compose.yml +/lib/generated +/node_modules +/user-function.desc diff --git a/samples/js/js-shopping-cart-quickstart/.gitignore b/samples/js/js-shopping-cart-quickstart/.gitignore new file mode 100644 index 00000000..8ace6d7a --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/.gitignore @@ -0,0 +1,5 @@ +/lib/generated +/node_modules +/user-function.desc +# test quickstarts without lock file +/package-lock.json diff --git a/samples/js/js-shopping-cart-quickstart/Dockerfile b/samples/js/js-shopping-cart-quickstart/Dockerfile new file mode 100644 index 00000000..ee69d121 --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/Dockerfile @@ -0,0 +1,48 @@ +# 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.19-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.19-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 +# Call node directly to get SIGTERM for graceful shutdown +CMD ["node", "src/index.js"] diff --git a/samples/js/js-shopping-cart-quickstart/README.md b/samples/js/js-shopping-cart-quickstart/README.md new file mode 100644 index 00000000..0c8a19e5 --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/README.md @@ -0,0 +1,117 @@ +# Quickstart project: Shopping Cart + + +## Designing + +To understand the Kalix concepts that are the basis for this example, see [designing +services](https://docs.kalix.io/services/development-process.html) in the documentation. + + +## Developing + +This project demonstrates the use of an Event Sourced Entity component to create a Shopping Cart. + +To understand more about components, see [developing services](https://docs.kalix.io/services/) and +the [JavaScript section](https://docs.kalix.io/javascript/) in particular. + + +## Building + +You can use `npm` to build your project, which will also take care of generating code based on the +`.proto` definitions. First install dependencies: + +``` +npm install +``` + +Then run the `build` script: + +``` +npm run build +``` + + +## Testing + +Unit tests, that test against a mock entity, can be found in `test`. Run the unit tests with: + +``` +npm test +``` + +Integration tests, that make calls against a local service, can be found in `integration-test`. +Run the integration tests with: + +``` +npm run integration-test +``` + + +## Running Locally + +To run the example locally, you must run the Kalix proxy. The included `docker-compose` file +contains the configuration required to run the proxy for a locally running application. It also +contains the configuration to start a local Google Pub/Sub emulator that the Kalix proxy will +connect to. To start the proxy, run the following command from this directory: + +``` +docker-compose up +``` + +To start the application locally, use the following command: + +``` +npm start +``` + +With both the proxy and your application running, the defined endpoints should be available at +`http://localhost:9000`. In addition to the defined gRPC interface, each method has a corresponding +HTTP endpoint. Example calls using [grpcurl](https://github.com/fullstorydev/grpcurl): + +* Add an item to a cart: + ``` + grpcurl \ + -d '{ + "cart_id": "abc123", + "product_id": "AAPL", + "name": "Apples", + "quantity": 42 + }' \ + --plaintext localhost:9000 \ + shopping.cart.api.ShoppingCart/AddItem + ``` + +* Retrieve the cart: + ``` + grpcurl \ + -d '{"cart_id": "abc123"}' \ + --plaintext localhost:9000 \ + shopping.cart.api.ShoppingCart/GetCart + ``` + +* Remove an item from the cart: + ``` + grpcurl \ + -d '{ + "cart_id": "abc123", + "product_id": "AAPL" + }' \ + --plaintext localhost:9000 \ + shopping.cart.api.ShoppingCart/RemoveItem + ``` + + +## Deploying + +To deploy your service, install the `kalix` CLI as documented in [setting up a local development +environment](https://docs.kalix.io/getting-started/set-up-development-env.html) and configure a +Docker Registry to upload your docker image to. + +You will need to update the `config.dockerImage` property in the `package.json`. Refer to +[configuring registries](https://docs.kalix.io/projects/container-registries.html) for more +information on how to make your docker image available to Kalix. + +Finally, you can use the [Kalix Console](https://console.kalix.io) to create a project and then +deploy your service into the project either by using `npm run deploy`, through the `kalix` CLI, or +via the web interface. When using `npm run deploy`, the deploy script will also conveniently package +and publish your docker image prior to deployment. diff --git a/samples/js/js-shopping-cart-quickstart/docker-compose.yml b/samples/js/js-shopping-cart-quickstart/docker-compose.yml new file mode 100644 index 00000000..365c3dbb --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3" +services: + kalix-proxy: + image: gcr.io/kalix-public/kalix-proxy:1.0.10 + command: -Dconfig.resource=dev-mode.conf -kalix.proxy.eventing.support=google-pubsub-emulator + ports: + - "9000:9000" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + USER_FUNCTION_HOST: ${USER_FUNCTION_HOST:-host.docker.internal} + USER_FUNCTION_PORT: ${USER_FUNCTION_PORT:-8080} + PUBSUB_EMULATOR_HOST: gcloud-pubsub-emulator + # Uncomment to disable the JWT dev secret + # JWT_DEV_SECRET: "false" + # Uncomment to set the JWT dev secret issuer + # JWT_DEV_SECRET_ISSUER: "my-issuer" + gcloud-pubsub-emulator: + image: gcr.io/google.com/cloudsdktool/cloud-sdk:341.0.0 + command: gcloud beta emulators pubsub start --project=test --host-port=0.0.0.0:8085 + ports: + - 8085:8085 diff --git a/samples/js/js-shopping-cart-quickstart/integration-test/shoppingcart.test.js b/samples/js/js-shopping-cart-quickstart/integration-test/shoppingcart.test.js new file mode 100644 index 00000000..f64c1149 --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/integration-test/shoppingcart.test.js @@ -0,0 +1,100 @@ +/* + * 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. + */ + +import { IntegrationTestkit } from "@kalix-io/testkit"; +import { expect } from "chai"; +import shoppingCart from "../src/shoppingcart.js"; + +const testkit = new IntegrationTestkit(); +testkit.addComponent(shoppingCart); + +const client = () => testkit.clients.ShoppingCart; + +describe("ShoppingCart", function () { + this.timeout(60000); + + before((done) => testkit.start(done)); + after((done) => testkit.shutdown(done)); + + it("should default to an empty cart", async () => { + const cart = await client().getCartAsync({ cartId: "cart1" }); + expect(cart).to.deep.equal({}); + }); + + it("should add items to a cart", async () => { + await client().addItemAsync({ + cartId: "cart2", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await client().addItemAsync({ + cartId: "cart2", + productId: "b", + name: "Banana", + quantity: 2, + }); + + await client().addItemAsync({ + cartId: "cart2", + productId: "c", + name: "Cantaloupe", + quantity: 3, + }); + + const cart = await client().getCartAsync({ cartId: "cart2" }); + + expect(cart.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ]); + }); + + it("should remove items from a cart", async () => { + await client().addItemAsync({ + cartId: "cart3", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await client().addItemAsync({ + cartId: "cart3", + productId: "b", + name: "Banana", + quantity: 2, + }); + + { + const cart = await client().getCartAsync({ cartId: "cart3" }); + expect(cart.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + ]); + } + + await client().removeItemAsync({ cartId: "cart3", productId: "a" }); + + { + const cart = await client().getCartAsync({ cartId: "cart3" }); + expect(cart.items).to.deep.equal([ + { productId: "b", name: "Banana", quantity: 2 } + ]); + } + }); +}); diff --git a/samples/js/js-shopping-cart-quickstart/jsconfig.json b/samples/js/js-shopping-cart-quickstart/jsconfig.json new file mode 100644 index 00000000..292f36bc --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "commonJS", + "target": "es6", + "checkJs": true + }, + "strict": true, + "exclude": ["lib/generated/proto.js"], + "include": ["src/**/*", "lib/**/*", "test/**/*"] +} diff --git a/samples/js/js-shopping-cart-quickstart/package.json b/samples/js/js-shopping-cart-quickstart/package.json new file mode 100644 index 00000000..ab5fe45e --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/package.json @@ -0,0 +1,35 @@ +{ + "name": "shopping-cart", + "version": "0.0.1", + "type": "module", + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + }, + "dependencies": { + "@kalix-io/kalix-javascript-sdk": "^1.0.0-M10" + }, + "devDependencies": { + "@kalix-io/kalix-scripts": "^1.0.0-M10", + "@kalix-io/testkit": "^1.0.0-M10", + "chai": "^4.3.6", + "mocha": "^10.0.0" + }, + "config": { + "dockerImage": "my-docker-repo/shopping-cart", + "sourceDir": "./src", + "testSourceDir": "./test", + "integrationTestSourceDir": "./integration-test", + "protoSourceDir": "./proto", + "generatedSourceDir": "./lib/generated", + "compileDescriptorArgs": [] + }, + "scripts": { + "start": "node src/index.js", + "test": "mocha ./test", + "integration-test": "mocha ./integration-test", + "build": "kalix-scripts build", + "package": "kalix-scripts package", + "deploy": "kalix-scripts deploy" + } +} diff --git a/samples/js/js-shopping-cart-quickstart/proto/kalix_policy.proto b/samples/js/js-shopping-cart-quickstart/proto/kalix_policy.proto new file mode 100644 index 00000000..d01e85bf --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/proto/kalix_policy.proto @@ -0,0 +1,26 @@ +// 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. + +// Default access control for all components of this Kalix Service + +syntax = "proto3"; + +package com.example; + +import "kalix/annotations.proto"; + +// only allow access from other services in the same project by default +option (kalix.file).acl = { + allow: { service: "*" } +}; diff --git a/samples/js/js-shopping-cart-quickstart/proto/shopping_cart_api.proto b/samples/js/js-shopping-cart-quickstart/proto/shopping_cart_api.proto new file mode 100644 index 00000000..9ffbb57d --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/proto/shopping_cart_api.proto @@ -0,0 +1,93 @@ +// 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 is the public API offered by the Shopping Cart Event Sourced Entity. + +// tag::declarations[] +syntax = "proto3"; + +package shopping.cart.api; + +import "google/api/annotations.proto"; +import "google/protobuf/empty.proto"; +import "kalix/annotations.proto"; +// end::declarations[] + +// tag::messages[] +message Cart { + repeated LineItem items = 1; +} + +message LineItem { + string product_id = 1; + string name = 2; + int32 quantity = 3; +} +// end::messages[] + +// tag::method-messages[] +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]; +} +// end::method-messages[] + +// tag::service[] +service ShoppingCart { + option (kalix.codegen) = { + event_sourced_entity: { + name: "shopping.cart.domain.ShoppingCart" + entity_type: "shopping-cart" + state: "shopping.cart.domain.CartState" + events: "shopping.cart.domain.ItemAdded" + events: "shopping.cart.domain.ItemRemoved" + } + }; + // end::service[] + option (kalix.service).acl.allow = { principal: ALL }; + // tag::service[] + + 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}" + additional_bindings: { + get: "/carts/{cart_id}/items" + response_body: "items" + } + }; + } +} +// end::service[] diff --git a/samples/js/js-shopping-cart-quickstart/proto/shopping_cart_domain.proto b/samples/js/js-shopping-cart-quickstart/proto/shopping_cart_domain.proto new file mode 100644 index 00000000..1f04e96c --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/proto/shopping_cart_domain.proto @@ -0,0 +1,44 @@ +// 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. + +// These are the messages that get persisted - the events, plus the current +// state (CartState) for snapshots. + +// tag::declarations[] +syntax = "proto3"; + +package shopping.cart.domain; +// end::declarations[] + +// tag::state[] +message CartState { + repeated LineItem items = 1; +} + +message LineItem { + string productId = 1; + string name = 2; + int32 quantity = 3; +} +// end::state[] + +// tag::events[] +message ItemAdded { + LineItem item = 1; +} + +message ItemRemoved { + string productId = 1; +} +// end::events[] diff --git a/samples/js/js-shopping-cart-quickstart/src/index.js b/samples/js/js-shopping-cart-quickstart/src/index.js new file mode 100644 index 00000000..84824efb --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/src/index.js @@ -0,0 +1,29 @@ +/* + * 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. + */ + +import { Kalix } from "@kalix-io/kalix-javascript-sdk"; +import generatedComponents from "../lib/generated/index.js"; + +const server = new Kalix(); + +// This generatedComponents array contains all generated Actions, Views or Entities, +// and is kept up-to-date with any changes in your protobuf definitions. +// If you prefer, you may remove this line and manually register these components. +generatedComponents.forEach((component) => { + server.addComponent(component); +}); + +server.start(); diff --git a/samples/js/js-shopping-cart-quickstart/src/shoppingcart.js b/samples/js/js-shopping-cart-quickstart/src/shoppingcart.js new file mode 100644 index 00000000..a3420e7b --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/src/shoppingcart.js @@ -0,0 +1,154 @@ +/* + * 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. + */ + +import { EventSourcedEntity, Reply } from "@kalix-io/kalix-javascript-sdk"; + +/** + * 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. + * + * ShoppingCart; a strongly typed extension of EventSourcedEntity derived from your proto source + * @typedef { import("../lib/generated/shoppingcart").ShoppingCart } ShoppingCart + * @typedef { import("../lib/generated/shoppingcart").ShoppingCart.CommandHandlers } CommandHandlers + * @typedef { import("../lib/generated/shoppingcart").ShoppingCart.EventHandlers } EventHandlers + */ + +/** + * @type ShoppingCart + */ +const entity = new EventSourcedEntity( + [ + "shopping_cart_api.proto", + "kalix_policy.proto", + "shopping_cart_domain.proto" + ], + "shopping.cart.api.ShoppingCart", + "shopping-cart", + { + includeDirs: ["./proto"] + } +); + +const CartState = entity.lookupType("shopping.cart.domain.CartState"); +const ItemAdded = entity.lookupType("shopping.cart.domain.ItemAdded"); +const ItemRemoved = entity.lookupType("shopping.cart.domain.ItemRemoved"); + +entity.setInitial(entityId => CartState.create({})); + +// tag::setBehavior[] +entity.setBehavior(state => ({ + commandHandlers: { + AddItem: addItem, + RemoveItem: removeItem, + GetCart: getCart + }, + eventHandlers: { + ItemAdded: itemAdded, + ItemRemoved: itemRemoved + } +})); +// end::setBehavior[] + +/** + * @type CommandHandlers['AddItem'] + */ +// tag::addItem[] +function addItem(addItem, _cart, ctx) { + if (addItem.quantity < 1) { + return Reply.failure( + `Quantity for item ${addItem.productId} must be at least one.` + ); + } else { + const itemAdded = ItemAdded.create({ + item: { + productId: addItem.productId, + name: addItem.name, + quantity: addItem.quantity + } + }); + ctx.emit(itemAdded); + return Reply.message({}); + } +} +// end::addItem[] + +/** + * @type CommandHandlers['RemoveItem'] + */ +// tag::removeItem[] +function removeItem(removeItem, cart, ctx) { + const existing = cart.items.find(item => + item.productId === removeItem.productId + ); + + if (!existing) { + return Reply.failure( + `Cannot remove item ${removeItem.productId} because it is not in the cart.` + ); + } else { + const itemRemoved = ItemRemoved.create({ + productId: removeItem.productId + }); + ctx.emit(itemRemoved); + return Reply.message({}); + } +} +// end::removeItem[] + +/** + * @type CommandHandlers['GetCart'] + */ +// tag::getCart[] +function getCart(_getShoppingCart, cart) { + // API and domain messages have the same fields so conversion is easy + return Reply.message(cart); +} +// end::getCart[] + +/** + * @type EventHandlers['ItemAdded'] + */ +// tag::itemAdded[] +function itemAdded(added, cart) { + const existing = cart.items.find(item => + item.productId === added.item.productId + ); + + if (existing) { + existing.quantity = existing.quantity + added.item.quantity; + } else { + cart.items.push(added.item); + } + + return cart; +} +// end::itemAdded[] + +/** + * @type EventHandlers['ItemRemoved'] + */ +// tag::itemRemoved[] +function itemRemoved(removed, cart) { + cart.items = cart.items.filter(item => + item.productId !== removed.productId + ); + + return cart; +} +// end::itemRemoved[] + +export default entity; diff --git a/samples/js/js-shopping-cart-quickstart/test/shoppingcart.test.js b/samples/js/js-shopping-cart-quickstart/test/shoppingcart.test.js new file mode 100644 index 00000000..07184afe --- /dev/null +++ b/samples/js/js-shopping-cart-quickstart/test/shoppingcart.test.js @@ -0,0 +1,196 @@ +/* + * 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. + */ + +import { MockEventSourcedEntity } from "@kalix-io/testkit"; +import { expect } from "chai"; +import shoppingCart from "../src/shoppingcart.js"; + +const ItemAdded = shoppingCart.lookupType("shopping.cart.domain.ItemAdded"); +const ItemRemoved = shoppingCart.lookupType("shopping.cart.domain.ItemRemoved"); + +describe("ShoppingCart", () => { + const entityId = "entityId"; + + describe("AddItem", () => { + it("should add items to the cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "b", + name: "Banana", + quantity: 2, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "c", + name: "Cantaloupe", + quantity: 3, + }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ItemAdded.create({ item: { productId: "c", name: "Cantaloupe", quantity: 3 } }), + ]); + }); + + it("should fail to add items with invalid quantity", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "z", + name: "Zilch", + quantity: 0, + }); + + expect(entity.error).to.equal("Quantity for item z must be at least one."); + expect(entity.state.items).to.be.empty; + expect(entity.events).to.be.empty; + }); + }); + + describe("RemoveItem", () => { + it("should remove items from a cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "b", + name: "Banana", + quantity: 2, + }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ]); + + await entity.handleCommand("RemoveItem", { cartId: "cart1", productId: "a" }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "b", name: "Banana", quantity: 2 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ItemRemoved.create({ productId: "a" }), + ]); + }); + + it("should fail to remove items that don't exist", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("RemoveItem", { + cartId: "cart1", + productId: "x", + }); + + expect(entity.error).to.equal("Cannot remove item x because it is not in the cart."); + expect(entity.state.items).to.be.empty; + expect(entity.events).to.be.empty; + }); + }); + + describe("GetCart", () => { + it("should default to an empty cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + const response = await entity.handleCommand("GetCart", { entityId }); + + expect(response).to.deep.equal({}); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.be.empty; + expect(entity.events).to.be.empty; + }); + + it("should retrieve a cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "b", + name: "Banana", + quantity: 2, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "c", + name: "Cantaloupe", + quantity: 3, + }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ItemAdded.create({ item: { productId: "c", name: "Cantaloupe", quantity: 3 } }), + ]); + + const response = await entity.handleCommand("GetCart", { cartId: "cart1" }); + + expect(response).to.deep.equal({ + items: [ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ], + }); + }); + }); +}); diff --git a/samples/ts/ts-shopping-cart-quickstart/.bundleignore b/samples/ts/ts-shopping-cart-quickstart/.bundleignore new file mode 100644 index 00000000..8d403408 --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/.bundleignore @@ -0,0 +1,6 @@ +.gitignore +dist +lib +node_modules +package-lock.json +user-function.desc diff --git a/samples/ts/ts-shopping-cart-quickstart/.dockerignore b/samples/ts/ts-shopping-cart-quickstart/.dockerignore new file mode 100644 index 00000000..70be84ff --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/.dockerignore @@ -0,0 +1,6 @@ +/README.md +/dist +/docker-compose.yml +/lib/generated +/node_modules +/user-function.desc diff --git a/samples/ts/ts-shopping-cart-quickstart/.gitignore b/samples/ts/ts-shopping-cart-quickstart/.gitignore new file mode 100644 index 00000000..c312e2c1 --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/.gitignore @@ -0,0 +1,6 @@ +/dist +/lib/generated +/node_modules +/user-function.desc +# test quickstarts without lock file +/package-lock.json diff --git a/samples/ts/ts-shopping-cart-quickstart/Dockerfile b/samples/ts/ts-shopping-cart-quickstart/Dockerfile new file mode 100644 index 00000000..6d2a648a --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/Dockerfile @@ -0,0 +1,47 @@ +# 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.19-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.19-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/dist ./dist + +# Run the app as an unprivileged user for extra security. +USER node + +# Run +EXPOSE 8080 +# Call node directly to get SIGTERM for graceful shutdown +CMD ["node", "dist/src/index.js"] diff --git a/samples/ts/ts-shopping-cart-quickstart/README.md b/samples/ts/ts-shopping-cart-quickstart/README.md new file mode 100644 index 00000000..0c8a19e5 --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/README.md @@ -0,0 +1,117 @@ +# Quickstart project: Shopping Cart + + +## Designing + +To understand the Kalix concepts that are the basis for this example, see [designing +services](https://docs.kalix.io/services/development-process.html) in the documentation. + + +## Developing + +This project demonstrates the use of an Event Sourced Entity component to create a Shopping Cart. + +To understand more about components, see [developing services](https://docs.kalix.io/services/) and +the [JavaScript section](https://docs.kalix.io/javascript/) in particular. + + +## Building + +You can use `npm` to build your project, which will also take care of generating code based on the +`.proto` definitions. First install dependencies: + +``` +npm install +``` + +Then run the `build` script: + +``` +npm run build +``` + + +## Testing + +Unit tests, that test against a mock entity, can be found in `test`. Run the unit tests with: + +``` +npm test +``` + +Integration tests, that make calls against a local service, can be found in `integration-test`. +Run the integration tests with: + +``` +npm run integration-test +``` + + +## Running Locally + +To run the example locally, you must run the Kalix proxy. The included `docker-compose` file +contains the configuration required to run the proxy for a locally running application. It also +contains the configuration to start a local Google Pub/Sub emulator that the Kalix proxy will +connect to. To start the proxy, run the following command from this directory: + +``` +docker-compose up +``` + +To start the application locally, use the following command: + +``` +npm start +``` + +With both the proxy and your application running, the defined endpoints should be available at +`http://localhost:9000`. In addition to the defined gRPC interface, each method has a corresponding +HTTP endpoint. Example calls using [grpcurl](https://github.com/fullstorydev/grpcurl): + +* Add an item to a cart: + ``` + grpcurl \ + -d '{ + "cart_id": "abc123", + "product_id": "AAPL", + "name": "Apples", + "quantity": 42 + }' \ + --plaintext localhost:9000 \ + shopping.cart.api.ShoppingCart/AddItem + ``` + +* Retrieve the cart: + ``` + grpcurl \ + -d '{"cart_id": "abc123"}' \ + --plaintext localhost:9000 \ + shopping.cart.api.ShoppingCart/GetCart + ``` + +* Remove an item from the cart: + ``` + grpcurl \ + -d '{ + "cart_id": "abc123", + "product_id": "AAPL" + }' \ + --plaintext localhost:9000 \ + shopping.cart.api.ShoppingCart/RemoveItem + ``` + + +## Deploying + +To deploy your service, install the `kalix` CLI as documented in [setting up a local development +environment](https://docs.kalix.io/getting-started/set-up-development-env.html) and configure a +Docker Registry to upload your docker image to. + +You will need to update the `config.dockerImage` property in the `package.json`. Refer to +[configuring registries](https://docs.kalix.io/projects/container-registries.html) for more +information on how to make your docker image available to Kalix. + +Finally, you can use the [Kalix Console](https://console.kalix.io) to create a project and then +deploy your service into the project either by using `npm run deploy`, through the `kalix` CLI, or +via the web interface. When using `npm run deploy`, the deploy script will also conveniently package +and publish your docker image prior to deployment. diff --git a/samples/ts/ts-shopping-cart-quickstart/docker-compose.yml b/samples/ts/ts-shopping-cart-quickstart/docker-compose.yml new file mode 100644 index 00000000..365c3dbb --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3" +services: + kalix-proxy: + image: gcr.io/kalix-public/kalix-proxy:1.0.10 + command: -Dconfig.resource=dev-mode.conf -kalix.proxy.eventing.support=google-pubsub-emulator + ports: + - "9000:9000" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + USER_FUNCTION_HOST: ${USER_FUNCTION_HOST:-host.docker.internal} + USER_FUNCTION_PORT: ${USER_FUNCTION_PORT:-8080} + PUBSUB_EMULATOR_HOST: gcloud-pubsub-emulator + # Uncomment to disable the JWT dev secret + # JWT_DEV_SECRET: "false" + # Uncomment to set the JWT dev secret issuer + # JWT_DEV_SECRET_ISSUER: "my-issuer" + gcloud-pubsub-emulator: + image: gcr.io/google.com/cloudsdktool/cloud-sdk:341.0.0 + command: gcloud beta emulators pubsub start --project=test --host-port=0.0.0.0:8085 + ports: + - 8085:8085 diff --git a/samples/ts/ts-shopping-cart-quickstart/integration-test/shoppingcart.test.ts b/samples/ts/ts-shopping-cart-quickstart/integration-test/shoppingcart.test.ts new file mode 100644 index 00000000..041fde82 --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/integration-test/shoppingcart.test.ts @@ -0,0 +1,115 @@ +/* + * 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. + */ + +import { IntegrationTestkit } from "@kalix-io/testkit"; +import { expect } from "chai"; +import shoppingCart from "../src/shoppingcart"; +import * as proto from "../lib/generated/proto"; + +type ShoppingCart = proto.shopping.cart.api.ShoppingCart; + +type ServiceMethods = { + [M in keyof S]: S[M] extends (x: any) => Promise ? M : never; +}[keyof S]; + +type AsyncShoppingCart = { + [M in ServiceMethods as `${M}Async`]: ( + ...args: Parameters + ) => ReturnType; +}; + +const testkit = new IntegrationTestkit(); +testkit.addComponent(shoppingCart); + +function client(): AsyncShoppingCart { + return testkit.clients.ShoppingCart; +} + +describe("ShoppingCart", function () { + this.timeout(60000); + + before((done) => testkit.start(done)); + after((done) => testkit.shutdown(done)); + + it("should default to an empty cart", async () => { + const cart = await client().getCartAsync({ cartId: "cart1" }); + expect(cart).to.deep.equal({}); + }); + + it("should add items to a cart", async () => { + await client().addItemAsync({ + cartId: "cart2", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await client().addItemAsync({ + cartId: "cart2", + productId: "b", + name: "Banana", + quantity: 2, + }); + + await client().addItemAsync({ + cartId: "cart2", + productId: "c", + name: "Cantaloupe", + quantity: 3, + }); + + const cart = await client().getCartAsync({ cartId: "cart2" }); + + expect(cart.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ]); + }); + + it("should remove items from a cart", async () => { + await client().addItemAsync({ + cartId: "cart3", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await client().addItemAsync({ + cartId: "cart3", + productId: "b", + name: "Banana", + quantity: 2, + }); + + { + const cart = await client().getCartAsync({ cartId: "cart3" }); + expect(cart.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + ]); + } + + await client().removeItemAsync({ cartId: "cart3", productId: "a" }); + + { + const cart = await client().getCartAsync({ cartId: "cart3" }); + expect(cart.items).to.deep.equal([ + { productId: "b", name: "Banana", quantity: 2 } + ]); + } + }); +}); diff --git a/samples/ts/ts-shopping-cart-quickstart/package.json b/samples/ts/ts-shopping-cart-quickstart/package.json new file mode 100644 index 00000000..27881ae3 --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/package.json @@ -0,0 +1,40 @@ +{ + "name": "shopping-cart", + "version": "0.0.1", + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + }, + "dependencies": { + "@kalix-io/kalix-javascript-sdk": "^1.0.0-M10" + }, + "devDependencies": { + "@kalix-io/kalix-scripts": "^1.0.0-M10", + "@kalix-io/testkit": "^1.0.0-M10", + "@types/chai": "^4.3.1", + "@types/mocha": "^9.1.1", + "@types/node": "18.0.0", + "chai": "^4.3.6", + "mocha": "^10.0.0", + "ts-mocha": "^10.0.0", + "typescript": "^4.7.4" + }, + "config": { + "typescript": true, + "dockerImage": "my-docker-repo/shopping-cart", + "sourceDir": "./src", + "testSourceDir": "./test", + "integrationTestSourceDir": "./integration-test", + "protoSourceDir": "./proto", + "generatedSourceDir": "./lib/generated", + "compileDescriptorArgs": [] + }, + "scripts": { + "start": "node dist/src/index.js", + "test": "ts-mocha test/**/*.test.ts", + "integration-test": "ts-mocha integration-test/**/*.test.ts", + "build": "kalix-scripts build && tsc", + "package": "kalix-scripts package", + "deploy": "kalix-scripts deploy" + } +} diff --git a/samples/ts/ts-shopping-cart-quickstart/proto/kalix_policy.proto b/samples/ts/ts-shopping-cart-quickstart/proto/kalix_policy.proto new file mode 100644 index 00000000..d01e85bf --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/proto/kalix_policy.proto @@ -0,0 +1,26 @@ +// 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. + +// Default access control for all components of this Kalix Service + +syntax = "proto3"; + +package com.example; + +import "kalix/annotations.proto"; + +// only allow access from other services in the same project by default +option (kalix.file).acl = { + allow: { service: "*" } +}; diff --git a/samples/ts/ts-shopping-cart-quickstart/proto/shopping_cart_api.proto b/samples/ts/ts-shopping-cart-quickstart/proto/shopping_cart_api.proto new file mode 100644 index 00000000..9ffbb57d --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/proto/shopping_cart_api.proto @@ -0,0 +1,93 @@ +// 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 is the public API offered by the Shopping Cart Event Sourced Entity. + +// tag::declarations[] +syntax = "proto3"; + +package shopping.cart.api; + +import "google/api/annotations.proto"; +import "google/protobuf/empty.proto"; +import "kalix/annotations.proto"; +// end::declarations[] + +// tag::messages[] +message Cart { + repeated LineItem items = 1; +} + +message LineItem { + string product_id = 1; + string name = 2; + int32 quantity = 3; +} +// end::messages[] + +// tag::method-messages[] +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]; +} +// end::method-messages[] + +// tag::service[] +service ShoppingCart { + option (kalix.codegen) = { + event_sourced_entity: { + name: "shopping.cart.domain.ShoppingCart" + entity_type: "shopping-cart" + state: "shopping.cart.domain.CartState" + events: "shopping.cart.domain.ItemAdded" + events: "shopping.cart.domain.ItemRemoved" + } + }; + // end::service[] + option (kalix.service).acl.allow = { principal: ALL }; + // tag::service[] + + 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}" + additional_bindings: { + get: "/carts/{cart_id}/items" + response_body: "items" + } + }; + } +} +// end::service[] diff --git a/samples/ts/ts-shopping-cart-quickstart/proto/shopping_cart_domain.proto b/samples/ts/ts-shopping-cart-quickstart/proto/shopping_cart_domain.proto new file mode 100644 index 00000000..1f04e96c --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/proto/shopping_cart_domain.proto @@ -0,0 +1,44 @@ +// 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. + +// These are the messages that get persisted - the events, plus the current +// state (CartState) for snapshots. + +// tag::declarations[] +syntax = "proto3"; + +package shopping.cart.domain; +// end::declarations[] + +// tag::state[] +message CartState { + repeated LineItem items = 1; +} + +message LineItem { + string productId = 1; + string name = 2; + int32 quantity = 3; +} +// end::state[] + +// tag::events[] +message ItemAdded { + LineItem item = 1; +} + +message ItemRemoved { + string productId = 1; +} +// end::events[] diff --git a/samples/ts/ts-shopping-cart-quickstart/src/index.ts b/samples/ts/ts-shopping-cart-quickstart/src/index.ts new file mode 100644 index 00000000..19cdec60 --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/src/index.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +import { Kalix } from "@kalix-io/kalix-javascript-sdk"; +import generatedComponents from "../lib/generated/index"; + +const server = new Kalix(); + +// This generatedComponents array contains all generated Actions, Views or Entities, +// and is kept up-to-date with any changes in your protobuf definitions. +// If you prefer, you may remove this line and manually register these components. +generatedComponents.forEach((component) => { + server.addComponent(component); +}); + +server.start(); diff --git a/samples/ts/ts-shopping-cart-quickstart/src/shoppingcart.ts b/samples/ts/ts-shopping-cart-quickstart/src/shoppingcart.ts new file mode 100644 index 00000000..7fb50fa2 --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/src/shoppingcart.ts @@ -0,0 +1,148 @@ +/* + * 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. + */ + +import { EventSourcedEntity, Reply } from "@kalix-io/kalix-javascript-sdk"; +import { ShoppingCart } from "../lib/generated/shoppingcart"; +// tag::imports[] +import { api, domain } from "../lib/generated/shoppingcart"; +// end::imports[] + +const entity: ShoppingCart = new EventSourcedEntity( + [ + "shopping_cart_api.proto", + "kalix_policy.proto", + "shopping_cart_domain.proto" + ], + "shopping.cart.api.ShoppingCart", + "shopping-cart", + { + includeDirs: ["./proto"] + } +); + +const CartState = entity.lookupType("shopping.cart.domain.CartState"); +const ItemAdded = entity.lookupType("shopping.cart.domain.ItemAdded"); +const ItemRemoved = entity.lookupType("shopping.cart.domain.ItemRemoved"); + +entity.setInitial(entityId => CartState.create({})); + +// tag::setBehavior[] +entity.setBehavior(state => ({ + commandHandlers: { + AddItem: addItem, + RemoveItem: removeItem, + GetCart: getCart + }, + eventHandlers: { + ItemAdded: itemAdded, + ItemRemoved: itemRemoved + } +})); +// end::setBehavior[] + +// tag::addItem[] +function addItem( + addLineItem: api.AddLineItem, + _cartState: domain.CartState, + context: ShoppingCart.CommandContext +): Reply { + if (addLineItem.quantity < 1) { + return Reply.failure( + `Quantity for item ${addLineItem.productId} must be at least one.` + ); + } else { + const itemAdded = ItemAdded.create({ + item: { + productId: addLineItem.productId, + name: addLineItem.name, + quantity: addLineItem.quantity + } + }); + context.emit(itemAdded); + return Reply.message({}); + } +} +// end::addItem[] + +// tag::removeItem[] +function removeItem( + removeLineItem: api.RemoveLineItem, + cartState: domain.CartState, + context: ShoppingCart.CommandContext +): Reply { + const existing = (cartState.items ?? []).find(item => + item.productId === removeLineItem.productId + ); + + if (!existing) { + const id = removeLineItem.productId; + return Reply.failure( + `Cannot remove item ${id} because it is not in the cart.` + ); + } else { + const itemRemoved = ItemRemoved.create({ + productId: removeLineItem.productId + }); + context.emit(itemRemoved); + return Reply.message({}); + } +} +// end::removeItem[] + +// tag::getCart[] +function getCart( + _getShoppingCart: api.GetShoppingCart, + cartState: domain.CartState +): Reply { + // API and domain messages have the same fields so conversion is easy + return Reply.message(cartState); +} +// end::getCart[] + +// tag::itemAdded[] +function itemAdded( + added: domain.ItemAdded, + cart: domain.CartState +): domain.CartState { + const existing = (cart.items ?? []).find(item => + item.productId === added.item?.productId + ); + + if (existing && existing.quantity) { + existing.quantity += added.item?.quantity ?? 0; + } else if (added.item) { + if (!cart.items) cart.items = []; + cart.items.push(added.item); + } + + return cart; +} +// end::itemAdded[] + +// tag::itemRemoved[] +function itemRemoved( + removed: domain.ItemRemoved, + cart: domain.CartState +): domain.CartState { + cart.items = (cart.items ?? []).filter(item => + item.productId !== removed.productId + ); + + return cart; +} +// end::itemRemoved[] + +export default entity; diff --git a/samples/ts/ts-shopping-cart-quickstart/test/shoppingcart.test.ts b/samples/ts/ts-shopping-cart-quickstart/test/shoppingcart.test.ts new file mode 100644 index 00000000..fb4a38dc --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/test/shoppingcart.test.ts @@ -0,0 +1,196 @@ +/* + * 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. + */ + +import { MockEventSourcedEntity } from "@kalix-io/testkit"; +import { expect } from "chai"; +import shoppingCart from "../src/shoppingcart"; + +const ItemAdded = shoppingCart.lookupType("shopping.cart.domain.ItemAdded"); +const ItemRemoved = shoppingCart.lookupType("shopping.cart.domain.ItemRemoved"); + +describe("ShoppingCart", () => { + const entityId = "entityId"; + + describe("AddItem", () => { + it("should add items to the cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "b", + name: "Banana", + quantity: 2, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "c", + name: "Cantaloupe", + quantity: 3, + }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ItemAdded.create({ item: { productId: "c", name: "Cantaloupe", quantity: 3 } }), + ]); + }); + + it("should fail to add items with invalid quantity", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "z", + name: "Zilch", + quantity: 0, + }); + + expect(entity.error).to.equal("Quantity for item z must be at least one."); + expect(entity.state.items).to.be.empty; + expect(entity.events).to.be.empty; + }); + }); + + describe("RemoveItem", () => { + it("should remove items from a cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "b", + name: "Banana", + quantity: 2, + }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ]); + + await entity.handleCommand("RemoveItem", { cartId: "cart1", productId: "a" }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "b", name: "Banana", quantity: 2 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ItemRemoved.create({ productId: "a" }), + ]); + }); + + it("should fail to remove items that don't exist", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("RemoveItem", { + cartId: "cart1", + productId: "x", + }); + + expect(entity.error).to.equal("Cannot remove item x because it is not in the cart."); + expect(entity.state.items).to.be.empty; + expect(entity.events).to.be.empty; + }); + }); + + describe("GetCart", () => { + it("should default to an empty cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + const response = await entity.handleCommand("GetCart", { entityId }); + + expect(response).to.deep.equal({}); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.be.empty; + expect(entity.events).to.be.empty; + }); + + it("should retrieve a cart", async () => { + const entity = new MockEventSourcedEntity(shoppingCart, entityId); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "a", + name: "Apple", + quantity: 1, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "b", + name: "Banana", + quantity: 2, + }); + + await entity.handleCommand("AddItem", { + cartId: "cart1", + productId: "c", + name: "Cantaloupe", + quantity: 3, + }); + + expect(entity.error).to.be.undefined; + expect(entity.state.items).to.deep.equal([ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ]); + expect(entity.events).to.deep.equal([ + ItemAdded.create({ item: { productId: "a", name: "Apple", quantity: 1 } }), + ItemAdded.create({ item: { productId: "b", name: "Banana", quantity: 2 } }), + ItemAdded.create({ item: { productId: "c", name: "Cantaloupe", quantity: 3 } }), + ]); + + const response = await entity.handleCommand("GetCart", { cartId: "cart1" }); + + expect(response).to.deep.equal({ + items: [ + { productId: "a", name: "Apple", quantity: 1 }, + { productId: "b", name: "Banana", quantity: 2 }, + { productId: "c", name: "Cantaloupe", quantity: 3 }, + ], + }); + }); + }); +}); diff --git a/samples/ts/ts-shopping-cart-quickstart/tsconfig.json b/samples/ts/ts-shopping-cart-quickstart/tsconfig.json new file mode 100644 index 00000000..ce8770dd --- /dev/null +++ b/samples/ts/ts-shopping-cart-quickstart/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "strict": true, + "strictFunctionTypes": true, + "target": "es6", + "module": "commonJS" + }, + "include": ["./src/**/*"] +}