I previously wrote about using Temporal's long-lived Workflows to build REST APIs without a conventional database, but the REST layer was written manually and with a lot of boilerplate.
While you can always build a REST API on top of Temporal Workflows yourself, I've now built temporal-rest
to make creating a RESTful API for your Workflows a one-liner with ExpressJS.
Just add middleware!
In this blog post, I'll show how you can use temporal-rest
to easily create RESTful APIs backed by long-lived Workflows.
Temporal without REST
The key idea of long-lived Workflows is that Workflow functions are deterministic, so Temporal can persist the state of the Workflow by storing the Workflow's initial state and event history. For example, below is a Workflow that keeps a single numeric counter. The Workflow listens for an increment
Signal to increase the counter, and a getCount
Query to get the current value of the counter.
import * as wf from '@temporalio/workflow';
export const incrementSignal = wf.defineSignal('increment');
export const getCountQuery = wf.defineQuery('getCount');
export async function counterWorkflow(): Promise<void> {
let count = 0;
wf.setHandler(exports.incrementSignal, () => ++count);
wf.setHandler(exports.getCountQuery, () => count);
// Wait forever
await wf.condition(() => false);
}
To execute an instance of counterWorkflow
, use WorkflowClient
to start the Workflow:
import { WorkflowClient } from '@temporalio/client';
import { counterWorkflow } from './workflows';
import crypto from 'crypto';
async function run() {
const client = new WorkflowClient();
const handle = await client.start(counterWorkflow, {
taskQueue: 'tutorial',
// Usually, we use a business ID. In this example, we don't have one, so we generate an ID.
workflowId: 'counter-' + crypto.randomUUID(),
});
// Increment the counter
await handle.signal('increment');
// Prints "1"
console.log(await handle.query('getCount'));
}
run().catch(err => {
console.error(err);
process.exit(1);
});
Introducing temporal-rest
Running counterWorkflow
from this command-line script is convenient for the sake of an example, but not very useful unless you're building a command-line app. Enter temporal-rest
, which you can use to create an Express API for counterWorkflow
:
import { WorkflowClient } from '@temporalio/client';
import * as workflows from './workflows';
import express from 'express';
import { createExpressMiddleware } from 'temporal-rest';
async function run() {
const client = new WorkflowClient();
const app = express();
// this is the oneliner that does all the work!
app.use(createExpressMiddleware(workflows, client, 'tutorial'));
await app.listen(3000);
console.log('Listening on port 3000');
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
The createExpressMiddleware()
function creates an Express router with 3 endpoints:
POST /workflow/counterWorkflow
: start a new instance ofcounterWorkflow
.GET /query/getCount/:workflowId
: execute thegetCount
Query on the Workflow with the given ID.PUT /signal/increment/:workflowId
: send anincrement
Signal to the Workflow with the given ID.
Below is an example of interacting with this API using curl
. A POST to /workflow/counterWorkflow
creates a new Workflow instance and returns the Workflow ID. Then you can send a Signal to increment the counter, and execute a Query to get the current state of the counter:
$ curl -X POST http://localhost:3000/workflow/counterWorkflow
{"workflowId":"4cb1b1ea-b962-419e-840c-5c18ab5555a1"}$
$ curl "http://localhost:3000/query/getCount/4cb1b1ea-b962-419e-840c-5c18ab5555a1"
{"result":0}
$ curl -X PUT "http://localhost:3000/signal/increment/4cb1b1ea-b962-419e-840c-5c18ab5555a1"
{"received":true}
$ curl "http://localhost:3000/query/getCount/4cb1b1ea-b962-419e-840c-5c18ab5555a1"
{"result":1}
REST endpoints for Queries and Signals
You can also invoke Signals and Queries with temporal-rest
. By default, temporal-rest
parses any JSON in the Express request body and passes the parsed object as the first parameter to Signals, and passes the Express query parameters as the first parameter to Queries.
For example, suppose counterWorkflow
supports tracking multiple counters, each one with a unique name:
import * as wf from '@temporalio/workflow';
export const incrementSignal = wf.defineSignal('increment');
export const getCountQuery = wf.defineQuery('getCount');
export async function counterWorkflow(): Promise<void> {
const counters = new Map<string, number>();
wf.setHandler(incrementSignal, (args: { name: string }) => {
const count = counters.get(args.name);
if (count !== undefined) {
counters.set(args.name, count + 1);
} else {
counters.set(args.name, 1);
}
});
wf.setHandler(getCountQuery, (args: { name: string }) => {
if (!counters.has(args.name)) {
return 0;
}
return counters.get(args.name);
});
// Wait forever
await wf.condition(() => false);
}
To create a new counter with this Workflow, we would normally send an increment
Signal with an object containing the counter's name
:
const handle = await client.start(counterWorkflow, {
taskQueue: 'tutorial',
workflowId: 'counter-' + crypto.randomUUID(),
});
// Increment a new counter
await handle.signal('increment', { name: 'test-counter' });
console.log(await handle.query('getCount', { name: 'test-counter' }));
// => "1"
console.log(await handle.query('getCount', { name: 'other-counter' }));
// => "0" because there's no counter named 'other-counter'
Instead, we can now expose these Signals via a RESTful API with the temporal-rest
middleware the same as we did above:
app.use(createExpressMiddleware(workflows, client, 'tutorial'));
To pass a parameter to the increment
Signal, we pass a JSON-encoded HTTP request body to the Signal's POST endpoint, and to pass a parameter to the getCount
Query, we add a query string to the Query's GET endpoint.
Here's an example using curl
:
$ curl -X POST http://localhost:3000/workflow/counterWorkflow
{"workflowId":"cbc5924c-1afc-45e0-b7d6-e8fe1a250089"}
$ curl -X PUT -H "Content-Type: application/json" -d '{"name":"test-counter"}' http://localhost:3000/signal/increment/cbc5924c-1afc-45e0-b7d6-e8fe1a250089
{"received":true}
$ curl http://localhost:3000/query/getCount/cbc5924c-1afc-45e0-b7d6-e8fe1a250089?name=test-counter
{"result":1}
$ curl http://localhost:3000/query/getCount/cbc5924c-1afc-45e0-b7d6-e8fe1a250089?name=other-counter
{"result":0}
Here's an alternative example of making requests to this API using the Axios HTTP client in Node.js:
let res = await axios.post('http://localhost:3000/workflow/counterWorkflow');
console.log(res.data); // "{ workflowId: '5232d34e-1c65-4d38-8470-39ec03b0eb02' }"
const { workflowId } = res.data;
res = await axios.put('http://localhost:3000/signal/increment/' + workflowId, {
name: 'test-counter'
});
console.log(res.data); // "{ received: true }"
res = await axios.get('http://localhost:3000/query/getCount/' + workflowId, {
params: { name: 'test-counter' }
});
console.log(res.data); // "{ result: 1 }"
Moving On
Long-lived Workflows in Temporal let you build durable, scalable RESTful APIs without a traditional database. The temporal-rest
package removes the boilerplate of wrapping Temporal Workflows, Queries, and Signals in an API. Just write your Workflows, and temporal-rest
takes care of the Express API. Try temporal-rest
out and let us know what you think in the comments or GitHub issues.