Implements HTTP content negotiation for feed format selection. Phase 2.4 Deliverables: - Content negotiation via Accept header parsing - Quality factor support (q= parameter) - 5 feed endpoints with format routing - 406 Not Acceptable responses with helpful errors - Comprehensive test coverage (63 tests) Endpoints: - /feed - Content negotiation based on Accept header - /feed.rss - Explicit RSS 2.0 - /feed.atom - Explicit ATOM 1.0 - /feed.json - Explicit JSON Feed 1.1 - /feed.xml - Backward compatibility (→ RSS) MIME Type Mapping: - application/rss+xml → RSS 2.0 - application/atom+xml → ATOM 1.0 - application/feed+json or application/json → JSON Feed 1.1 - */* → RSS 2.0 (default) Implementation: - Simple quality factor parsing (StarPunk philosophy) - Not full RFC 7231 compliance (minimal approach) - Reuses existing feed generators - No breaking changes Quality Metrics: - 132/132 tests passing (100%) - Zero breaking changes - Full backward compatibility - Standards compliant negotiation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
223 lines
6.8 KiB
Python
223 lines
6.8 KiB
Python
"""
|
|
Content negotiation for feed formats
|
|
|
|
This module provides simple HTTP content negotiation to determine which feed
|
|
format to serve based on the client's Accept header. Follows StarPunk's
|
|
philosophy of simplicity over RFC compliance.
|
|
|
|
Supported formats:
|
|
- RSS 2.0 (application/rss+xml)
|
|
- ATOM 1.0 (application/atom+xml)
|
|
- JSON Feed 1.1 (application/feed+json, application/json)
|
|
|
|
Example:
|
|
>>> negotiate_feed_format('application/atom+xml', ['rss', 'atom', 'json'])
|
|
'atom'
|
|
>>> negotiate_feed_format('*/*', ['rss', 'atom', 'json'])
|
|
'rss'
|
|
"""
|
|
|
|
from typing import List
|
|
|
|
|
|
# MIME type to format mapping
|
|
MIME_TYPES = {
|
|
'rss': 'application/rss+xml',
|
|
'atom': 'application/atom+xml',
|
|
'json': 'application/feed+json',
|
|
}
|
|
|
|
# Reverse mapping for parsing Accept headers
|
|
MIME_TO_FORMAT = {
|
|
'application/rss+xml': 'rss',
|
|
'application/atom+xml': 'atom',
|
|
'application/feed+json': 'json',
|
|
'application/json': 'json', # Also accept generic JSON
|
|
}
|
|
|
|
|
|
def negotiate_feed_format(accept_header: str, available_formats: List[str]) -> str:
|
|
"""
|
|
Parse Accept header and return best matching format
|
|
|
|
Implements simple content negotiation with quality factor support.
|
|
When multiple formats have the same quality, defaults to RSS.
|
|
Wildcards (*/*) default to RSS.
|
|
|
|
Args:
|
|
accept_header: HTTP Accept header value (e.g., "application/atom+xml, */*;q=0.8")
|
|
available_formats: List of available formats (e.g., ['rss', 'atom', 'json'])
|
|
|
|
Returns:
|
|
Best matching format ('rss', 'atom', or 'json')
|
|
|
|
Raises:
|
|
ValueError: If no acceptable format found (caller should return 406)
|
|
|
|
Examples:
|
|
>>> negotiate_feed_format('application/atom+xml', ['rss', 'atom', 'json'])
|
|
'atom'
|
|
>>> negotiate_feed_format('application/json;q=0.9, */*;q=0.1', ['rss', 'atom', 'json'])
|
|
'json'
|
|
>>> negotiate_feed_format('*/*', ['rss', 'atom', 'json'])
|
|
'rss'
|
|
>>> negotiate_feed_format('text/html', ['rss', 'atom', 'json'])
|
|
Traceback (most recent call last):
|
|
...
|
|
ValueError: No acceptable format found
|
|
"""
|
|
# Parse Accept header into list of (mime_type, quality) tuples
|
|
media_types = _parse_accept_header(accept_header)
|
|
|
|
# Score each available format
|
|
scores = {}
|
|
for format_name in available_formats:
|
|
score = _score_format(format_name, media_types)
|
|
if score > 0:
|
|
scores[format_name] = score
|
|
|
|
# If no formats matched, raise error
|
|
if not scores:
|
|
raise ValueError("No acceptable format found")
|
|
|
|
# Return format with highest score
|
|
# On tie, prefer in this order: rss, atom, json
|
|
best_score = max(scores.values())
|
|
|
|
# Check in preference order
|
|
for preferred in ['rss', 'atom', 'json']:
|
|
if preferred in scores and scores[preferred] == best_score:
|
|
return preferred
|
|
|
|
# Fallback (shouldn't reach here)
|
|
return max(scores, key=scores.get)
|
|
|
|
|
|
def _parse_accept_header(accept_header: str) -> List[tuple]:
|
|
"""
|
|
Parse Accept header into list of (mime_type, quality) tuples
|
|
|
|
Simple parser that extracts MIME types and quality factors.
|
|
Does not implement full RFC 7231 - just enough for feed negotiation.
|
|
|
|
Args:
|
|
accept_header: HTTP Accept header value
|
|
|
|
Returns:
|
|
List of (mime_type, quality) tuples sorted by quality (highest first)
|
|
|
|
Examples:
|
|
>>> _parse_accept_header('application/json;q=0.9, text/html')
|
|
[('text/html', 1.0), ('application/json', 0.9)]
|
|
"""
|
|
media_types = []
|
|
|
|
# Split on commas to get individual media types
|
|
for part in accept_header.split(','):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
|
|
# Split on semicolon to separate MIME type from parameters
|
|
components = part.split(';')
|
|
mime_type = components[0].strip().lower()
|
|
|
|
# Extract quality factor (default to 1.0)
|
|
quality = 1.0
|
|
for param in components[1:]:
|
|
param = param.strip()
|
|
if param.startswith('q='):
|
|
try:
|
|
quality = float(param[2:])
|
|
# Clamp quality to 0-1 range
|
|
quality = max(0.0, min(1.0, quality))
|
|
except (ValueError, IndexError):
|
|
quality = 1.0
|
|
break
|
|
|
|
media_types.append((mime_type, quality))
|
|
|
|
# Sort by quality (highest first)
|
|
media_types.sort(key=lambda x: x[1], reverse=True)
|
|
|
|
return media_types
|
|
|
|
|
|
def _score_format(format_name: str, media_types: List[tuple]) -> float:
|
|
"""
|
|
Calculate score for a format based on parsed Accept header
|
|
|
|
Args:
|
|
format_name: Format to score ('rss', 'atom', or 'json')
|
|
media_types: List of (mime_type, quality) tuples from Accept header
|
|
|
|
Returns:
|
|
Score (0.0 to 1.0), where 0 means no match
|
|
|
|
Examples:
|
|
>>> media_types = [('application/atom+xml', 1.0), ('*/*', 0.8)]
|
|
>>> _score_format('atom', media_types)
|
|
1.0
|
|
>>> _score_format('rss', media_types)
|
|
0.8
|
|
"""
|
|
# Get the MIME type for this format
|
|
format_mime = MIME_TYPES.get(format_name)
|
|
if not format_mime:
|
|
return 0.0
|
|
|
|
# Build list of acceptable MIME types for this format
|
|
# Check both the primary MIME type and any alternatives from MIME_TO_FORMAT
|
|
acceptable_mimes = [format_mime]
|
|
for mime, fmt in MIME_TO_FORMAT.items():
|
|
if fmt == format_name and mime != format_mime:
|
|
acceptable_mimes.append(mime)
|
|
|
|
# Find best matching media type
|
|
best_quality = 0.0
|
|
|
|
for mime_type, quality in media_types:
|
|
# Exact match (check all acceptable MIME types)
|
|
if mime_type in acceptable_mimes:
|
|
best_quality = max(best_quality, quality)
|
|
# Wildcard match
|
|
elif mime_type == '*/*':
|
|
best_quality = max(best_quality, quality)
|
|
# Type wildcard (e.g., "application/*")
|
|
elif '/' in mime_type and mime_type.endswith('/*'):
|
|
type_prefix = mime_type.split('/')[0]
|
|
# Check if any acceptable MIME type matches the wildcard
|
|
for acceptable in acceptable_mimes:
|
|
if acceptable.startswith(type_prefix + '/'):
|
|
best_quality = max(best_quality, quality)
|
|
break
|
|
|
|
return best_quality
|
|
|
|
|
|
def get_mime_type(format_name: str) -> str:
|
|
"""
|
|
Get MIME type for a format name
|
|
|
|
Args:
|
|
format_name: Format name ('rss', 'atom', or 'json')
|
|
|
|
Returns:
|
|
MIME type string
|
|
|
|
Raises:
|
|
ValueError: If format name is not recognized
|
|
|
|
Examples:
|
|
>>> get_mime_type('rss')
|
|
'application/rss+xml'
|
|
>>> get_mime_type('atom')
|
|
'application/atom+xml'
|
|
>>> get_mime_type('json')
|
|
'application/feed+json'
|
|
"""
|
|
mime_type = MIME_TYPES.get(format_name)
|
|
if not mime_type:
|
|
raise ValueError(f"Unknown format: {format_name}")
|
|
return mime_type
|