Skip to content

Express

Routes call one Rungs command each. This code runs against a real Express server in CI.

const app = express();
app.use(express.json());
app.post("/expenses/:id/submit", async (req, res, next) => {
try {
const request = await rungs.submit({
flow: "expense",
requesterId: userId(req), // the actor comes from the session, never from the body
subject: { type: "expense", id: req.params.id },
payload: { amount: Number(req.body.amount) },
idempotencyKey: req.header("idempotency-key"), // a retried POST submits once
});
res.status(201).json(request);
} catch (error) {
next(error);
}
});
app.post("/requests/:id/approve", async (req, res, next) => {
try {
res.json(await rungs.approve(approveInput(req)));
} catch (error) {
next(error);
}
});
app.get("/inbox", async (req, res, next) => {
try {
res.json(await rungs.inbox({ userId: userId(req), after: req.query.after as string }));
} catch (error) {
next(error);
}
});

Runs in CI:examples/docs-snippets/src/guides/express.ts

  • The actor comes from authentication, here a header that stands in for your session or token. Never accept an actorId from the request body.
  • expectedVersion carries the version the user saw, so a decision on stale data is refused (EC-15).
  • idempotencyKey from the Idempotency-Key header makes a retried POST create one request (EC-16).

Every command method has a named input type, so the code that builds a command from an HTTP request can be typed on its own:

// Each command method has a named input type (`ApproveInput`, `RejectInput`, ...), so the code
// that turns an HTTP request into a command can be typed and tested on its own.
function approveInput(req: Request): ApproveInput {
return {
requestId: String(req.params.id),
actorId: userId(req),
expectedVersion: req.body.version, // the version the approver was looking at
comment: req.body.comment,
};
}

Runs in CI:examples/docs-snippets/src/guides/express.ts

The others are SubmitInput, RejectInput, ReturnInput, ResubmitInput, WithdrawInput, DelegateInput, ReassignInput, RetryInput and OverrideInput.

Every Rungs error extends RungsError and has a stable code. Map them once:

// Map Rungs errors to HTTP by class (or by their stable `code`), never by message text.
app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
if (!(error instanceof RungsError)) return next(error);
const status =
error instanceof RequestNotFoundError
? 404
: error instanceof NotAssignedError
? 403
: error instanceof ConflictError || error instanceof InvalidTransitionError
? 409
: error instanceof InvalidCommandError
? 400
: 422;
res.status(status).json({ code: error.code, message: error.message });
});

Runs in CI:examples/docs-snippets/src/guides/express.ts

The errors reference lists every code.