Files
StarPunk/starpunk/feeds/negotiation.py
Phil Skentelbery 8fbdcb6e6f feat: Complete Phase 2.4 - HTTP Content Negotiation
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>
2025-11-27 20:46:49 -07:00

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