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>
This commit is contained in:
@@ -8,21 +8,59 @@ No authentication required for these routes.
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, abort, render_template, Response, current_app
|
||||
from flask import Blueprint, abort, render_template, Response, current_app, request
|
||||
|
||||
from starpunk.notes import list_notes, get_note
|
||||
from starpunk.feed import generate_feed_streaming
|
||||
from starpunk.feed import generate_feed_streaming # Legacy RSS
|
||||
from starpunk.feeds import (
|
||||
generate_rss_streaming,
|
||||
generate_atom_streaming,
|
||||
generate_json_feed_streaming,
|
||||
negotiate_feed_format,
|
||||
get_mime_type,
|
||||
)
|
||||
|
||||
# Create blueprint
|
||||
bp = Blueprint("public", __name__)
|
||||
|
||||
# Simple in-memory cache for RSS feed note list
|
||||
# Simple in-memory cache for feed note list
|
||||
# Caches the database query results to avoid repeated DB hits
|
||||
# XML is streamed, not cached (memory optimization for large feeds)
|
||||
# Feed content (XML/JSON) is streamed, not cached (memory optimization)
|
||||
# Structure: {'notes': list[Note], 'timestamp': datetime}
|
||||
_feed_cache = {"notes": None, "timestamp": None}
|
||||
|
||||
|
||||
def _get_cached_notes():
|
||||
"""
|
||||
Get cached note list or fetch fresh notes
|
||||
|
||||
Returns cached notes if still valid, otherwise fetches fresh notes
|
||||
from database and updates cache.
|
||||
|
||||
Returns:
|
||||
List of published notes for feed generation
|
||||
"""
|
||||
# Get cache duration from config (in seconds)
|
||||
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
|
||||
cache_duration = timedelta(seconds=cache_seconds)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Check if note list cache is valid
|
||||
if _feed_cache["notes"] and _feed_cache["timestamp"]:
|
||||
cache_age = now - _feed_cache["timestamp"]
|
||||
if cache_age < cache_duration:
|
||||
# Use cached note list
|
||||
return _feed_cache["notes"]
|
||||
|
||||
# Cache expired or empty, fetch fresh notes
|
||||
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
|
||||
notes = list_notes(published_only=True, limit=max_items)
|
||||
_feed_cache["notes"] = notes
|
||||
_feed_cache["timestamp"] = now
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
"""
|
||||
@@ -67,10 +105,73 @@ def note(slug: str):
|
||||
return render_template("note.html", note=note_obj)
|
||||
|
||||
|
||||
@bp.route("/feed.xml")
|
||||
@bp.route("/feed")
|
||||
def feed():
|
||||
"""
|
||||
RSS 2.0 feed of published notes
|
||||
Content negotiation endpoint for feeds
|
||||
|
||||
Serves feed in format based on HTTP Accept header:
|
||||
- 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)
|
||||
|
||||
If no acceptable format is available, returns 406 Not Acceptable with
|
||||
X-Available-Formats header listing supported formats.
|
||||
|
||||
Returns:
|
||||
Streaming feed response in negotiated format, or 406 error
|
||||
|
||||
Headers:
|
||||
Content-Type: Varies by format
|
||||
Cache-Control: public, max-age={FEED_CACHE_SECONDS}
|
||||
X-Available-Formats: List of supported formats (on 406 error only)
|
||||
|
||||
Examples:
|
||||
>>> # Request with Accept: application/atom+xml
|
||||
>>> response = client.get('/feed', headers={'Accept': 'application/atom+xml'})
|
||||
>>> response.headers['Content-Type']
|
||||
'application/atom+xml; charset=utf-8'
|
||||
|
||||
>>> # Request with no Accept header (defaults to RSS)
|
||||
>>> response = client.get('/feed')
|
||||
>>> response.headers['Content-Type']
|
||||
'application/rss+xml; charset=utf-8'
|
||||
"""
|
||||
# Get Accept header
|
||||
accept = request.headers.get('Accept', '*/*')
|
||||
|
||||
# Negotiate format
|
||||
available_formats = ['rss', 'atom', 'json']
|
||||
try:
|
||||
format_name = negotiate_feed_format(accept, available_formats)
|
||||
except ValueError:
|
||||
# No acceptable format - return 406
|
||||
return (
|
||||
"Not Acceptable. Supported formats: application/rss+xml, application/atom+xml, application/feed+json",
|
||||
406,
|
||||
{
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Available-Formats': 'application/rss+xml, application/atom+xml, application/feed+json',
|
||||
}
|
||||
)
|
||||
|
||||
# Route to appropriate generator
|
||||
if format_name == 'rss':
|
||||
return feed_rss()
|
||||
elif format_name == 'atom':
|
||||
return feed_atom()
|
||||
elif format_name == 'json':
|
||||
return feed_json()
|
||||
else:
|
||||
# Shouldn't reach here, but be defensive
|
||||
return feed_rss()
|
||||
|
||||
|
||||
@bp.route("/feed.rss")
|
||||
def feed_rss():
|
||||
"""
|
||||
Explicit RSS 2.0 feed endpoint
|
||||
|
||||
Generates standards-compliant RSS 2.0 feed using memory-efficient streaming.
|
||||
Instead of building the entire feed in memory, yields XML chunks directly
|
||||
@@ -81,7 +182,7 @@ def feed():
|
||||
but streaming prevents holding full XML in memory.
|
||||
|
||||
Returns:
|
||||
Streaming XML response with RSS feed
|
||||
Streaming RSS 2.0 feed response
|
||||
|
||||
Headers:
|
||||
Content-Type: application/rss+xml; charset=utf-8
|
||||
@@ -98,42 +199,21 @@ def feed():
|
||||
- Recommended for feeds with 100+ items
|
||||
|
||||
Examples:
|
||||
>>> # Request streams XML directly to client
|
||||
>>> response = client.get('/feed.xml')
|
||||
>>> response = client.get('/feed.rss')
|
||||
>>> response.status_code
|
||||
200
|
||||
>>> response.headers['Content-Type']
|
||||
'application/rss+xml; charset=utf-8'
|
||||
"""
|
||||
# Get cache duration from config (in seconds)
|
||||
# Get cached notes
|
||||
notes = _get_cached_notes()
|
||||
|
||||
# Get cache duration for response header
|
||||
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
|
||||
cache_duration = timedelta(seconds=cache_seconds)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Check if note list cache is valid
|
||||
# We cache the note list to avoid repeated DB queries, but still stream the XML
|
||||
if _feed_cache["notes"] and _feed_cache["timestamp"]:
|
||||
cache_age = now - _feed_cache["timestamp"]
|
||||
if cache_age < cache_duration:
|
||||
# Use cached note list
|
||||
notes = _feed_cache["notes"]
|
||||
else:
|
||||
# Cache expired, fetch fresh notes
|
||||
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
|
||||
notes = list_notes(published_only=True, limit=max_items)
|
||||
_feed_cache["notes"] = notes
|
||||
_feed_cache["timestamp"] = now
|
||||
else:
|
||||
# No cache, fetch notes
|
||||
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
|
||||
notes = list_notes(published_only=True, limit=max_items)
|
||||
_feed_cache["notes"] = notes
|
||||
_feed_cache["timestamp"] = now
|
||||
|
||||
# Generate streaming response
|
||||
# This avoids holding the full XML in memory - chunks are yielded directly
|
||||
# Generate streaming RSS feed
|
||||
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
|
||||
generator = generate_feed_streaming(
|
||||
generator = generate_rss_streaming(
|
||||
site_url=current_app.config["SITE_URL"],
|
||||
site_name=current_app.config["SITE_NAME"],
|
||||
site_description=current_app.config.get("SITE_DESCRIPTION", ""),
|
||||
@@ -146,3 +226,110 @@ def feed():
|
||||
response.headers["Cache-Control"] = f"public, max-age={cache_seconds}"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/feed.atom")
|
||||
def feed_atom():
|
||||
"""
|
||||
Explicit ATOM 1.0 feed endpoint
|
||||
|
||||
Generates standards-compliant ATOM 1.0 feed using memory-efficient streaming.
|
||||
Follows RFC 4287 specification for ATOM syndication format.
|
||||
|
||||
Returns:
|
||||
Streaming ATOM 1.0 feed response
|
||||
|
||||
Headers:
|
||||
Content-Type: application/atom+xml; charset=utf-8
|
||||
Cache-Control: public, max-age={FEED_CACHE_SECONDS}
|
||||
|
||||
Examples:
|
||||
>>> response = client.get('/feed.atom')
|
||||
>>> response.status_code
|
||||
200
|
||||
>>> response.headers['Content-Type']
|
||||
'application/atom+xml; charset=utf-8'
|
||||
"""
|
||||
# Get cached notes
|
||||
notes = _get_cached_notes()
|
||||
|
||||
# Get cache duration for response header
|
||||
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
|
||||
|
||||
# Generate streaming ATOM feed
|
||||
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
|
||||
generator = generate_atom_streaming(
|
||||
site_url=current_app.config["SITE_URL"],
|
||||
site_name=current_app.config["SITE_NAME"],
|
||||
site_description=current_app.config.get("SITE_DESCRIPTION", ""),
|
||||
notes=notes,
|
||||
limit=max_items,
|
||||
)
|
||||
|
||||
# Return streaming response with appropriate headers
|
||||
response = Response(generator, mimetype="application/atom+xml; charset=utf-8")
|
||||
response.headers["Cache-Control"] = f"public, max-age={cache_seconds}"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/feed.json")
|
||||
def feed_json():
|
||||
"""
|
||||
Explicit JSON Feed 1.1 endpoint
|
||||
|
||||
Generates standards-compliant JSON Feed 1.1 feed using memory-efficient streaming.
|
||||
Follows JSON Feed specification (https://jsonfeed.org/version/1.1).
|
||||
|
||||
Returns:
|
||||
Streaming JSON Feed 1.1 response
|
||||
|
||||
Headers:
|
||||
Content-Type: application/feed+json; charset=utf-8
|
||||
Cache-Control: public, max-age={FEED_CACHE_SECONDS}
|
||||
|
||||
Examples:
|
||||
>>> response = client.get('/feed.json')
|
||||
>>> response.status_code
|
||||
200
|
||||
>>> response.headers['Content-Type']
|
||||
'application/feed+json; charset=utf-8'
|
||||
"""
|
||||
# Get cached notes
|
||||
notes = _get_cached_notes()
|
||||
|
||||
# Get cache duration for response header
|
||||
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
|
||||
|
||||
# Generate streaming JSON Feed
|
||||
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
|
||||
generator = generate_json_feed_streaming(
|
||||
site_url=current_app.config["SITE_URL"],
|
||||
site_name=current_app.config["SITE_NAME"],
|
||||
site_description=current_app.config.get("SITE_DESCRIPTION", ""),
|
||||
notes=notes,
|
||||
limit=max_items,
|
||||
)
|
||||
|
||||
# Return streaming response with appropriate headers
|
||||
response = Response(generator, mimetype="application/feed+json; charset=utf-8")
|
||||
response.headers["Cache-Control"] = f"public, max-age={cache_seconds}"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/feed.xml")
|
||||
def feed_xml_legacy():
|
||||
"""
|
||||
Legacy RSS 2.0 feed endpoint (backward compatibility)
|
||||
|
||||
Maintains backward compatibility for /feed.xml endpoint.
|
||||
New code should use /feed.rss or /feed with content negotiation.
|
||||
|
||||
Returns:
|
||||
Streaming RSS 2.0 feed response
|
||||
|
||||
See feed_rss() for full documentation.
|
||||
"""
|
||||
# Use the new RSS endpoint
|
||||
return feed_rss()
|
||||
|
||||
Reference in New Issue
Block a user