Files
StarPunk/starpunk/routes/public.py
Phil Skentelbery c1dd706b8f feat: Implement Phase 3 Feed Caching (Partial)
Implements feed caching layer with LRU eviction, TTL expiration, and ETag support.

Phase 3.1: Feed Caching (Complete)
- LRU cache with configurable max_size (default: 50 feeds)
- TTL-based expiration (default: 300 seconds = 5 minutes)
- SHA-256 checksums for cache keys and ETags
- Weak ETag generation (W/"checksum")
- If-None-Match header support for 304 Not Modified responses
- Cache invalidation (全体 or per-format)
- Hit/miss/eviction statistics tracking
- Content-based cache keys (changes when notes are modified)

Implementation:
- Created starpunk/feeds/cache.py with FeedCache class
- Integrated caching into feed routes (RSS, ATOM, JSON Feed)
- Added ETag headers to all feed responses
- 304 Not Modified responses for conditional requests
- Configuration: FEED_CACHE_ENABLED, FEED_CACHE_MAX_SIZE
- Global cache instance with singleton pattern

Architecture:
- Two-level caching:
  1. Note list cache (simple dict, existing)
  2. Feed content cache (LRU with TTL, new)
- Cache keys include format + notes checksum
- Checksums based on note IDs + updated timestamps
- Non-streaming generators used for cacheable content

Testing:
- 25 comprehensive cache tests (100% passing)
- Tests for LRU eviction, TTL expiration, statistics
- Tests for checksum generation and consistency
- Tests for ETag generation and uniqueness
- All 114 feed tests passing (no regressions)

Quality Metrics:
- 114/114 tests passing (100%)
- Zero breaking changes
- Full backward compatibility
- Cache disabled mode supported (FEED_CACHE_ENABLED=false)

Performance Benefits:
- Database queries reduced (note list cached)
- Feed generation reduced (content cached)
- Bandwidth saved (304 responses)
- Memory efficient (LRU eviction)

Note: Phase 3 is partially complete. Still pending:
- Feed statistics dashboard
- OPML 2.0 export endpoint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:14:03 -07:00

380 lines
11 KiB
Python

"""
Public routes for StarPunk
Handles public-facing pages including homepage and note permalinks.
No authentication required for these routes.
"""
import hashlib
from datetime import datetime, timedelta
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 # Legacy RSS
from starpunk.feeds import (
generate_rss,
generate_rss_streaming,
generate_atom,
generate_atom_streaming,
generate_json_feed,
generate_json_feed_streaming,
negotiate_feed_format,
get_mime_type,
get_cache,
)
# Create blueprint
bp = Blueprint("public", __name__)
# Simple in-memory cache for feed note list
# Caches the database query results to avoid repeated DB hits
# Feed content is now cached via FeedCache (Phase 3)
# 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
def _generate_feed_with_cache(format_name: str, non_streaming_generator):
"""
Generate feed with caching and ETag support.
Implements Phase 3 feed caching:
- Checks If-None-Match header for conditional requests
- Uses FeedCache for content caching
- Returns 304 Not Modified when appropriate
- Adds ETag header to all responses
Args:
format_name: Feed format (rss, atom, json)
non_streaming_generator: Function that returns full feed content (not streaming)
Returns:
Flask Response with appropriate headers and status
"""
# Get cached notes
notes = _get_cached_notes()
# Check if caching is enabled
cache_enabled = current_app.config.get("FEED_CACHE_ENABLED", True)
if not cache_enabled:
# Caching disabled, generate fresh feed
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
# Generate feed content (non-streaming)
content = non_streaming_generator(
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,
)
response = Response(content, mimetype=get_mime_type(format_name))
response.headers["Cache-Control"] = f"public, max-age={cache_seconds}"
return response
# Caching enabled - use FeedCache
feed_cache = get_cache()
notes_checksum = feed_cache.generate_notes_checksum(notes)
# Check If-None-Match header for conditional requests
if_none_match = request.headers.get('If-None-Match')
# Try to get cached feed
cached_result = feed_cache.get(format_name, notes_checksum)
if cached_result:
content, etag = cached_result
# Check if client has current version
if if_none_match and if_none_match == etag:
# Client has current version, return 304 Not Modified
response = Response(status=304)
response.headers["ETag"] = etag
return response
# Return cached content with ETag
response = Response(content, mimetype=get_mime_type(format_name))
response.headers["ETag"] = etag
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
response.headers["Cache-Control"] = f"public, max-age={cache_seconds}"
return response
# Cache miss - generate fresh feed
max_items = current_app.config.get("FEED_MAX_ITEMS", 50)
# Generate feed content (non-streaming)
content = non_streaming_generator(
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,
)
# Store in cache and get ETag
etag = feed_cache.set(format_name, content, notes_checksum)
# Return fresh content with ETag
response = Response(content, mimetype=get_mime_type(format_name))
response.headers["ETag"] = etag
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
response.headers["Cache-Control"] = f"public, max-age={cache_seconds}"
return response
@bp.route("/")
def index():
"""
Homepage displaying recent published notes
Returns:
Rendered homepage template with note list
Template: templates/index.html
Microformats: h-feed containing h-entry items
"""
# Get recent published notes (limit 20)
notes = list_notes(published_only=True, limit=20)
return render_template("index.html", notes=notes)
@bp.route("/note/<slug>")
def note(slug: str):
"""
Individual note permalink page
Args:
slug: URL-safe note identifier
Returns:
Rendered note template with full content
Raises:
404: If note not found or not published
Template: templates/note.html
Microformats: h-entry
"""
# Get note by slug
note_obj = get_note(slug=slug)
# Return 404 if note doesn't exist or isn't published
if not note_obj or not note_obj.published:
abort(404)
return render_template("note.html", note=note_obj)
@bp.route("/feed")
def feed():
"""
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 (with caching)
Generates standards-compliant RSS 2.0 feed with Phase 3 caching:
- LRU cache with TTL (default 5 minutes)
- ETag support for conditional requests
- 304 Not Modified responses
- SHA-256 checksums
Returns:
Cached or fresh RSS 2.0 feed response
Headers:
Content-Type: application/rss+xml; charset=utf-8
Cache-Control: public, max-age={FEED_CACHE_SECONDS}
ETag: W/"sha256_hash"
Caching Strategy:
- Database query cached (note list)
- Feed content cached (full XML)
- Conditional requests (If-None-Match)
- Cache invalidation on content changes
Examples:
>>> response = client.get('/feed.rss')
>>> response.status_code
200
>>> response.headers['Content-Type']
'application/rss+xml; charset=utf-8'
>>> response.headers['ETag']
'W/"abc123..."'
>>> # Conditional request
>>> response = client.get('/feed.rss', headers={'If-None-Match': 'W/"abc123..."'})
>>> response.status_code
304
"""
return _generate_feed_with_cache('rss', generate_rss)
@bp.route("/feed.atom")
def feed_atom():
"""
Explicit ATOM 1.0 feed endpoint (with caching)
Generates standards-compliant ATOM 1.0 feed with Phase 3 caching.
Follows RFC 4287 specification for ATOM syndication format.
Returns:
Cached or fresh ATOM 1.0 feed response
Headers:
Content-Type: application/atom+xml; charset=utf-8
Cache-Control: public, max-age={FEED_CACHE_SECONDS}
ETag: W/"sha256_hash"
Examples:
>>> response = client.get('/feed.atom')
>>> response.status_code
200
>>> response.headers['Content-Type']
'application/atom+xml; charset=utf-8'
>>> response.headers['ETag']
'W/"abc123..."'
"""
return _generate_feed_with_cache('atom', generate_atom)
@bp.route("/feed.json")
def feed_json():
"""
Explicit JSON Feed 1.1 endpoint (with caching)
Generates standards-compliant JSON Feed 1.1 feed with Phase 3 caching.
Follows JSON Feed specification (https://jsonfeed.org/version/1.1).
Returns:
Cached or fresh JSON Feed 1.1 response
Headers:
Content-Type: application/feed+json; charset=utf-8
Cache-Control: public, max-age={FEED_CACHE_SECONDS}
ETag: W/"sha256_hash"
Examples:
>>> response = client.get('/feed.json')
>>> response.status_code
200
>>> response.headers['Content-Type']
'application/feed+json; charset=utf-8'
>>> response.headers['ETag']
'W/"abc123..."'
"""
return _generate_feed_with_cache('json', generate_json_feed)
@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()