Shopify Hydrogen App Development Tutorial 101 Part 4 - Partner App Backend Integration using Temporal.io (Partner Backend API - ep 3)
1. Introduction
In episode 1 and 2, we have created a long-living shopping cart built on Temporal with an e-money payment and email notification. We are almost there to a fully functional app. For this last part, we’re going to build a REST API to run the signals that we created before in Workflows.
2. REST-API with express
Since the API written using express is considered a Temporal client (since it interacts with the existing Workflow), it will use classes from @temporalio/client
Connect to the existing workflow.
src/api/main.js
import { Connection, WorkflowClient } from '@temporalio/client';
import { CartWorkflow } from '../workflows.js';
async function run() {
const connection = new Connection({
// // Connect to localhost with default ConnectionOptions.
// // In production, pass options to the Connection constructor to configure TLS and other settings:
// address: 'foo.bar.tmprl.cloud', // as provisioned
// tls: {} // as provisioned
});
const client = new WorkflowClient(connection.service, {
// namespace: 'default', // change if you have a different namespace
});
const result = await client.start(CartWorkflow , {
taskQueue: 'temporal-ecommerce',
// in practice, use a meaningful business id, eg customerId or transactionId
workflowId: temporal-ecommerce-business-id-45,
args: [],
});
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
The above code will connect to the CartWorkflow Workflow that we wrote in part 2. An Express web server process can run in this client.
src/api/main.js
import { Connection, WorkflowClient } from '@temporalio/client';
import { CartWorkflow } from '../workflows.js';
import { createExpressMiddleware } from 'temporal-rest';
import express from 'express';
import bodyparser from 'body-parser';
async function run() {
const connection = new Connection({
// // Connect to localhost with default ConnectionOptions.
// // In production, pass options to the Connection constructor to configure TLS and other settings:
// address: 'foo.bar.tmprl.cloud', // as provisioned
// tls: {} // as provisioned
});
const client = new WorkflowClient(connection.service, {
// namespace: 'default', // change if you have a different namespace
});
// express
const app = express();
app.use(createExpressMiddleware(CartWorkflow, client, 'express-ecommerce'));
// notes: body parser needed (if not included, can't read request data)
// parse application/x-www-form-urlencoded
app.use(bodyparser.urlencoded({ extended: false }));
// parse application/json
app.use(bodyparser.json());
await app.listen(8393);
console.log('listening on port 8393');
const result = await client.start(CartWorkflow , {
taskQueue: 'temporal-ecommerce',
// in practice, use a meaningful business id, eg customerId or transactionId
workflowId: 'temporal-ecommerce-business-id-45',
args: [],
});
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
Now our app is successfully listening on port 8393.
3. Shopping Cart Routes
Since we have express already available in our app. Let’s make it possible to do some interactions with an API. The following are the available routes and its corresponding activity that we are going to write.
-
GET /products
Receive a list of available products
-
POST /cart
Create a shopping cart
-
GET /cart/{workflowID}
Get the current state of the shopping cart
-
PUT /cart/{workflowID}/add
Add item to cart
-
PUT /cart/{workflowID}/remove
Remove item from cart
-
PUT /cart/{workflowID}/checkout
Proceed to checkout
Now that we know all the routes needed, let’s get on to create it.
3.1. Receive a list of available products
For this method, it will return the user a list of available products as the name suggests. But we haven’t had a list of products lying around, we need to create one–a simple JS file that exports a list of product objects.
src/products.js
export const products = [
{
id: '0',
name: 'iPhone 12 Pro',
description: 'Shoot amazing videos and photos with the Ultra Wide, Wide, and Telephoto cameras.',
image: 'https://images.unsplash.com/photo-1603921326210-6edd2d60ca68',
price: 999,
},
{
id: '1',
name: 'iPhone 12',
description: 'The iPhone 12 sports a gorgeous new design, full 5G support, great cameras and even better performance',
image: 'https://images.unsplash.com/photo-1611472173362-3f53dbd65d80',
price: 699,
},
{
id: '2',
name: 'iPhone SE',
description: 'The Most Affordable iPhone Features A13 Bionic, the Fastest Chip in a Smartphone, and the Best Single-Camera System in an iPhone',
image: 'https://images.unsplash.com/photo-1529618160092-2f8ccc8e087b',
price: 399,
},
{
id: '3',
name: 'iPhone 11',
description: 'The iPhone 11 offers superb cameras, fast performance and excellent battery life for an affordable price',
image: 'https://images.unsplash.com/photo-1574755393849-623942496936',
price: 599,
}
];
This products will be imported and saved as a state in Workflow so it can be retrievable from outside.
src/workflows.js
import * as wf from '@temporalio/workflow';
import { products as initialProducts } from './products.js'; // import products
const { createMidtransPayment, sendAbandonedCartEmail } = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
// create ProductsState (for querying GET /products)
export const ProductsState = useState("ProductsState", initialProducts);
// ....
export async function CartWorkflow() {
// ....
}
/** Utility state function */
// ...
Now it is accessible for outside of Workflow, import ProductsState on our temporal web server client.
src/api/main.js
import { Connection, WorkflowClient } from '@temporalio/client';
import {
CartWorkflow,
ProductsState,
} from '../workflows.js';
import { createExpressMiddleware } from 'temporal-rest';
import express from 'express';
import bodyparser from 'body-parser';
async function run() {
// ....
/** GET products */
app.get('/products', async (req, res) => {
// get products state from workflow
const productResponse = await ProductsState.value
// sending products (json response) to client
res.send({
status: '200 OK',
products: productResponse
});
});
await app.listen(8393);
console.log('listening on port 8393');
// ....
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
What the code above do is to retrieve the exported products state that came from Workflow, get the value of it, and send it as a response.
3.2. Create a shopping cart
For every shopping cart creation for every workflowId will need a name and email (two important arguments when sending an email to a customer). To accommodate tihs need, we would need a function to change the main state to whatever the user inputs.
These handlers and its signals must be written directly and exported from Workflow
Write the signals first
src/workflows.js
import * as wf from '@temporalio/workflow';
import { products as initialProducts } from './products.js'; // import products
const { createMidtransPayment, sendAbandonedCartEmail } = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
// create ProductsState (for querying GET /products)
export const ProductsState = useState("ProductsState", initialProducts);
// two handlers for update email and name data for cartstate
export const UpdateEmailSignal = wf.defineSignal('UpdateEmail');
export const UpdateNameSignal = wf.defineSignal('UpdateName');
// .... other signals that we wrote before
export async function CartWorkflow() {
// ....
}
/** Utility state function */
// ...
Handler to update the email and name state to a new value
src/workflows.js
// ....
/** Cart workflow */
export async function CartWorkflow() {
// create CartState
const CartState = useState("CartState", {items: [], status: 'IN_PROGRESS', email: '', name: ''});
// Update Email Handler
wf.setHandler(UpdateEmailSignal, function updateEmailSignal(email) {
// set cartState.email to user inputted email
CartState.value.email = email;
console.log('Update Email Handler finished, current email: ', email);
})
// Update Name Handler
wf.setHandler(UpdateNameSignal, function updateNameSignal(name) {
// set cartState.name to user inputted name
CartState.value.name = name;
console.log('Update Name Handler finished, current name: ', name);
})
// ....
}
/** Utility state function */
// ....
So when the POST /cart route is called, it will modify the current cart wate with the request body property value of email and name. It will return a respond containing the current cart and current workflow ID.
Import update email and name signals and write the method
src/api/main.js
import { Connection, WorkflowClient } from '@temporalio/client';
import {
CartWorkflow,
ProductsState,
UpdateEmailSignal,
UpdateNameSignal,
} from '../workflows.js';
import { createExpressMiddleware } from 'temporal-rest';
import express from 'express';
import bodyparser from 'body-parser';
async function run() {
// ....
/** GET products */
app.get('/products', async (req, res) => {
// ....
});
/** create cart */
app.post('/cart', async (req, res) => {
// would need to fetch current state of cart (with getHandle)
const handle = await client.getHandle('temporal-ecommerce-business-id-45');
await handle.signal(UpdateEmailSignal, req.body.email); // add emailto state
await handle.signal(UpdateNameSignal, req.body.name); // add name to state
// fetch new updated cartState
const currentCart = await handle.query("CartState")
// sending cart response (cart array and workflow ID) to client
res.send({
status: '200 OK',
body: 'creating cart...',
cart: currentCart,
workflowID: handle.workflowId
});
});
await app.listen(8393);
console.log('listening on port 8393');
// ....
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
3.3. Get the current state of the shopping cart
Getting all the property of the cart state is the simples of all methods, all we need to do is fetch the current state and return it as it is, with the request params property of workflow ID as its argument when getting the handle. No need to write additional code like before.
src/api/main.js
// ....
async function run() {
// ....
/** GET products */
app.get('/products', async (req, res) => {
// ....
});
/** create cart */
app.post('/cart', async (req, res) => {
// ....
});
/** get cart (workflowID) */
app.get('/cart/:workflowID', async (req, res) => {
// Functionality is almost the same as the above method, but we will be only returning the
// value of cart object
// get handler
const handle = await client.getHandle(req.params.workflowID); // from the /:workflowID
const currentCart = await handle.query("CartState");
// send cart state as json
res.send(currentCart);
});
await app.listen(8393);
console.log('listening on port 8393');
// ....
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
3.4. Add item to cart
Because we have declared the signal and handler since part 1, we just need to import its signal at the top and run the signal with the request property value of body. Add item to cart signal accepts a product object with the same pattern as the item in our list of products (in src.products.js).
src/api/main.js
import {
CartWorkflow,
ProductsState,
UpdateEmailSignal,
UpdateNameSignal,
AddToCartSignal,
} from '../workflows.js';
// ....
async function run() {
// ....
/** GET products */
app.get('/products', async (req, res) => {
// ....
});
/** create cart */
app.post('/cart', async (req, res) => {
// ....
});
/** get cart (workflowID) */
app.get('/cart/:workflowID', async (req, res) => {
// ....
});
/** add product to cart */
app.put('/cart/:workflowID/add', async (req, res) => {
// get our workflow handler
const handle = await client.getHandle(req.params.workflowID);
const cartState = await handle.query("CartState"); // get our cart state
// call our add to cart handler with req.body as product to add
await handle.signal(AddToCartSignal, req.body)
// send response to client
res.send({
status: '200 OK',
body: 'Add to cart successful!',
ok: req.body.quantity
})
});
await app.listen(8393);
console.log('listening on port 8393');
// ....
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
3.5. Remove item from cart
Like add item to cart method, this method only accepts a product object with properties of id, name, description, image, and price (id and price is mandatory). It will run RemoveFromCartSignal with req.body as its argument.
src/api/main.js
import {
CartWorkflow,
ProductsState,
UpdateEmailSignal,
UpdateNameSignal,
AddToCartSignal,
RemoveFromCartSignal
} from '../workflows.js';
// ....
async function run() {
// ....
/** GET products */
app.get('/products', async (req, res) => {
// ....
});
/** create cart */
app.post('/cart', async (req, res) => {
// ....
});
/** get cart (workflowID) */
app.get('/cart/:workflowID', async (req, res) => {
// ....
});
/** add product to cart */
app.put('/cart/:workflowID/add', async (req, res) => {
// ....
});
/** remove product from cart */
app.put('/cart/:workflowID/remove', async (req, res) => {
// get handler
const handle = await client.getHandle(req.params.workflowID);
// call remove product signal
await handle.signal(RemoveFromCartSignal, req.body);
res.send({
status: '200 OK',
body: 'Removed from cart',
id: req.body.id,
product: req.body.name,
quantity: req.body.quantity,
});
});
await app.listen(8393);
console.log('listening on port 8393');
// ....
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
3.6. Proceed to checkout
For checkout, beside calling the CheckoutSignal we will also query Transaction ID and QR Code value from Workflow to pass it as a response. There will be a delay for a few seconds inbetween the process to ensure that the queried value is not the initial value.
src/api/main.js
import {
CartWorkflow,
ProductsState,
UpdateEmailSignal,
UpdateNameSignal,
AddToCartSignal,
RemoveFromCartSignal,
CheckoutSignal
} from '../workflows.js';
// ....
async function run() {
// ....
/** GET products */
app.get('/products', async (req, res) => {
// ....
});
/** create cart */
app.post('/cart', async (req, res) => {
// ....
});
/** get cart (workflowID) */
app.get('/cart/:workflowID', async (req, res) => {
// ....
});
/** add product to cart */
app.put('/cart/:workflowID/add', async (req, res) => {
// ....
});
/** remove product from cart */
app.put('/cart/:workflowID/remove', async (req, res) => {
// ....
});
/** checkout */
app.put('/cart/:workflowID/checkout', async (req, res) => {
const handle = await client.getHandle(req.params.workflowID);
// call checkout signal
await handle.signal(CheckoutSignal);
// wait for a few second
const delay = ms => new Promise(res => setTimeout(res, ms));
await delay(5000);
console.log('delayed 5 seconds.')
// retrieve transaction id and qr code
const transactionId = await handle.query("TransactionId");
const qrCode = await handle.query("QRCode");
res.send({
status: '200 OK',
body: 'checked out.',
workflowID: handle.workflowId,
transactionID: transactionId,
qr: qrCode
});
});
await app.listen(8393);
console.log('listening on port 8393');
// ....
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
4. Handling After-Payment Notification
We have all the methods written, but not after-payment handling yet. If you look at the Workflow, the transaction will be successful only if the IsPaid state has a value of true. So how do we change this value to true if we already received the payment from the customer? We will use Webhook Payment Status ( the documentation of this topic can be accessed here: HTTP(S) Notification / Webhooks of Payment Status ).
We would need an exclusive route reserved just for listening to payment transaction notification. Because it listens to notification, the route would be named as it is.
src/api/main.js
// ....
async function run() {
// ....
/** GET products */
app.get('/products', async (req, res) => {
// ....
});
/** create cart */
app.post('/cart', async (req, res) => {
// ....
});
/** get cart (workflowID) */
app.get('/cart/:workflowID', async (req, res) => {
// ....
});
/** add product to cart */
app.put('/cart/:workflowID/add', async (req, res) => {
// ....
});
/** remove product from cart */
app.put('/cart/:workflowID/remove', async (req, res) => {
// ....
});
/** checkout */
app.put('/cart/:workflowID/checkout', async (req, res) => {
// ....
});
// notification listener to listen for payment fulfillment
app.post('/notification', async (req, res) => {
console.log('POST /notification received.');
console.log(req.body);
res.send({
status: '200 OK',
body: 'POST /notification successful.'
});
/** Notification Handler */
const handle = await client.getHandle('temporal-ecommerce-business-id-45');
// get transaction ID from workflow
const transactionId = await handle.query("TransactionId");
/** post-payment handling */
if (req.body.transaction_status === 'settlement' && req.body.transaction_id === await transactionId) {
// change transactionStatus to paid
await handle.signal(UpdateTransactionStatusSignal, 'PAID');
console.log('changed transaction status -> PAID')
console.log('change IsPaid to true');
// change IsPaid to true
await handle.signal("IsPaid", true);
}
});
await app.listen(8393);
console.log('listening on port 8393');
// ....
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
The code above listens to every transaction status change, when its value is equal to ‘SETTLEMENT’, that’s when the transaction is paid by the customer. When that time comes, we would want to change the IsPaid state to true and the Workflow will be finished.
To make our listener endpoint working, we need to expose our local server port to the internet and use that URL for midtrans’ Payment Notification URL (required to set it from the dashboard, I will show you later).
One way to do that is to use ngrok ( Installation Guide ). If you have already installed ngrok, from your project directory, type the following command:
./ngrok http 8393
It will give us an URL that we can set at midtrans dashboard (Settings–Configuration). Put the URL on the Payment Notification URL section like the following.
After doing all of this, all we need to do is run our app.
5. Running our App + End Notes
Running our App
To run our app, we need to open another two different terminal and run the following command.
In a terminal (and in your project directory), run:
npm run start.watch
This will start the Workflow (if you haven’t run the temporal server beforehand, the Workflow won’t run).
In another terminal,run the following command to start the express web server
cd src
node api/main.js
Using Postman to Test our API
a. Get products
b. Create cart
c. Get cart
d. Add item to cart
e. Delete item from cart
f. Checkout
After we checked out, it will create a transaction and return a QR code that you need to pay in order to finish the order.
When a new transaction is created, midtrans will send us a notification that this certain transaction now has a status of pending (waiting for payment fulfillment).
Because we are using a sandbox environment of midtrans, we can’t really pay it with real money, but we can simulate pay using midtrans mock payment .
Paste the QR link generated from the terminal and click the Scan QR button.
The transaction will be marked as successful.
After the transaction is now marked successful, midtrans will send us a notification that the status has changed (settlement) to our /notification route that we wrote before.
When a transaction has reached to this point, our workflow task will be finished ant it is marked as done.
But if we leave the workflow as it is without doing the payment and when the timeout expires, it will send an abandoned cart email to the registered email like the proceeding.
Next Up
After following this post, we can learn that we can build a RESTful API on top of Temporal by making HTTP requests, other than that it simplifies many jobs that usually require a scheduled job queue (like in this case, a separate job queue is needed to check for an abandoned cart and send an abandoned cart email) This isn't the only way you can build a RESTful API with Temporal, but this pattern works well if you use long-lived Workflows to store user data. Next up, we will integrate this partner backend with hydrogen (without the external payment we created in this part, of course).
Post a Comment