HATEOAS Content Negotiation — Serving Multiple Hypermedia Formats from One API
In this tutorial, you will learn about HATEOAS Content Negotiation. We cover key concepts, practical examples, and best practices to help you master this topic.
HATEOAS content negotiation allows a single API endpoint to serve different hypermedia formats (HAL, Siren, JSON:API, Collection+JSON) based on the client's Accept header, enabling broad client compatibility without maintaining separate endpoints.
What You'll Learn
- Media type negotiation with Accept headers
- Serving HAL, Siren, JSON:API, and Collection+JSON from one endpoint
- Custom media types for HATEOAS versions
- Format-specific serializers and link builders
- Testing content negotiation across clients
Why It Matters
Clients built with different hypermedia libraries consume different formats. Supporting negotiation means one API serves all clients. DodaTech's public threat API serves HAL for Traverson clients, Siren for custom dashboards, and JSON:API for automated tooling — all from the same endpoint.
Real-World Use
A security API serves multiple integration partners who each use different hypermedia libraries. Content negotiation lets each partner consume their preferred format without API changes. A single /threats/{id} endpoint returns HAL, Siren, or JSON:API depending on the client.
flowchart LR
A["Client A: Traverson
(Accept: application/hal+json)"] --> B["API Gateway"]
C["Client B: Custom
(Accept: application/vnd.siren+json)"] --> B
D["Client C: Automation
(Accept: application/vnd.api+json)"] --> B
B --> E["HAL Serializer"]
B --> F["Siren Serializer"]
B --> G["JSON:API Serializer"]
E --> H["Common Resource Model"]
F --> H
G --> H
Code Examples
Example 1: Accept Header Routing
from flask import Flask, request, jsonify
app = Flask(__name__)
MEDIA_TYPE_FORMATS = {
'application/hal+json': 'hal',
'application/vnd.siren+json': 'siren',
'application/vnd.api+json': 'jsonapi',
'application/vnd.collection+json': 'collectionjson',
'application/json': 'hal' # default
}
@app.route('/threats/<threat_id>')
def get_threat(threat_id):
threat = find_threat(threat_id)
best_format = request.accept_mimetypes.best_match(
MEDIA_TYPE_FORMATS.keys()
)
fmt = MEDIA_TYPE_FORMATS.get(best_format, 'hal')
serializer = get_serializer(fmt)
response = serializer.serialize(threat)
return jsonify(response)
Example 2: Format-Agnostic Resource Model
class ThreatResource:
def __init__(self, threat):
self.id = threat.id
self.name = threat.name
self.severity = threat.severity
self.status = threat.status
self.actions = {
'analyze': {'method': 'POST', 'href': f'/threats/{threat.id}/analyze'},
'remediate': {'method': 'POST', 'href': f'/threats/{threat.id}/fix'},
'ignore': {'method': 'POST', 'href': f'/threats/{threat.id}/ignore'}
}
self.related = {
'reports': f'/threats/{threat.id}/reports',
'indicators': f'/threats/{threat.id}/iocs'
}
# Usage
threat = ThreatResource(find_threat('abc123'))
print(threat.actions['analyze']['href'])
# Output: /threats/abc123/analyze
Example 3: HAL Serializer
class HALSerializer:
def serialize(self, resource):
result = {
'id': resource.id,
'name': resource.name,
'severity': resource.severity,
'status': resource.status,
'_links': {
'self': {'href': f'/threats/{resource.id}'}
},
'_embedded': {}
}
for rel, href in resource.related.items():
result['_links'][rel] = {'href': href}
for name, action in resource.actions.items():
result['_links'][name] = {
'href': action['href'],
'method': action['method']
}
return result
serializer = HALSerializer()
output = serializer.serialize(threat)
print(json.dumps(output, indent=2))
# Output:
# {
# "id": "abc123",
# "name": "SuspiciousProcess",
# "_links": {
# "self": {"href": "/threats/abc123"},
# "analyze": {"href": "/threats/abc123/analyze", "method": "POST"}
# }
# }
Common Mistakes
1. Ignoring Quality Values in Accept Headers
Clients send q parameters to indicate preference. Ignoring these may serve the wrong format.
2. Returning Wrong Content-Type
Set the response Content-Type to match the format served, not just application/json.
3. Format-Specific Business Logic
Business logic should be format-agnostic. Only Serialization differs by format.
4. Not Caching Per Format
Cache responses by (URL, Accept) pairs, not by URL alone.
5. Missing Format Default
Always specify a default format for clients that send Accept: */*.
Practice Questions
- What HTTP header controls content negotiation?
- How do you serve both HAL and Siren from the same endpoint?
- What Content-Type should a HAL response use?
- Why should business logic be format-agnostic?
- How do you cache format-negotiated responses?
Answers:
- The Accept header specifies which media types the client can Process.
- Inspect the Accept header and route to the appropriate serializer.
application/hal+jsonfor HAL responses.- Business logic doesn't change with format; only serialization does.
- Include the Accept header as a cache key dimension (Vary: Accept).
Challenge: Build a format router that supports HAL, Siren, JSON:API, and Collection+JSON from one endpoint. Accept quality values and return the best match.
FAQ
What's Next
After implementing content negotiation, explore HATEOAS Conditional Requests for caching, and review HATEOAS Link Formats for format-specific details.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro