Add 5 new API endpoints with comprehensive tests

New Endpoints:
1. GET /api/verse/random - Random verse with OT/NT/book filtering
2. GET /api/commentary/{book}/{chapter}/{verse} - AI-generated verse commentary
3. GET /api/chapter-commentary/{book}/{chapter} - Chapter explanations
4. POST /api/verses/bulk - Bulk verse lookup
5. GET /api/family-tree - List biblical figures
6. GET /api/family-tree/{name} - Get biography with alias support

Features:
- Random verse supports testament (ot/nt) and book filters
- Commentary includes analysis, historical context, and reflection questions
- Chapter commentary provides context for each chapter
- Bulk lookup handles multiple verse references in one request
- Family tree supports name aliases (e.g., Israel → Jacob)
- All endpoints include comprehensive OpenAPI documentation

Tests:
- Added 19 new tests (78 total, all passing)
- Test coverage for all new endpoints
- Edge case handling (invalid inputs, 404s, etc.)

🤖 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:46:33 -05:00
parent 87a85c979c
commit 999265e90e
2 changed files with 573 additions and 2 deletions
+344 -2
View File
@@ -1,6 +1,10 @@
"""API routes for KJV Study - JSON endpoints for programmatic access."""
from typing import Optional, List
from pydantic import BaseModel, Field
import json
import random
from pathlib import Path as FilePath
from functools import lru_cache
from fastapi import APIRouter, HTTPException, Query, Path, Body
from fastapi.responses import JSONResponse, StreamingResponse
@@ -14,7 +18,7 @@ from ..topics import get_all_topics, get_topic
from ..interlinear_loader import get_interlinear_data, has_interlinear_data
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 ..utils.helpers import get_daily_verse, create_slug, CHAPTER_EXPLANATIONS
from ..books import get_book_data, get_all_books_metadata, has_book_data
from ..red_letter import get_christ_words, load_red_letter_verses
from ..stories import (
@@ -49,6 +53,28 @@ router = APIRouter(prefix="/api", tags=["API"])
templates = None
@lru_cache(maxsize=1)
def _load_verse_commentary():
"""Load verse commentary from JSON file. Cached since data never changes."""
commentary_path = FilePath(__file__).parent.parent / "data" / "verse_commentary.json"
if not commentary_path.exists():
return {}
with open(commentary_path, "r", encoding="utf-8") as f:
return json.load(f)
@lru_cache(maxsize=1)
def _load_biographies():
"""Load biographies from JSON file. Cached since data never changes."""
biographies_path = FilePath(__file__).parent.parent / "data" / "biographies.json"
if not biographies_path.exists():
return {"biographies": {}, "aliases": {}}
with open(biographies_path, "r", encoding="utf-8") as f:
return json.load(f)
# Pydantic models for API responses
class VerseResponse(BaseModel):
"""Response model for a single verse"""
@@ -175,6 +201,57 @@ class RedLetterStatsResponse(BaseModel):
by_book: dict = Field(..., json_schema_extra={"example": {"Matthew": 644, "Mark": 285}})
class CommentaryResponse(BaseModel):
"""Verse commentary response"""
book: str = Field(..., json_schema_extra={"example": "John"})
chapter: int = Field(..., json_schema_extra={"example": 3})
verse: int = Field(..., json_schema_extra={"example": 16})
reference: str = Field(..., json_schema_extra={"example": "John 3:16"})
text: str = Field(..., json_schema_extra={"example": "For God so loved the world..."})
analysis: str = Field(..., json_schema_extra={"example": "<strong>For I am the LORD...</strong>"})
historical: str = Field(..., json_schema_extra={"example": "Historical context..."})
questions: List[str] = Field(..., json_schema_extra={"example": ["How does this verse...", "What does..."]})
class ChapterCommentaryResponse(BaseModel):
"""Chapter commentary response"""
book: str = Field(..., json_schema_extra={"example": "Genesis"})
chapter: int = Field(..., json_schema_extra={"example": 1})
explanation: str = Field(..., json_schema_extra={"example": "The creation account..."})
class BulkVerseRequest(BaseModel):
"""Request body for bulk verse lookup"""
references: List[str] = Field(..., json_schema_extra={"example": ["John 3:16", "Romans 8:28", "Psalm 23:1"]})
class BulkVerseResponse(BaseModel):
"""Response for bulk verse lookup"""
total: int = Field(..., json_schema_extra={"example": 3})
verses: List[VerseResponse]
class KeyEvent(BaseModel):
"""A key event in a person's life"""
age: int = Field(..., json_schema_extra={"example": 100})
event: str = Field(..., json_schema_extra={"example": "Birth of Isaac"})
verse: str = Field(..., json_schema_extra={"example": "Genesis 21:5"})
class BiographyResponse(BaseModel):
"""Biography of a biblical figure"""
name: str = Field(..., json_schema_extra={"example": "Abraham"})
summary: str = Field(..., json_schema_extra={"example": "Originally named Abram..."})
significance: str = Field(..., json_schema_extra={"example": "Abraham is the father of the Hebrew nation..."})
key_events: List[KeyEvent]
class FamilyTreeListResponse(BaseModel):
"""List of all people in family tree"""
total: int = Field(..., json_schema_extra={"example": 42})
people: List[str] = Field(..., json_schema_extra={"example": ["Adam", "Noah", "Abraham"]})
# Mapping of category names to their data dictionaries
CATEGORY_TO_DATA = {
'biblical_locations': BIBLICAL_LOCATIONS,
@@ -257,7 +334,13 @@ def api_index():
"stories": "/api/stories",
"story": "/api/stories/{slug}",
"red_letter": "/api/red-letter?book={book}&limit={limit}&offset={offset}",
"red_letter_stats": "/api/red-letter/stats"
"red_letter_stats": "/api/red-letter/stats",
"random_verse": "/api/verse/random?testament={testament}&book={book}",
"commentary": "/api/commentary/{book}/{chapter}/{verse}",
"chapter_commentary": "/api/chapter-commentary/{book}/{chapter}",
"bulk_verses": "/api/verses/bulk",
"family_tree": "/api/family-tree",
"biography": "/api/family-tree/{name}"
}
}
@@ -1677,3 +1760,262 @@ def api_red_letter_stats():
"books_with_red_letter": sorted(list(books_set)),
"by_book": by_book
}
@router.get(
"/verse/random",
response_model=VerseResponse,
summary="Get a random verse",
description="Returns a random Bible verse. Optionally filter by testament (ot/nt) or specific book."
)
def api_random_verse(
testament: Optional[str] = Query(None, description="Filter by testament: 'ot' or 'nt'", example="nt"),
book: Optional[str] = Query(None, description="Filter by book name", example="John")
):
"""Get a random Bible verse with optional filtering."""
all_books = bible.get_books()
# Apply testament filter
if testament:
testament = testament.lower()
if testament == "ot":
filtered_books = [b for b in all_books if b in OT_BOOKS]
elif testament == "nt":
filtered_books = [b for b in all_books if b not in OT_BOOKS]
else:
raise HTTPException(status_code=400, detail="Testament must be 'ot' or 'nt'")
else:
filtered_books = all_books
# Apply book filter
if book:
canonical_name = normalize_book_name(book)
if canonical_name:
book = canonical_name
if book not in filtered_books:
raise HTTPException(status_code=404, detail=f"Book '{book}' not found")
filtered_books = [book]
# Select random book
selected_book = random.choice(filtered_books)
# Get random chapter
chapters = bible.get_chapters_for_book(selected_book)
if not chapters:
raise HTTPException(status_code=404, detail="No chapters found")
selected_chapter = random.choice(chapters)
# Get random verse
verses = bible.get_verses_by_book_chapter(selected_book, selected_chapter)
if not verses:
raise HTTPException(status_code=404, detail="No verses found")
selected_verse_num = random.choice([v.verse for v in verses])
# Get the verse text
verse_text = bible.get_verse_text(selected_book, selected_chapter, selected_verse_num)
if not verse_text:
raise HTTPException(status_code=404, detail="Verse text not found")
# Get red letter information
christ_words = get_christ_words(selected_book, selected_chapter, selected_verse_num)
return {
"book": selected_book,
"chapter": selected_chapter,
"verse": selected_verse_num,
"reference": f"{selected_book} {selected_chapter}:{selected_verse_num}",
"text": verse_text,
"red_letter": christ_words
}
@router.get(
"/commentary/{book}/{chapter}/{verse}",
response_model=CommentaryResponse,
summary="Get verse commentary",
description="Get AI-generated theological commentary for a specific verse, including analysis, historical context, and reflection questions."
)
def api_get_verse_commentary(
book: str = Path(..., description="Book name", example="John"),
chapter: int = Path(..., description="Chapter number", example=3),
verse: int = Path(..., description="Verse number", example=16)
):
"""Get commentary for a specific verse."""
canonical_name = normalize_book_name(book)
if canonical_name:
book = canonical_name
# Check if book exists
all_books = bible.get_books()
if book not in all_books:
raise HTTPException(status_code=404, detail=f"Book '{book}' not found")
# Get verse text
verse_text = bible.get_verse_text(book, chapter, verse)
if not verse_text:
raise HTTPException(status_code=404, detail="Verse not found")
# Load commentary data
commentary_data = _load_verse_commentary()
# Navigate to the commentary
if book not in commentary_data:
raise HTTPException(status_code=404, detail="Commentary not available for this book")
if str(chapter) not in commentary_data[book]:
raise HTTPException(status_code=404, detail="Commentary not available for this chapter")
if str(verse) not in commentary_data[book][str(chapter)]:
raise HTTPException(status_code=404, detail="Commentary not available for this verse")
verse_commentary = commentary_data[book][str(chapter)][str(verse)]
return {
"book": book,
"chapter": chapter,
"verse": verse,
"reference": f"{book} {chapter}:{verse}",
"text": verse_text,
"analysis": verse_commentary.get("analysis", ""),
"historical": verse_commentary.get("historical", ""),
"questions": verse_commentary.get("questions", [])
}
@router.get(
"/chapter-commentary/{book}/{chapter}",
response_model=ChapterCommentaryResponse,
summary="Get chapter commentary",
description="Get a brief explanation of what a chapter contains and why it's significant."
)
def api_get_chapter_commentary(
book: str = Path(..., description="Book name", example="Genesis"),
chapter: int = Path(..., description="Chapter number", example=1)
):
"""Get commentary/explanation for a specific chapter."""
canonical_name = normalize_book_name(book)
if canonical_name:
book = canonical_name
# Check if book exists
all_books = bible.get_books()
if book not in all_books:
raise HTTPException(status_code=404, detail=f"Book '{book}' not found")
# Check if chapter exists
chapters = bible.get_chapters_for_book(book)
if chapter not in chapters:
raise HTTPException(status_code=404, detail=f"Chapter {chapter} not found in {book}")
# Get chapter explanation
if book in CHAPTER_EXPLANATIONS and chapter in CHAPTER_EXPLANATIONS[book]:
explanation = CHAPTER_EXPLANATIONS[book][chapter]
else:
# Provide generic explanation if specific one doesn't exist
explanation = f"Chapter {chapter} of {book}"
return {
"book": book,
"chapter": chapter,
"explanation": explanation
}
@router.post(
"/verses/bulk",
response_model=BulkVerseResponse,
summary="Bulk verse lookup",
description="Fetch multiple verses in a single request. Provide an array of verse references like ['John 3:16', 'Romans 8:28']."
)
def api_bulk_verse_lookup(request: BulkVerseRequest):
"""Look up multiple verses in a single request."""
from ..kjv import VerseReference
verses = []
for ref_string in request.references:
try:
# Parse the reference
verse_ref = VerseReference.from_string(ref_string.strip())
# Normalize book name
book = verse_ref.book
canonical_name = normalize_book_name(book)
if canonical_name:
book = canonical_name
# Get verse text
verse_text = bible.get_verse_text(book, verse_ref.chapter, verse_ref.verse)
if verse_text:
# Get red letter information
christ_words = get_christ_words(book, verse_ref.chapter, verse_ref.verse)
verses.append({
"book": book,
"chapter": verse_ref.chapter,
"verse": verse_ref.verse,
"reference": f"{book} {verse_ref.chapter}:{verse_ref.verse}",
"text": verse_text,
"red_letter": christ_words
})
except Exception:
# Skip invalid references
continue
return {
"total": len(verses),
"verses": verses
}
@router.get(
"/family-tree",
response_model=FamilyTreeListResponse,
summary="List all biblical figures",
description="Get a list of all people who have biographies in the family tree database."
)
def api_list_family_tree():
"""List all people with biographies."""
data = _load_biographies()
biographies = data.get("biographies", {})
people = sorted(list(biographies.keys()))
return {
"total": len(people),
"people": people
}
@router.get(
"/family-tree/{name}",
response_model=BiographyResponse,
summary="Get biography of biblical figure",
description="Get detailed biography including summary, significance, and key life events for a specific biblical figure."
)
def api_get_biography(
name: str = Path(..., description="Name of the person", example="Abraham")
):
"""Get biography of a specific person."""
data = _load_biographies()
biographies = data.get("biographies", {})
aliases = data.get("aliases", {})
# Check if name is an alias
if name in aliases:
name = aliases[name]
# Get biography
if name not in biographies:
raise HTTPException(status_code=404, detail=f"Biography for '{name}' not found")
biography = biographies[name]
return {
"name": name,
"summary": biography.get("summary", ""),
"significance": biography.get("significance", ""),
"key_events": biography.get("key_events", [])
}
+229
View File
@@ -706,3 +706,232 @@ class TestRedLetterEndpoints:
assert "endpoints" in data
assert "red_letter" in data["endpoints"]
assert "red_letter_stats" in data["endpoints"]
class TestRandomVerseEndpoint:
"""Tests for random verse endpoint"""
def test_random_verse_basic(self, client):
"""Test basic random verse generation"""
response = client.get("/api/verse/random")
assert response.status_code == 200
data = response.json()
# Verify structure
assert "book" in data
assert "chapter" in data
assert "verse" in data
assert "reference" in data
assert "text" in data
assert "red_letter" in data
# Verify types
assert isinstance(data["book"], str)
assert isinstance(data["chapter"], int)
assert isinstance(data["verse"], int)
def test_random_verse_ot_filter(self, client):
"""Test filtering by Old Testament"""
response = client.get("/api/verse/random?testament=ot")
assert response.status_code == 200
data = response.json()
# OT books don't include Matthew, Mark, Luke, John, etc.
nt_books = ["Matthew", "Mark", "Luke", "John", "Acts", "Romans", "Revelation"]
assert data["book"] not in nt_books
def test_random_verse_nt_filter(self, client):
"""Test filtering by New Testament"""
response = client.get("/api/verse/random?testament=nt")
assert response.status_code == 200
data = response.json()
# NT books include Matthew through Revelation
nt_books = ["Matthew", "Mark", "Luke", "John", "Acts", "Romans", "1 Corinthians",
"2 Corinthians", "Galatians", "Ephesians", "Philippians", "Colossians",
"1 Thessalonians", "2 Thessalonians", "1 Timothy", "2 Timothy", "Titus",
"Philemon", "Hebrews", "James", "1 Peter", "2 Peter", "1 John", "2 John",
"3 John", "Jude", "Revelation"]
assert data["book"] in nt_books
def test_random_verse_book_filter(self, client):
"""Test filtering by specific book"""
response = client.get("/api/verse/random?book=John")
assert response.status_code == 200
data = response.json()
assert data["book"] == "John"
def test_random_verse_invalid_testament(self, client):
"""Test invalid testament parameter"""
response = client.get("/api/verse/random?testament=invalid")
assert response.status_code == 400
class TestCommentaryEndpoint:
"""Tests for verse commentary endpoint"""
def test_get_verse_commentary(self, client):
"""Test getting commentary for a verse"""
response = client.get("/api/commentary/Genesis/1/1")
assert response.status_code == 200
data = response.json()
# Verify structure
assert data["book"] == "Genesis"
assert data["chapter"] == 1
assert data["verse"] == 1
assert "reference" in data
assert "text" in data
assert "analysis" in data
assert "historical" in data
assert "questions" in data
# Verify content
assert isinstance(data["questions"], list)
assert len(data["analysis"]) > 0
def test_commentary_nonexistent_verse(self, client):
"""Test commentary for non-existent verse"""
response = client.get("/api/commentary/Genesis/1/99999")
assert response.status_code == 404
def test_commentary_missing_for_verse(self, client):
"""Test verse with no commentary available"""
# Most verses should have commentary, but check proper 404 handling
response = client.get("/api/commentary/Philemon/1/999")
assert response.status_code == 404
class TestChapterCommentaryEndpoint:
"""Tests for chapter commentary endpoint"""
def test_get_chapter_commentary(self, client):
"""Test getting chapter commentary"""
response = client.get("/api/chapter-commentary/Genesis/1")
assert response.status_code == 200
data = response.json()
assert data["book"] == "Genesis"
assert data["chapter"] == 1
assert "explanation" in data
assert len(data["explanation"]) > 0
def test_chapter_commentary_nonexistent_book(self, client):
"""Test commentary for non-existent book"""
response = client.get("/api/chapter-commentary/InvalidBook/1")
assert response.status_code == 404
def test_chapter_commentary_nonexistent_chapter(self, client):
"""Test commentary for non-existent chapter"""
response = client.get("/api/chapter-commentary/Genesis/999")
assert response.status_code == 404
class TestBulkVerseEndpoint:
"""Tests for bulk verse lookup endpoint"""
def test_bulk_verse_lookup(self, client):
"""Test bulk verse lookup with multiple verses"""
response = client.post("/api/verses/bulk", json={
"references": ["John 3:16", "Romans 8:28", "Psalm 23:1"]
})
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
assert len(data["verses"]) == 3
# Check first verse
assert data["verses"][0]["book"] == "John"
assert data["verses"][0]["chapter"] == 3
assert data["verses"][0]["verse"] == 16
def test_bulk_verse_with_invalid_references(self, client):
"""Test bulk lookup with some invalid references"""
response = client.post("/api/verses/bulk", json={
"references": ["John 3:16", "Invalid Reference", "Genesis 1:1"]
})
assert response.status_code == 200
data = response.json()
# Should return 2 valid verses, skipping invalid
assert data["total"] == 2
def test_bulk_verse_empty_list(self, client):
"""Test bulk lookup with empty list"""
response = client.post("/api/verses/bulk", json={
"references": []
})
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert len(data["verses"]) == 0
class TestFamilyTreeEndpoints:
"""Tests for family tree/biography endpoints"""
def test_list_family_tree(self, client):
"""Test listing all biblical figures"""
response = client.get("/api/family-tree")
assert response.status_code == 200
data = response.json()
assert "total" in data
assert "people" in data
assert data["total"] > 0
assert len(data["people"]) == data["total"]
# Check some expected people
assert "Abraham" in data["people"]
assert "Moses" in data["people"]
assert "David" in data["people"]
def test_get_biography(self, client):
"""Test getting a specific biography"""
response = client.get("/api/family-tree/Abraham")
assert response.status_code == 200
data = response.json()
assert data["name"] == "Abraham"
assert "summary" in data
assert "significance" in data
assert "key_events" in data
# Verify key events structure
assert isinstance(data["key_events"], list)
if len(data["key_events"]) > 0:
event = data["key_events"][0]
assert "age" in event
assert "event" in event
assert "verse" in event
def test_biography_with_alias(self, client):
"""Test getting biography using an alias name"""
response = client.get("/api/family-tree/Israel")
assert response.status_code == 200
data = response.json()
# Should resolve to Jacob
assert data["name"] == "Jacob"
def test_biography_nonexistent_person(self, client):
"""Test getting biography for non-existent person"""
response = client.get("/api/family-tree/NonExistentPerson")
assert response.status_code == 404
def test_api_index_includes_new_endpoints(self, client):
"""Test that API index includes all new endpoints"""
response = client.get("/api/")
assert response.status_code == 200
data = response.json()
assert "endpoints" in data
assert "random_verse" in data["endpoints"]
assert "commentary" in data["endpoints"]
assert "chapter_commentary" in data["endpoints"]
assert "bulk_verses" in data["endpoints"]
assert "family_tree" in data["endpoints"]
assert "biography" in data["endpoints"]