""" 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