HATEOAS State Transitions — Modeling Resource State Machines with Links
In this tutorial, you will learn about HATEOAS State Transitions. We cover key concepts, practical examples, and best practices to help you master this topic.
HATEOAS state transitions model resources as state machines where the available links and actions change based on the resource's current state, guiding clients through valid operations.
What You'll Learn
- State machine modeling for REST resources
- Showing state-dependent links
- Preventing invalid state transitions
- Client guidance through available actions
- Workflow Orchestration with HATEOAS
Why It Matters
A threat in "analyzing" state can't be quarantined — only canceled. A device in "quarantined" state can't be quarantined again. HATEOAS naturally prevents invalid operations by only showing links for actions that are valid in the current state. DodaTech's Durga Antivirus Pro uses state-based links to guide the admin dashboard through the threat response workflow.
Real-World Use
A threat is detected and enters "new" state. The API shows links for "analyze" and "ignore". When the user clicks "analyze", the state changes to "analyzing" and the API now shows "cancel" and "quarantine" links. When analysis completes, state becomes "identified" with "remediate" and "report" links.
stateDiagram-v2
[*] --> New: Threat Detected
New --> Analyzing: dt:analyze
New --> Ignored: dt:ignore
Analyzing --> Identified: dt:classify
Analyzing --> New: dt:cancel
Identified --> Quarantined: dt:quarantine
Identified --> Remediated: dt:remediate
Quarantined --> Remediated: dt:clean
Quarantined --> New: dt:false-positive
Remediated --> [*]: Resolved
Ignored --> [*]: Closed
Code Examples
Example 1: State Machine Configuration
// State machine definition for Threat resource
const threatStates = {
new: {
label: 'New',
actions: [
{ rel: 'dt:analyze', method: 'POST', href: '/threats/{id}/analyze', title: 'Analyze Threat' },
{ rel: 'dt:ignore', method: 'POST', href: '/threats/{id}/ignore', title: 'Ignore Threat' },
],
transitionsTo: ['analyzing', 'ignored'],
},
analyzing: {
label: 'Analyzing',
actions: [
{ rel: 'dt:cancel', method: 'POST', href: '/threats/{id}/cancel-analysis', title: 'Cancel Analysis' },
{ rel: 'dt:classify', method: 'POST', href: '/threats/{id}/classify', title: 'Classify Threat' },
],
transitionsTo: ['new', 'identified'],
},
identified: {
label: 'Identified',
actions: [
{ rel: 'dt:quarantine', method: 'POST', href: '/threats/{id}/quarantine', title: 'Quarantine' },
{ rel: 'dt:remediate', method: 'POST', href: '/threats/{id}/remediate', title: 'Remediate' },
],
transitionsTo: ['quarantined', 'remediated'],
},
quarantined: {
label: 'Quarantined',
actions: [
{ rel: 'dt:clean', method: 'POST', href: '/threats/{id}/clean', title: 'Clean Device' },
{ rel: 'dt:false-positive', method: 'POST', href: '/threats/{id}/false-positive', title: 'Mark False Positive' },
],
transitionsTo: ['remediated', 'new'],
},
remediated: {
label: 'Remediated',
actions: [],
transitionsTo: [],
},
ignored: {
label: 'Ignored',
actions: [],
transitionsTo: [],
},
};
function getActionsForState(threatId, state) {
const stateDef = threatStates[state];
if (!stateDef) return [];
return stateDef.actions.map(action => ({
...action,
href: action.href.replace('{id}', threatId),
}));
}
// Validate transition
function canTransition(threat, targetState) {
const stateDef = threatStates[threat.status];
if (!stateDef) return false;
return stateDef.transitionsTo.includes(targetState);
}
Example 2: State-Dependent Resource Response
const express = require('express');
const app = express();
// Middleware to add state-dependent links
function addStateLinks(req, res, next) {
const originalJson = res.json.bind(res);
res.json = function(body) {
if (body && body._links) {
// Get state-dependent actions
const state = body.status || 'new';
const stateActions = getActionsForState(body.id, state);
// Add state-dependent links
for (const action of stateActions) {
body._links[action.rel] = {
href: action.href,
title: action.title,
method: action.method,
};
}
}
return originalJson(body);
};
next();
}
app.use(addStateLinks);
app.get('/threats/:id', (req, res) => {
const threat = getThreat(req.params.id);
res.json({
...threat,
status: threat.status,
_links: {
self: { href: `/threats/${threat.id}` },
collection: { href: '/threats' },
},
});
});
// Execute state transition
app.post('/threats/:id/:action', (req, res) => {
const threat = getThreat(req.params.id);
const action = req.params.action;
// Map action to target state
const actionStateMap = {
analyze: 'analyzing',
ignore: 'ignored',
cancel: 'new',
classify: 'identified',
quarantine: 'quarantined',
remediate: 'remediated',
clean: 'remediated',
'false-positive': 'new',
};
const targetState = actionStateMap[action];
if (!targetState || !canTransition(threat, targetState)) {
return res.status(422).json({
error: `Cannot transition from ${threat.status} to ${targetState}`,
_links: {
self: { href: `/threats/${threat.id}` },
},
});
}
// Update state
const updated = updateThreatState(threat.id, targetState);
res.json({
...updated,
_links: {
self: { href: `/threats/${updated.id}` },
},
});
});
Example 3: Client Navigating State Machines
class StateMachineClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async fetch(url) {
const res = await fetch(`${this.baseUrl}${url}`);
return res.json();
}
async getAvailableActions(threatId) {
const threat = await this.fetch(`/threats/${threatId}`);
console.log(`Threat: ${threat.name}`);
console.log(`Current state: ${threat.status}`);
// Available actions are in _links, filtered by state
const actions = Object.entries(threat._links || {})
.filter(([rel]) => rel.startsWith('dt:') && rel !== 'dt:scan' && rel !== 'dt:deep-scan')
.map(([rel, link]) => ({
rel,
href: link.href,
title: link.title,
}));
console.log('Available actions:');
for (const action of actions) {
console.log(` [${action.rel}] ${action.title}`);
}
return { threat, actions };
}
async executeAction(threatId, rel) {
const threat = await this.fetch(`/threats/${threatId}`);
const action = threat._links[rel];
if (!action) {
throw new Error(`Action '${rel}' not available in current state`);
}
console.log(`Executing: ${action.title}`);
const res = await fetch(`${this.baseUrl}${action.href}`, {
method: action.method || 'POST',
headers: { 'Content-Type': 'application/json' },
});
return res.json();
}
async walkThroughWorkflow(threatId) {
// Navigate through the state machine step by step
let currentState = 'new';
const workflow = [
'dt:analyze',
'dt:classify',
'dt:quarantine',
'dt:clean',
];
for (const action of workflow) {
const { threat } = await this.getAvailableActions(threatId);
console.log(`\n--- Step: ${action} ---`);
const result = await this.executeAction(threatId, action);
console.log(`New state: ${result.status}`);
currentState = result.status;
}
console.log('\nWorkflow complete!');
}
}
// Usage
const client = new StateMachineClient('https://api.dodatech.com');
await client.walkThroughWorkflow('threat-101');
Common Mistakes
- Showing invalid actions — never show links for actions that can't be executed in the current state. This misleads clients and causes 422 errors.
- Not documenting the state machine — clients need to understand the full state machine. Include a state diagram in your API documentation or serve it via a link.
- Allowing invalid transitions server-side — even if the client somehow calls an invalid action, the server must reject it with a proper error.
- Hardcoding state transitions in clients — clients should discover available transitions from links, not hardcode which actions follow which state.
- Ignoring race conditions — two clients might both try to transition the same resource. Use optimistic locking (ETags) to prevent double transitions.
Practice Questions
- How does HATEOAS naturally prevent invalid state transitions?
- Why should actions change based on resource state?
- How do you handle race conditions in state transitions?
- What should the server return when an invalid transition is attempted?
- How do clients discover available transitions?
Challenge: Design a state machine for a vulnerability management workflow with states: open, triaging, in-progress, resolved, verified, closed. Define available actions and transitions for each state. Implement as a HATEOAS API with state-dependent links.
Mini Project
Build a HATEOAS workflow engine with: state machine definition per resource type, automatic link filtering based on current state, transition validation with error handling, optimistic locking for concurrent transitions, and a client library that walks through workflows automatically.
FAQ
What's Next
Learn about HATEOAS actions and forms
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro