HATEOAS Link Discovery — How Clients Find and Follow Links at Runtime
In this tutorial, you will learn about HATEOAS Link Discovery. We cover key concepts, practical examples, and best practices to help you master this topic.
HATEOAS link discovery is the process by which clients find relevant URLs at runtime by examining link relations in API responses, eliminating the need for hardcoded URLs and enabling runtime API navigation.
What You'll Learn
- Link discovery strategies for clients
- IANA and custom link relations
- Curie-based compact link URIs
- Following links programmatically
- Caching discovered URLs
- Handling missing or changed links
Why It Matters
The core promise of HATEOAS is that clients don't need to know URLs. Instead, they discover them by following links. This decouples clients from server URL structures, allowing the server to reorganize URLs without breaking clients. DodaTech's Durga Antivirus Pro clients discover all API endpoints at startup by following links from a single root URL.
Real-World Use
A monitoring client starts by fetching GET /api. The response contains links to /devices, /threats, /scans, and /reports. The client caches these URLs. When the server updates its URL structure in a new version, the root URL's links change, and the client adapts automatically.
sequenceDiagram
participant Client
participant API
Client->>API: GET /api (root)
API-->>Client: _links: { devices, threats, scans }
Client->>Client: Cache discovered URLs
Client->>API: Follow /devices
API-->>Client: _links: { self, threats, quarantine }
Client->>API: Follow /devices/1/threats
API-->>Client: _links: { self, next, analyze }
Client->>Client: Navigate by following links
Note over Client,API: No hardcoded URLs anywhere
Code Examples
Example 1: Link Discovery Client in JavaScript
class HypermediaClient {
constructor(rootUrl) {
this.rootUrl = rootUrl;
this.linkCache = new Map();
this.followHistory = [];
}
// Discover links from a response
discoverLinks(response) {
const links = {};
// HAL-style _links
if (response._links) {
for (const [rel, link] of Object.entries(response._links)) {
if (Array.isArray(link)) {
links[rel] = link.map(l => l.href);
} else if (link.href) {
links[rel] = link.href;
}
}
}
// Siren-style links array
if (response.links) {
for (const link of response.links) {
const rel = link.rel[0] || link.rel;
links[rel] = link.href;
}
}
// Siren-style actions
if (response.actions) {
for (const action of response.actions) {
links[`action:${action.name}`] = {
href: action.href,
method: action.method,
fields: action.fields,
};
}
}
return links;
}
// Follow a link by relation
async follow(rel, options = {}) {
// Check cache first for rel->url mapping
let url = this.linkCache.get(rel);
if (!url) {
// Need to discover this link from current resource
const current = await this.getCurrentResource();
const links = this.discoverLinks(current);
url = links[rel];
if (!url) {
throw new Error(`Link '${rel}' not found in current resource`);
}
// Cache the discovered URL
if (typeof url === 'string') {
this.linkCache.set(rel, url);
}
}
// Obtain the URL
const href = typeof url === 'string' ? url : url.href;
this.followHistory.push({ rel, href, timestamp: Date.now() });
// Determine HTTP method
const method = (url && url.method) || 'GET';
const fetchOptions = {
method,
headers: { 'Accept': 'application/json' },
};
if (options.data && method !== 'GET') {
fetchOptions.body = JSON.stringify(options.data);
fetchOptions.headers['Content-Type'] = 'application/json';
}
const res = await fetch(href, fetchOptions);
const data = await res.json();
// Auto-discover links from response
const newLinks = this.discoverLinks(data);
for (const [newRel, newUrl] of Object.entries(newLinks)) {
this.linkCache.set(newRel, newUrl);
}
return data;
}
// Start from root and navigate
async start() {
console.log('Starting from root:', this.rootUrl);
const root = await this.follow(this.rootUrl);
console.log('Discovered links:',
Array.from(this.linkCache.keys()));
// Follow links to explore the API
if (this.linkCache.has('devices')) {
const devices = await this.follow('devices');
console.log('Devices retrieved');
}
return root;
}
}
// Usage
const client = new HypermediaClient('https://api.dodatech.com');
await client.start();
// Client now has all URLs cached by relation
const threats = await client.follow('threats');
Example 2: Link Relation Types
// IANA standard link relations
const IANA_RELS = {
self: 'The resource itself',
next: 'Next page of results',
prev: 'Previous page of results',
first: 'First page of results',
last: 'Last page of results',
collection: 'Collection of resources',
item: 'Item within a collection',
edit: 'Edit this resource',
delete: 'Delete this resource',
describedBy: 'Schema or documentation',
payment: 'Payment endpoint',
license: 'License information',
};
// Custom link relations with CURIE format
const CUSTOM_RELS = {
'dt:quarantine': 'Quarantine a device',
'dt:analyze': 'Run threat analysis',
'dt:scan': 'Initiate a scan',
'dt:reports': 'Access reports',
'dt:config': 'Device configuration',
};
// Link discovery with relation validation
function validateLink(rel, links) {
// IANA relations don't need prefix
if (IANA_RELS[rel]) return true;
// Custom relations should use CURIE
if (rel.includes(':')) {
const [prefix, name] = rel.split(':');
const curie = links._links?.curies?.find(c => c.name === prefix);
if (curie) return true;
console.warn(`Custom relation '${rel}' found but no CURIE documentation`);
}
return false;
}
// Client that handles both standard and custom links
async function discoverAndValidate(apiResponse) {
const links = apiResponse._links || apiResponse.links;
if (!links) return;
const discovered = [];
for (const [rel, value] of Object.entries(links)) {
if (rel === 'curies') continue;
const href = Array.isArray(value) ? value[0]?.href : value?.href;
const isStandard = IANA_RELS[rel] !== undefined;
discovered.push({
rel,
href,
type: isStandard ? 'IANA' : 'custom',
description: IANA_RELS[rel] ||
(CUSTOM_RELS[rel] || 'Undocumented custom relation'),
});
}
return discovered;
}
Example 3: Caching and Refreshing Discovered Links
class LinkCache {
constructor(ttlMs = 300000) { // 5 minute default TTL
this.cache = new Map();
this.defaultTtl = ttlMs;
}
set(rel, url, ttlMs) {
this.cache.set(rel, {
url,
expiresAt: Date.now() + (ttlMs || this.defaultTtl),
});
}
get(rel) {
const entry = this.cache.get(rel);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(rel);
return null; // Expired, need re-discovery
}
return entry.url;
}
invalidate(rel) {
this.cache.delete(rel);
}
invalidateAll() {
this.cache.clear();
}
}
class ResilientClient {
constructor(rootUrl) {
this.rootUrl = rootUrl;
this.linkCache = new LinkCache();
this.currentResource = null;
this.currentUrl = rootUrl;
this.retries = 3;
}
async follow(rel) {
let url = this.linkCache.get(rel);
// If not cached, try to discover from current resource
if (!url && this.currentResource) {
const links = this.discover(this.currentResource);
if (links[rel]) {
url = links[rel];
this.linkCache.set(rel, url);
}
}
// If still not found, go back to root and re-discover
if (!url) {
console.log(`Link '${rel}' not in cache, rediscovering from root`);
await this.fetchAndCache(this.rootUrl);
const links = this.discover(this.currentResource);
url = links[rel];
if (!url) {
throw new Error(`Link '${rel}' not found even after rediscovery`);
}
}
return this.fetchWithRetry(url, this.retries);
}
async fetchAndCache(url) {
const res = await fetch(url);
const data = await res.json();
this.currentResource = data;
this.currentUrl = url;
const links = this.discover(data);
for (const [rel, href] of Object.entries(links)) {
this.linkCache.set(rel, href);
}
return data;
}
async fetchWithRetry(url, retriesLeft) {
try {
return await this.fetchAndCache(url);
} catch (error) {
if (retriesLeft > 0 && this.isRetryable(error)) {
// Link may have changed, invalidate cache
this.linkCache.invalidateAll();
return this.fetchWithRetry(url, retriesLeft - 1);
}
throw error;
}
}
}
Common Mistakes
- Hardcoding URLs despite having hypermedia — some developers add _links but still hardcode URLs in client code. Always follow links instead.
- Not caching discovered URLs — discovering links on every request adds latency. Cache discovered URLs with a reasonable TTL.
- Ignoring link relations that change — when the server renames a relation, cached URLs point to dead ends. Handle 404s by re-discovering from root.
- Using only one link discovery Strategy — support both HAL _links and Siren links/actions. Different API styles use different formats.
- Not discovering links from embedded resources — embedded sub-entities have their own links. Discover and cache them too.
Practice Questions
- How does a HATEOAS client discover links at runtime?
- What is the difference between IANA and custom link relations?
- Why should clients cache discovered URLs?
- How does a client handle a link that no longer exists?
- What is the role of CURIE in link discovery?
Challenge: Build a HATEOAS client that discovers all available endpoints from a single root URL, caches them with TTL, follows pagination links automatically, handles missing links by re-discovering from root, and supports both HAL and Siren formats.
Mini Project
Build a generic HATEOAS discovery client with: root URL entry point, automatic link discovery from HAL/Siren responses, link caching with configurable TTL, automatic pagination via next links, fallback to root re-discovery on broken links, and a CLI interface that lets users navigate by link relation names.
FAQ
What's Next
Learn about HATEOAS links and relations
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro