Shopify Hydrogen App Development Tutorial 101 Part 2 - Partner App Backend Integration using Temporal.io (Demo App Porting - ep 1)
1. Introduction
In this episode 1 blog post, we will create a demo eCommerce app using Temporal.io with Javascript. This post is the porting version from the original written in GoLang, you can check out the original post at https://docs.temporal.io/blog/build-an-ecommerce-app-with-temporal-part-1/
We will add a new feature for creating an e-money billing after the user checked out their cart that we’ll be visiting for the next part of this post. The e-money feature will only be included for testing since in the last part we will integrate this partner backend with hydrogen (hydrogen uses its own payment method (Shopify Payment)).
2. Getting Started
2.1. Download Temporalite
To run temporal locally (what we’ll be doing to run our app), we need to download the package. I will be using the temporalite version of the server because of the easy setup and no dependencies on a container runtime needed.
Build from source using go install:
Note: Go 1.17 or greater is currently required.
go install github.com/DataDog/temporalite/cmd/temporalite@latest
2.2. Create a New Project and Start Temporal Locally
2.2.1. Create a new project
Use the package initializer to create a new project. We will named it temporal-ecommerce.
npx @temporalio/create@latest ./temporal-ecommerce
cd example
This will create a project with a starter sample, when asked for which sample that would you like to use, choose the hello-world-js starter
2.2.2. Start temporal server
temporalite start -f my_test.db --namespace default --ip 0.0.0.0
At this point you should have a server running on localhost:7233 and a web interface at http://localhost:8233.
2.3. Installing Dependencies
Below are the dependencies we’ll need to install before moving on:- body-parser (v1.20.0)
- express (v4.18.1)
- midtrans-client (v1.3.1)
- node-mailjet (v3.3.13)
- temporal-rest (v0.4.0)
- uuid (v8.3.2)
npm i body-barser express midtrans-client node-mailjet temporal-rest uuid
2.4. Temporal Components
Below are temporal components that we will be seeing upon starter installation.
1. Activity
Activities are called from Workflows in order to run non-deterministic code
Any async function can be used as an Activity as long as its parameters and return value are serializable. Activities run in the Node.js execution environment, meaning you can easily port over any existing code into an Activity and it should work.
src/activities.js
2. Workflow
A Workflow is also an async function, but it has access to special Workflow APIs like Signals, Queries, Timers, and Child Workflows.
src/workflows.js
3. Worker
The Worker hosts Workflows and Activities, connects to Temporal Server, and continually polls a Task Queue for Commands coming from Clients
src/worker.js
4. Client
The WorkflowClient class is used to interact with existing Workflows or to start new ones./p>
It can be used in any Node.js process (for example, an Express web server) and is separate from the Worker.
src/client.js
3. Shopping Cart Workflow
Like what is explained in temporal.io docs I mentioned above, the ecommerce shopping cart app built is unlike the usual traditional architecture. Most common practice is to save the user cart activities in a database, but with temporal, you can represent a shopping cart as a long-living workflow.
For now, the shopping cart workflow would look like the following (as we haven’t added any functionalities)
src/workflows.js
import { proxyActivities } from '@temporalio/workflow';
const { greet } = proxyActivities({
startToCloseTimeout: '1 minute',
});
/** Cart workflow */
export async function CartWorkflow() {
console.log(‘Hello temporal!);
}
To run a Workflow, you need to create a Worker process. A Temporal Worker listens for events on a queue and has a list of registered Workflows that it can run in response to messages on the queue. Hello-world-js starter already included the worker file. We won’t need to write or change the file ourselves (we still need to change the TaskQueue value, though).
Below is the worker.
src/worker.js
/** Worker hosts workflows and activities, connects to temporal server,
* continually polls a task queue for commands
*/
import { Worker } from '@temporalio/worker';
import { URL } from 'url';
import * as activities from './activities.js';
async function run() {
// step 1: register Workflows and Activities
// and connect to the Temporal server
const worker = await Worker.create({
workflowsPath: new URL('./workflows.js', import.meta.url).pathname,
activities,
taskQueue: 'temporal-ecommerce'
});
// Step 2: Start accepting tasks on the 'tutorial' queue
await worker.run()
}
run().catch((err) => {
console.error(err);
process.exit(1)
})
4. Adding and removing product
When we’re talking about shopping cart, the main functionality of a cart is to allow users to put some things or remove some from the cart, In order to do that, workflow signals are needed (for both adding and removing items). Workflow then will listen to events triggering these signals and executing it. The code below is to define both add to cart and remove from cart signals.
src/workflows.js
import * as wf from '@temporalio/workflow';
const { greet } = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
/** Add To Cart Signal */
export const AddToCartSignal = wf.defineSignal('AddToCart');
/** Remove Product from Cart Signal*/
export const RemoveFromCartSignal = wf.defineSignal('RemoveFromCart');
/** Cart workflow */
export async function CartWorkflow() {
console.log(‘Hello temporal!);
}
The signals need to have handlers, so when these signals are called, it will execute the handler function. Both the signals are used to modify the cart state (where the user places their items). Since we haven’t created the cart state and signal handlers, the following is the code to do that.
Creating useState utility
src/workflows.js
// ....
/** Cart workflow */
export async function CartWorkflow() {
// ....
}
/** Utility state function */
function useState(name, initialValue) {
const signal = wf.defineSignal(name);
const query = wf.defineQuery(name);
let state = initialValue;
wf.setHandler(signal, (newValue) => {
console.log('Updating...', name, newValue);
state = newValue;
});
wf.setHandler(query, () => state);
return {
signal,
query,
get value() {
// need to use closure because function doesn't rerun unlike React hooks
return state;
},
set value(newVal) {
state = newVal;
}
}
}
Creating cart state
src/workflows.js
// ....
/** Cart workflow */
export async function CartWorkflow() {
// create CartState
const CartState = useState("CartState", {items: [], status: 'IN_PROGRESS', email: '', name: ''});
}
Now the handler for AddToCartSignal
src/workflows.js
// ....
/** Cart workflow */
export async function CartWorkflow() {
// create CartState
const CartState = useState("CartState", {items: [], status: 'IN_PROGRESS', email: '', name: ''});
/** Add to Cart Handler */
wf.setHandler(AddToCartSignal, function addToCartSignal(product) {
// find existing item
const existingItem = CartState.value.items.find(({ productId }) => productId === product.productId);
// if it already exists, add the quantity by product.quantity
if (existingItem !== undefined) {
existingItem.quantity += product.quantity;
} else {
CartState.value.items.push(product); // Instead if it doesn't exist, push the product to cart array
}
console.log('Add To Cart Handler done.')
});
}
If this certain product the user added have never existed on the cart, the workflow will add all the product attributes to the cart. If the product already exists, it will simply add the number of quantity by the number of product quantity added to the cart.
RemoveFromCart signal handler
src/workflows.js
// ....
/** Cart workflow */
export async function CartWorkflow() {
// create CartState
const CartState = useState("CartState", {items: [], status: 'IN_PROGRESS', email: '', name: ''});
/** Add to Cart Handler */
// ....
/** Remove From Cart Handler*/
wf.setHandler(RemoveFromCartSignal, function removeFromCartSignal(product) {
// find product index
const index = CartState.value.items.findIndex(({ productId }) => productId === product.productId);
// if product not found, return
if (index === -1) {
console.log('No product found: ', product);
return;
}
// if it doesn't pass the if check, it means the product is available. Find the existing item
const existingItem = CartState.value.items[index];
// remove the quantity of the product
existingItem.quantity -= product.quantity;
// if the quantity is reduced to 0, remove product from cart completely
if (existingItem.quantity <= 0) {
CartState.value.items.splice(index, 1); // at position index, remove that product in that position
};
console.log('Remove product handler done.');
});
}
Before removing the product, the handler will check whether the selected product already exists on the cart. After it passes the check, it will remove the quantity, and whenever the quantity drops to zero, the product will be deleted from the cart.
5. Next up
Although it doesn’t make things significantly simpler when writing this simple CRUD application like this. For the next post, we can see a case where temporal really shines (sending a cart abandonment email when it passes a timeout and ending the workflow when the transaction is settled) !
Post a Comment