Add red letter (words of Christ) API endpoints

- Added /api/red-letter endpoint for listing all verses where Jesus speaks
  - Supports filtering by book
  - Includes pagination (limit/offset)
  - Returns verse text, reference, and whether full or partial

- Added /api/red-letter/stats endpoint for statistics
  - Total count of red letter verses
  - Breakdown by full vs partial verses
  - Count by book
  - List of books containing red letter verses

- Added 8 comprehensive tests (59 total tests now)
- All tests passing

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-29 13:40:22 -05:00
parent 94a8801251
commit 87a85c979c
2 changed files with 294 additions and 5 deletions
+140 -3
View File
@@ -16,7 +16,7 @@ from ..utils.books import normalize_book_name, OT_BOOKS
from ..utils.search import perform_full_text_search
from ..utils.helpers import get_daily_verse, create_slug
from ..books import get_book_data, get_all_books_metadata, has_book_data
from ..red_letter import get_christ_words
from ..red_letter import get_christ_words, load_red_letter_verses
from ..stories import (
get_categories,
get_story_by_slug,
@@ -147,6 +147,34 @@ class ResourceItemDetail(BaseModel):
verses: List[ResourceVerse]
class RedLetterVerse(BaseModel):
"""A verse containing words of Christ"""
reference: str = Field(..., json_schema_extra={"example": "John 3:16"})
book: str = Field(..., json_schema_extra={"example": "John"})
chapter: int = Field(..., json_schema_extra={"example": 3})
verse: int = Field(..., json_schema_extra={"example": 16})
text: str = Field(..., json_schema_extra={"example": "For God so loved the world..."})
christ_words: str = Field(..., json_schema_extra={"example": "full"})
is_full_verse: bool = Field(..., json_schema_extra={"example": True})
class RedLetterListResponse(BaseModel):
"""Response listing red letter verses"""
total: int = Field(..., json_schema_extra={"example": 1842})
verses: List[RedLetterVerse]
limit: int = Field(..., json_schema_extra={"example": 50})
offset: int = Field(..., json_schema_extra={"example": 0})
class RedLetterStatsResponse(BaseModel):
"""Statistics about red letter verses"""
total_verses: int = Field(..., json_schema_extra={"example": 1842})
full_verses: int = Field(..., json_schema_extra={"example": 1456})
partial_verses: int = Field(..., json_schema_extra={"example": 386})
books_with_red_letter: List[str] = Field(..., json_schema_extra={"example": ["Matthew", "Mark", "Luke", "John"]})
by_book: dict = Field(..., json_schema_extra={"example": {"Matthew": 644, "Mark": 285}})
# Mapping of category names to their data dictionaries
CATEGORY_TO_DATA = {
'biblical_locations': BIBLICAL_LOCATIONS,
@@ -227,7 +255,9 @@ def api_index():
"reading_plans": "/api/reading-plans",
"reading_plan": "/api/reading-plans/{plan_id}",
"stories": "/api/stories",
"story": "/api/stories/{slug}"
"story": "/api/stories/{slug}",
"red_letter": "/api/red-letter?book={book}&limit={limit}&offset={offset}",
"red_letter_stats": "/api/red-letter/stats"
}
}
@@ -1532,7 +1562,7 @@ async def api_get_resource_item_pdf(
# Generate PDF
pdf_buffer = await render_html_to_pdf_async(html_content)
# Return as downloadable PDF
filename = f"{slug}-{category}.pdf"
return StreamingResponse(
@@ -1540,3 +1570,110 @@ async def api_get_resource_item_pdf(
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get(
"/red-letter",
response_model=RedLetterListResponse,
summary="List all red letter verses",
description="Get a list of all verses containing the words of Jesus Christ (red letter edition). Supports filtering by book and pagination."
)
def api_list_red_letter_verses(
book: Optional[str] = Query(None, description="Filter by book name", example="John"),
limit: int = Query(50, description="Maximum number of verses to return", example=50, ge=1, le=500),
offset: int = Query(0, description="Number of verses to skip", example=0, ge=0)
):
"""List all red letter verses with optional filtering and pagination."""
red_letter_data = load_red_letter_verses()
# Parse all verses and build result list
all_verses = []
for verse_ref, christ_words in red_letter_data.items():
# Parse the reference (format: "Book Chapter:Verse")
parts = verse_ref.rsplit(' ', 1)
if len(parts) != 2:
continue
book_name = parts[0]
chapter_verse = parts[1].split(':')
if len(chapter_verse) != 2:
continue
try:
chapter_num = int(chapter_verse[0])
verse_num = int(chapter_verse[1])
except ValueError:
continue
# Apply book filter if specified
if book and book_name != book:
# Try normalizing the book name in case it's an abbreviation
canonical = normalize_book_name(book)
if not canonical or book_name != canonical:
continue
# Get the verse text
verse_text = bible.get_verse_text(book_name, chapter_num, verse_num)
if not verse_text:
continue
all_verses.append({
"reference": verse_ref,
"book": book_name,
"chapter": chapter_num,
"verse": verse_num,
"text": verse_text,
"christ_words": christ_words,
"is_full_verse": christ_words == "full"
})
# Apply pagination
total = len(all_verses)
paginated_verses = all_verses[offset:offset + limit]
return {
"total": total,
"verses": paginated_verses,
"limit": limit,
"offset": offset
}
@router.get(
"/red-letter/stats",
response_model=RedLetterStatsResponse,
summary="Get red letter statistics",
description="Get statistics about verses containing the words of Jesus Christ, including counts by book and full vs. partial verses."
)
def api_red_letter_stats():
"""Get statistics about red letter verses in the Bible."""
red_letter_data = load_red_letter_verses()
total_verses = len(red_letter_data)
full_verses = sum(1 for v in red_letter_data.values() if v == "full")
partial_verses = total_verses - full_verses
# Count by book
by_book = {}
books_set = set()
for verse_ref in red_letter_data.keys():
# Parse the reference (format: "Book Chapter:Verse")
parts = verse_ref.rsplit(' ', 1)
if len(parts) != 2:
continue
book_name = parts[0]
books_set.add(book_name)
by_book[book_name] = by_book.get(book_name, 0) + 1
# Sort books by count (descending)
by_book = dict(sorted(by_book.items(), key=lambda x: x[1], reverse=True))
return {
"total_verses": total_verses,
"full_verses": full_verses,
"partial_verses": partial_verses,
"books_with_red_letter": sorted(list(books_set)),
"by_book": by_book
}
+154 -2
View File
@@ -546,11 +546,163 @@ class TestResourcesEndpoints:
response = client.get("/api/resources")
assert response.status_code == 200
categories = response.json()["categories"]
# Test a sample of categories (not all 39 to keep test fast)
sample_categories = [cat["name"] for cat in categories[:10]]
for cat_name in sample_categories:
response = client.get(f"/api/resources/{cat_name}")
assert response.status_code == 200, f"Failed to access category: {cat_name}"
assert response.json()["category"] == cat_name
class TestRedLetterEndpoints:
"""Tests for red letter (words of Christ) endpoints"""
def test_list_red_letter_verses(self, client):
"""Test /api/red-letter endpoint returns list of verses"""
response = client.get("/api/red-letter")
assert response.status_code == 200
data = response.json()
# Check response structure
assert "total" in data
assert "verses" in data
assert "limit" in data
assert "offset" in data
# Verify we have verses
assert data["total"] > 0
assert len(data["verses"]) > 0
# Check first verse structure
first_verse = data["verses"][0]
assert "reference" in first_verse
assert "book" in first_verse
assert "chapter" in first_verse
assert "verse" in first_verse
assert "text" in first_verse
assert "christ_words" in first_verse
assert "is_full_verse" in first_verse
def test_list_red_letter_with_pagination(self, client):
"""Test pagination parameters work correctly"""
# Get first 10 verses
response1 = client.get("/api/red-letter?limit=10&offset=0")
assert response1.status_code == 200
data1 = response1.json()
assert data1["limit"] == 10
assert data1["offset"] == 0
assert len(data1["verses"]) == 10
# Get next 10 verses
response2 = client.get("/api/red-letter?limit=10&offset=10")
assert response2.status_code == 200
data2 = response2.json()
assert data2["limit"] == 10
assert data2["offset"] == 10
assert len(data2["verses"]) == 10
# Verify different verses
assert data1["verses"][0]["reference"] != data2["verses"][0]["reference"]
def test_list_red_letter_filter_by_book(self, client):
"""Test filtering by book works correctly"""
response = client.get("/api/red-letter?book=John")
assert response.status_code == 200
data = response.json()
# All verses should be from John
assert data["total"] > 0
for verse in data["verses"]:
assert verse["book"] == "John"
def test_list_red_letter_filter_by_book_multiple_books(self, client):
"""Test that different books return different results"""
response_john = client.get("/api/red-letter?book=John&limit=10")
response_matthew = client.get("/api/red-letter?book=Matthew&limit=10")
assert response_john.status_code == 200
assert response_matthew.status_code == 200
john_data = response_john.json()
matthew_data = response_matthew.json()
# Both should have verses
assert john_data["total"] > 0
assert matthew_data["total"] > 0
# All verses in each response should be from the correct book
for verse in john_data["verses"]:
assert verse["book"] == "John"
for verse in matthew_data["verses"]:
assert verse["book"] == "Matthew"
def test_red_letter_stats(self, client):
"""Test /api/red-letter/stats endpoint"""
response = client.get("/api/red-letter/stats")
assert response.status_code == 200
data = response.json()
# Check response structure
assert "total_verses" in data
assert "full_verses" in data
assert "partial_verses" in data
assert "books_with_red_letter" in data
assert "by_book" in data
# Verify counts make sense
assert data["total_verses"] > 0
assert data["full_verses"] > 0
assert data["partial_verses"] >= 0
assert data["total_verses"] == data["full_verses"] + data["partial_verses"]
# Verify books list
assert len(data["books_with_red_letter"]) > 0
assert "Matthew" in data["books_with_red_letter"]
assert "Mark" in data["books_with_red_letter"]
assert "Luke" in data["books_with_red_letter"]
assert "John" in data["books_with_red_letter"]
# Verify by_book counts
assert isinstance(data["by_book"], dict)
assert "Matthew" in data["by_book"]
assert data["by_book"]["Matthew"] > 0
def test_red_letter_verses_contain_expected_data(self, client):
"""Test that specific known red letter verses are included"""
response = client.get("/api/red-letter?book=John&limit=500")
assert response.status_code == 200
data = response.json()
# Find John 3:16 (Jesus speaks the entire verse)
john_316 = None
for verse in data["verses"]:
if verse["chapter"] == 3 and verse["verse"] == 16:
john_316 = verse
break
assert john_316 is not None, "John 3:16 should be in red letter verses"
assert john_316["is_full_verse"] is True
assert john_316["christ_words"] == "full"
assert "God so loved the world" in john_316["text"]
def test_red_letter_pagination_limits(self, client):
"""Test pagination limit validation"""
# Test maximum limit
response = client.get("/api/red-letter?limit=500")
assert response.status_code == 200
# Test exceeding maximum limit should be rejected
response = client.get("/api/red-letter?limit=1000")
assert response.status_code == 422 # Validation error
def test_api_index_includes_red_letter_endpoints(self, client):
"""Test that API index includes red letter endpoints"""
response = client.get("/api/")
assert response.status_code == 200
data = response.json()
assert "endpoints" in data
assert "red_letter" in data["endpoints"]
assert "red_letter_stats" in data["endpoints"]