feat: D&D SRD 5.2 API — FastAPI app with flat JSON caching

- Data fetched from Open5e API: 3,215 items (339 spells, 330 creatures,
  24 classes, 2,319 magic items, 203 equipment)
- FastAPI app with API key auth (X-API-Key header or ?api_key= param)
- Sliding window rate limiting (60 req/min, 10K req/day)
- Dice rolling endpoint (e.g., /api/dice/roll?spec=2d20+5)
- Full-text search across all resource types
- Pagination, filtering (name, level, school, class, etc.)
- Admin CLI for API key management
- nginx + systemd service ready for deployment
This commit is contained in:
Cupcake 2026-06-03 18:13:00 +00:00
parent f62803bc7e
commit f6858e6ea1
14 changed files with 58679 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pyo
.env
data/*_raw.json

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# D&D SRD 5.2 API
An open REST API for the Dungeons & Dragons 5.2 System Reference Document (SRD), served at **[dnd.jezzahehn.com](https://dnd.jezzahehn.com)**.
Powered by [Open5e](https://api.open5e.com) data with flat JSON caching.
## Endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /api` | API root — list all available resources |
| `GET /api/spells` | List spells (filter by name, level, school, class, etc.) |
| `GET /api/spells/{key}` | Get a specific spell |
| `GET /api/creatures` | List creatures/monsters |
| `GET /api/creatures/{key}` | Get a specific creature |
| `GET /api/classes` | List classes |
| `GET /api/classes/{key}` | Get a specific class |
| `GET /api/magic-items` | List magic items |
| `GET /api/magic-items/{key}` | Get a specific magic item |
| `GET /api/equipment` | List equipment |
| `GET /api/equipment/{key}` | Get specific equipment |
| `GET /api/dice/roll` | Roll dice (`?spec=2d20+5`) |
| `GET /api/search` | Search across all resources (`?q=fireball`) |
| `GET /api/docs` | Interactive API documentation (Swagger UI) |
## Authentication
Pass your API key via `X-API-Key` header or `?api_key=` query parameter.
```bash
curl -H "X-API-Key: your-key-here" https://dnd.jezzahehn.com/api/spells
```
## Running Locally
```bash
pip install -r requirements.txt
python scripts/fetch_data.py
uvicorn app.main:app --reload
```
## Managing API Keys
```bash
python scripts/manage_keys.py list # List all keys
python scripts/manage_keys.py create "name" # Create a new key
python scripts/manage_keys.py revoke <key> # Revoke a key
```
## Data
- **Spells:** 339
- **Creatures:** 330
- **Classes:** 24
- **Magic Items:** 2,319
- **Equipment:** 203
- **Total:** 3,215 items
Data is fetched from [Open5e](https://api.open5e.com) and cached as flat JSON files in `data/`.

1
app/__init__.py Normal file
View file

@ -0,0 +1 @@
# empty

537
app/main.py Normal file
View file

@ -0,0 +1,537 @@
"""
D&D SRD 5.2 API FastAPI application
Serves SRD data from flat JSON files with API key auth + rate limiting.
"""
import json
import os
import random
import re
import threading
import time
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
# ── Paths ───────────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
API_KEYS_FILE = DATA_DIR / "api_keys.json"
RATE_LIMITS_FILE = DATA_DIR / "rate_limits.json"
# ── Data Loading ────────────────────────────────────────────────────────────
_data_cache = {}
_data_lock = threading.Lock()
_last_load = 0
def load_data():
"""Load all JSON data files into memory cache."""
global _last_load
cache = {}
files = {
"spells": DATA_DIR / "spells.json",
"creatures": DATA_DIR / "creatures.json",
"classes": DATA_DIR / "classes.json",
"magic_items": DATA_DIR / "magic-items.json",
"equipment": DATA_DIR / "equipment.json",
}
for key, path in files.items():
if path.exists():
with open(path) as f:
cache[key] = json.load(f)
print(f" 📖 Loaded {key}: {cache[key]['count']} items")
else:
print(f" ⚠ Data file not found: {path}")
cache[key] = {"count": 0, "results": []}
with _data_lock:
_data_cache.clear()
_data_cache.update(cache)
_last_load = time.time()
return cache
def get_data(collection: str) -> dict:
"""Get a data collection, reloading if cache is stale."""
with _data_lock:
if not _data_cache:
load_data()
if collection not in _data_cache:
load_data()
return _data_cache.get(collection, {"count": 0, "results": []})
# ── API Keys & Rate Limiting ────────────────────────────────────────────────
def load_api_keys() -> dict:
"""Load API keys from JSON file."""
if API_KEYS_FILE.exists():
with open(API_KEYS_FILE) as f:
return json.load(f)
return {"keys": {}}
def save_api_keys(keys: dict):
"""Save API keys to JSON file."""
API_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(API_KEYS_FILE, "w") as f:
json.dump(keys, f, indent=2)
# Rate limiter: sliding window per key
_rate_limits = {} # key -> list of timestamps
_rate_lock = threading.Lock()
RATE_LIMIT_CONFIG = {"requests_per_minute": 60, "requests_per_day": 10000}
def check_rate_limit(api_key: str) -> Optional[JSONResponse]:
"""Check if request is within rate limits. Returns error response if exceeded."""
now = time.time()
with _rate_lock:
if api_key not in _rate_limits:
_rate_limits[api_key] = []
# Clean old entries (older than 1 minute)
timestamps = _rate_limits[api_key]
cutoff = now - 60
timestamps[:] = [t for t in timestamps if t > cutoff]
# Check per-minute limit
if len(timestamps) >= RATE_LIMIT_CONFIG["requests_per_minute"]:
return JSONResponse(
status_code=429,
content={
"error": "Rate limit exceeded",
"message": f"Max {RATE_LIMIT_CONFIG['requests_per_minute']} requests per minute",
"retry_after": 60,
},
)
# Check per-day limit (count all timestamps in last 24h)
day_cutoff = now - 86400
day_count = sum(1 for t in timestamps if t > day_cutoff)
if day_count >= RATE_LIMIT_CONFIG["requests_per_day"]:
return JSONResponse(
status_code=429,
content={
"error": "Daily rate limit exceeded",
"message": f"Max {RATE_LIMIT_CONFIG['requests_per_day']} requests per day",
},
)
timestamps.append(now)
return None
# Page through all rate limits without archive
def _has_rate_limit_archive(coll: str) -> bool:
return False
# ── Auth Middleware ──────────────────────────────────────────────────────────
AUTH_EXEMPT_PATHS = {"/api/docs", "/api/openapi.json", "/api/redoc", "/health"}
async def auth_middleware(request: Request, call_next):
"""API key authentication middleware."""
path = request.url.path
# Skip auth for docs and health
if any(path.startswith(p) for p in AUTH_EXEMPT_PATHS) or path == "/api":
return await call_next(request)
# Get API key from header or query param
api_key = request.headers.get("X-API-Key") or request.query_params.get("api_key")
if not api_key:
return JSONResponse(
status_code=401,
content={"error": "Missing API key", "message": "Provide via X-API-Key header or ?api_key= parameter"},
)
keys = load_api_keys()
if api_key not in keys.get("keys", {}):
return JSONResponse(
status_code=403,
content={"error": "Invalid API key", "message": "The provided API key is not valid"},
)
# Check rate limit
rate_check = check_rate_limit(api_key)
if rate_check:
return rate_check
return await call_next(request)
# ── Filtering Helpers ────────────────────────────────────────────────────────
def filter_spells(spells: list, params: dict) -> list:
"""Apply filters to spells list."""
result = spells
for key, value in params.items():
if key == "name":
result = [s for s in result if value.lower() in s.get("name", "").lower()]
elif key == "level":
try:
level = int(value)
result = [s for s in result if s.get("level") == level]
except ValueError:
pass
elif key == "school":
result = [s for s in result if value.lower() in s.get("school", "").lower()]
elif key == "class":
result = [s for s in result if any(value.lower() in c.lower() for c in s.get("classes", []))]
elif key == "concentration":
val = value.lower() in ("true", "1", "yes")
result = [s for s in result if s.get("concentration") == val]
elif key == "ritual":
val = value.lower() in ("true", "1", "yes")
result = [s for s in result if s.get("ritual") == val]
elif key == "search":
val = value.lower()
result = [s for s in result if val in s.get("name", "").lower() or val in s.get("description", "").lower()]
return result
def filter_creatures(creatures: list, params: dict) -> list:
"""Apply filters to creatures list."""
result = creatures
for key, value in params.items():
if key == "name":
result = [c for c in result if value.lower() in c.get("name", "").lower()]
elif key == "type":
result = [c for c in result if value.lower() in c.get("type", "").lower()]
elif key == "cr" or key == "challenge_rating":
result = [c for c in result if str(c.get("challenge_rating", "")) == value]
elif key == "size":
result = [c for c in result if value.lower() in c.get("size", "").lower()]
elif key == "alignment":
result = [c for c in result if value.lower() in c.get("alignment", "").lower()]
elif key == "search":
val = value.lower()
result = [c for c in result if val in c.get("name", "").lower() or val in c.get("description", "").lower()]
return result
def filter_items(items: list, params: dict) -> list:
"""Apply filters to items list."""
result = items
for key, value in params.items():
if key == "name":
result = [i for i in result if value.lower() in i.get("name", "").lower()]
elif key == "type":
result = [i for i in result if value.lower() in i.get("type", "").lower()]
elif key == "rarity":
result = [i for i in result if value.lower() in i.get("rarity", "").lower()]
elif key == "search":
val = value.lower()
result = [i for i in result if val in i.get("name", "").lower() or val in i.get("description", "").lower()]
return result
def paginate(items: list, page: int = 1, page_size: int = 50) -> dict:
"""Paginate a list of items."""
total = len(items)
total_pages = max(1, (total + page_size - 1) // page_size)
page = max(1, min(page, total_pages))
start = (page - 1) * page_size
end = start + page_size
return {
"count": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"next": f"?page={page + 1}&page_size={page_size}" if page < total_pages else None,
"previous": f"?page={page - 1}&page_size={page_size}" if page > 1 else None,
"results": items[start:end],
}
# ── App Initialization ──────────────────────────────────────────────────────
app = FastAPI(
title="D&D SRD 5.2 API",
description="An open API for the Dungeons & Dragons 5.2 System Reference Document (SRD). "
"Powered by Open5e data with flat JSON caching.\n\n"
"**Authentication:** Pass `X-API-Key` header or `?api_key=` query parameter.",
version="1.0.0",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.middleware("http")(auth_middleware)
# ── Startup ──────────────────────────────────────────────────────────────────
@app.on_event("startup")
async def startup():
print("🚀 D&D SRD 5.2 API starting up...")
load_data()
print(f"✅ Loaded {sum(v['count'] for v in _data_cache.values())} total items")
# ── Dice Rolling ────────────────────────────────────────────────────────────
DICE_PATTERN = re.compile(r"^(\d*)d(\d+)([+-]\d+)?$")
def roll_dice(spec: str) -> dict:
"""Parse and roll dice notation like '2d20+5' or 'd6'."""
m = DICE_PATTERN.match(spec.lower().replace(" ", ""))
if not m:
return {"error": f"Invalid dice notation: '{spec}'. Use format like 2d20+5, d6, 3d8-2"}
num = int(m.group(1)) if m.group(1) else 1
sides = int(m.group(2))
mod = int(m.group(3)) if m.group(3) else 0
if num < 1 or num > 100:
return {"error": "Number of dice must be between 1 and 100"}
if sides < 2 or sides > 1000:
return {"error": "Dice sides must be between 2 and 1000"}
rolls = [random.randint(1, sides) for _ in range(num)]
total = sum(rolls) + mod
return {
"spec": spec.strip(),
"num_dice": num,
"sides": sides,
"modifier": mod,
"rolls": rolls,
"total": max(total, 1), # Minimum 1
}
# ── Endpoints ────────────────────────────────────────────────────────────────
@app.get("/")
async def root_redirect():
return {"message": "D&D SRD 5.2 API", "docs": "/api/docs", "api_root": "/api"}
@app.get("/api")
async def api_root():
return {
"name": "D&D SRD 5.2 API",
"version": "1.0.0",
"documentation": "/api/docs",
"endpoints": {
"spells": "/api/spells",
"creatures": "/api/creatures",
"classes": "/api/classes",
"magic_items": "/api/magic-items",
"equipment": "/api/equipment",
"dice": "/api/dice/roll",
},
}
@app.get("/health")
async def health():
data_count = sum(v["count"] for v in _data_cache.values()) if _data_cache else 0
return {"status": "healthy", "data_items": data_count, "cache_age_s": int(time.time() - _last_load) if _last_load else 0}
# ── Spells ───────────────────────────────────────────────────────────────────
@app.get("/api/spells")
async def list_spells(
request: Request,
name: Optional[str] = Query(None, description="Filter by name (partial match)"),
level: Optional[int] = Query(None, description="Filter by spell level (0=cantrip, 1-9)"),
school: Optional[str] = Query(None, description="Filter by school of magic"),
class_name: Optional[str] = Query(None, alias="class", description="Filter by class name"),
concentration: Optional[bool] = Query(None),
ritual: Optional[bool] = Query(None),
search: Optional[str] = Query(None, description="Search name and description"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
):
data = get_data("spells")
params = {k: v for k, v in {
"name": name, "level": level, "school": school, "class": class_name,
"concentration": concentration, "ritual": ritual, "search": search,
}.items() if v is not None}
filtered = filter_spells(data["results"], params)
return paginate(filtered, page, page_size)
@app.get("/api/spells/{spell_key}")
async def get_spell(spell_key: str):
data = get_data("spells")
for spell in data["results"]:
if spell.get("key") == spell_key:
return spell
raise HTTPException(status_code=404, detail=f"Spell '{spell_key}' not found")
# ── Creatures ────────────────────────────────────────────────────────────────
@app.get("/api/creatures")
async def list_creatures(
name: Optional[str] = Query(None),
type: Optional[str] = Query(None),
cr: Optional[str] = Query(None, alias="challenge_rating"),
size: Optional[str] = Query(None),
alignment: Optional[str] = Query(None),
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
):
data = get_data("creatures")
params = {k: v for k, v in {
"name": name, "type": type, "cr": cr, "size": size,
"alignment": alignment, "search": search,
}.items() if v is not None}
filtered = filter_creatures(data["results"], params)
return paginate(filtered, page, page_size)
@app.get("/api/creatures/{creature_key}")
async def get_creature(creature_key: str):
data = get_data("creatures")
for creature in data["results"]:
if creature.get("key") == creature_key:
return creature
raise HTTPException(status_code=404, detail=f"Creature '{creature_key}' not found")
# ── Classes ──────────────────────────────────────────────────────────────────
@app.get("/api/classes")
async def list_classes(
name: Optional[str] = Query(None),
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
):
data = get_data("classes")
results = data["results"]
if name:
results = [c for c in results if name.lower() in c.get("name", "").lower()]
if search:
val = search.lower()
results = [c for c in results if val in c.get("name", "").lower() or val in c.get("description", "").lower()]
return paginate(results, page, page_size)
@app.get("/api/classes/{class_key}")
async def get_class(class_key: str):
data = get_data("classes")
for cls in data["results"]:
if cls.get("key") == class_key:
return cls
raise HTTPException(status_code=404, detail=f"Class '{class_key}' not found")
# ── Magic Items ──────────────────────────────────────────────────────────────
@app.get("/api/magic-items")
async def list_magic_items(
name: Optional[str] = Query(None),
type: Optional[str] = Query(None),
rarity: Optional[str] = Query(None),
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
):
data = get_data("magic_items")
params = {k: v for k, v in {"name": name, "type": type, "rarity": rarity, "search": search}.items() if v is not None}
filtered = filter_items(data["results"], params)
return paginate(filtered, page, page_size)
@app.get("/api/magic-items/{item_key}")
async def get_magic_item(item_key: str):
data = get_data("magic_items")
for item in data["results"]:
if item.get("key") == item_key:
return item
raise HTTPException(status_code=404, detail=f"Magic item '{item_key}' not found")
# ── Equipment ────────────────────────────────────────────────────────────────
@app.get("/api/equipment")
async def list_equipment(
name: Optional[str] = Query(None),
type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
):
data = get_data("equipment")
params = {k: v for k, v in {"name": name, "type": type, "search": search}.items() if v is not None}
filtered = filter_items(data["results"], params)
return paginate(filtered, page, page_size)
@app.get("/api/equipment/{item_key}")
async def get_equipment(item_key: str):
data = get_data("equipment")
for item in data["results"]:
if item.get("key") == item_key:
return item
raise HTTPException(status_code=404, detail=f"Equipment '{item_key}' not found")
# ── Dice Rolling ─────────────────────────────────────────────────────────────
@app.get("/api/dice/roll")
async def dice_roll(
spec: str = Query("d20", description="Dice notation (e.g., 2d20+5, d6, 3d8)"),
count: int = Query(1, ge=1, le=10, description="Number of times to roll"),
):
if count > 1:
rolls = [roll_dice(spec) for _ in range(count)]
grand_total = sum(r.get("total", 0) for r in rolls if "error" not in r)
errors = [r for r in rolls if "error" in r]
result = {
"spec": spec,
"rolls": rolls,
"grand_total": grand_total,
"count": count,
}
if errors:
result["errors"] = errors
return result
else:
return roll_dice(spec)
# ── Search ───────────────────────────────────────────────────────────────────
@app.get("/api/search")
async def search_all(
q: str = Query(..., description="Search query"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
):
"""Search across all resource types."""
query = q.lower()
results = []
for collection_key, label in [
("spells", "spells"),
("creatures", "creatures"),
("classes", "classes"),
("magic_items", "magic-items"),
("equipment", "equipment"),
]:
data = get_data(collection_key)
for item in data["results"]:
name = item.get("name", "").lower()
desc = (item.get("description", "") or "").lower()
if query in name or query in desc:
item["_type"] = label
results.append(item)
return paginate(results, page, page_size)
# ── Run ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=True)

197
data/classes.json Normal file
View file

@ -0,0 +1,197 @@
{
"count": 24,
"results": [
{
"key": "srd-2024_barbarian",
"name": "Barbarian",
"hit_dice": "D12",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_bard",
"name": "Bard",
"hit_dice": "D8",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_champion",
"name": "Champion",
"hit_dice": null,
"description": "*Pursue Physical Excellence in Combat*\n\nA Champion focuses on the development of martial prowess in a relentless pursuit of victory. Champions combine rigorous training with physical excellence to deal devastating blows, withstand peril, and garner glory. Whether in athletic contests or bloody battle, Champions strive for the crown of the victor.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_circle-of-the-land",
"name": "Circle of the Land",
"hit_dice": null,
"description": "*Celebrate Connection to the Natural World*\n\nThe Circle of the Land comprises mystics and sages who safeguard ancient knowledge and rites. These Druids meet within sacred circles of trees or standing stones to whisper primal secrets in Druidic. The circle's wisest members preside as the chief priests of their communities.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_cleric",
"name": "Cleric",
"hit_dice": "D8",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_college-of-lore",
"name": "College of Lore",
"hit_dice": "D8",
"description": "*Plumb the Depths of Magical Knowledge*\n\nBards of the College of Lore collect spells and secrets from diverse sources, such as scholarly tomes, mystical rites, and peasant tales. The college's members gather in libraries and universities to share their lore with one another. They also meet at festivals or affairs of state, where they can expose corruption, unravel lies, and poke fun at self-important figures of authority.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_draconic-sorcery",
"name": "Draconic Sorcery",
"hit_dice": null,
"description": "*Breathe the Magic of Dragons*\n\nYour innate magic comes from the gift of a dragon. Perhaps an ancient dragon facing death bequeathed some of its magical power to you or your ancestor. You might have absorbed magic from a site infused with dragons' power. Or perhaps you handled a treasure taken from a dragon's hoard that was steeped in draconic power. Or you might have a dragon for an ancestor.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_druid",
"name": "Druid",
"hit_dice": "D8",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_evoker",
"name": "Evoker",
"hit_dice": null,
"description": "*Create Explosive Elemental Effects*\n\nYour studies focus on magic that creates powerful elemental effects such as bitter cold, searing flame, rolling thunder, crackling lightning, and burning acid. Some Evokers find employment in military forces, serving as artillery to blast armies from afar. Others use their power to protect others, while some seek their own gain.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_fiend-patron",
"name": "Fiend Patron",
"hit_dice": null,
"description": "*Make a Deal with the Lower Planes*\n\nYour pact draws on the Lower Planes, the realms of perdition. You might forge a bargain with a demon lord, an archdevil, or another fiend that is especially mighty. That patron's aims are evil\u2014the corruption or destruction of all things, ultimately including you\u2014and your path is defined by the extent to which you strive against those aims.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_fighter",
"name": "Fighter",
"hit_dice": "D10",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_hunter",
"name": "Hunter",
"hit_dice": null,
"description": "*Protect Nature and People from Destruction*\n\nYou stalk prey in the wilds and elsewhere, using your abilities as a Hunter to protect nature and people everywhere from forces that would destroy them.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_life-domain",
"name": "Life Domain",
"hit_dice": null,
"description": "*Soothe the Hurts of the World*\n\nThe Life Domain focuses on the positive energy that helps sustain all life in the multiverse. Clerics who tap into this domain are masters of healing, using that life force to cure many hurts.\n\nExistence itself relies on the positive energy associated with this domain, so a Cleric of almost any religious tradition might choose it. This domain is particularly associated with agricultural deities, gods of healing or endurance, and gods of home and community. Religious orders of healing also seek the magic of this domain.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_monk",
"name": "Monk",
"hit_dice": "D8",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_oath-of-devotion",
"name": "Oath of Devotion",
"hit_dice": null,
"description": "*Uphold the Ideals of Justice and Order*\n\nThe Oath of Devotion binds Paladins to the ideals of justice and order. These Paladins meet the archetype of the knight in shining armor. They hold themselves to the highest standards of conduct, and some\u2014for better or worse\u2014hold the rest of the world to the same standards.\n\nMany who swear this oath are devoted to gods of law and good and use their gods' tenets as the measure of personal devotion. Others hold angels as their ideals and incorporate images of angelic wings into their helmets or coats of arms.\n\nThese paladins share the following tenets:\n\n- Let your word be your promise.\n- Protect the weak and never fear to act.\n- Let your honorable deeds be an example.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_paladin",
"name": "Paladin",
"hit_dice": "D10",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_path-of-the-berserker",
"name": "Path of the Berserker",
"hit_dice": "D12",
"description": "*Channel Rage into Violent Fury*\n\nBarbarians who walk the Path of the Berserker direct their Rage primarily toward violence. Their path is one of untrammeled fury, and they thrill in the chaos of battle as they allow their Rage to seize and empower them.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_ranger",
"name": "Ranger",
"hit_dice": "D10",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_rogue",
"name": "Rogue",
"hit_dice": "D8",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_sorcerer",
"name": "Sorcerer",
"hit_dice": "D6",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_thief",
"name": "Thief",
"hit_dice": null,
"description": "*Hunt for Treasure as a Classic Adventurer*\n\nA mix of burglar, treasure hunter, and explorer, you are the epitome of an adventurer. In addition to improving your agility and stealth, you gain abilities useful for delving into ruins and getting maximum benefit from the magic items you find there.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_warlock",
"name": "Warlock",
"hit_dice": "D8",
"description": "",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_warrior-of-the-open-hand",
"name": "Warrior of the Open Hand",
"hit_dice": null,
"description": "*Master Unarmed Combat Techniques*\n\nWarriors of the Open Hand are masters of unarmed combat. They learn techniques to push and trip their opponents and manipulate their own energy to protect themselves from harm.",
"spellcasting_ability": null,
"subclasses": []
},
{
"key": "srd-2024_wizard",
"name": "Wizard",
"hit_dice": "D6",
"description": "",
"spellcasting_ability": null,
"subclasses": []
}
]
}

22898
data/creatures.json Normal file

File diff suppressed because it is too large Load diff

3056
data/equipment.json Normal file

File diff suppressed because it is too large Load diff

22858
data/magic-items.json Normal file

File diff suppressed because one or more lines are too long

8687
data/spells.json Normal file

File diff suppressed because it is too large Load diff

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
fastapi>=0.100.0
uvicorn[standard]>=0.20.0

44
scripts/dnd-api.nginx Normal file
View file

@ -0,0 +1,44 @@
server {
listen 80;
server_name dnd.jezzahehn.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name dnd.jezzahehn.com;
ssl_certificate /etc/letsencrypt/live/dnd.jezzahehn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dnd.jezzahehn.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long timeout for large responses
proxy_read_timeout 30s;
proxy_send_timeout 30s;
# Increase buffer for API responses
proxy_buffers 8 64k;
proxy_buffer_size 64k;
}
# Deny access to sensitive files
location ~ /\.git {
deny all;
}
access_log /var/log/nginx/dnd-api-access.log;
error_log /var/log/nginx/dnd-api-error.log;
}

16
scripts/dnd-api.service Normal file
View file

@ -0,0 +1,16 @@
[Unit]
Description=D&D SRD 5.2 API Server
After=network.target
[Service]
Type=simple
User=hermes
WorkingDirectory=/home/hermes/workspace/dnd-srd-api
ExecStart=/usr/local/lib/hermes-agent/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target

225
scripts/fetch_data.py Normal file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Fetch all SRD 5.2 data from Open5e API and cache as flat JSON files.
"""
import json
import os
import sys
import time
import urllib.request
import urllib.error
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
SRD_FILTER = "document__key__in=srd-2024"
SOURCES = {
"spells": f"https://api.open5e.com/v2/spells/?limit=100&{SRD_FILTER}",
"creatures": f"https://api.open5e.com/v2/creatures/?limit=100&{SRD_FILTER}",
"classes": f"https://api.open5e.com/v2/classes/?limit=100&{SRD_FILTER}",
"magic-items": "https://api.open5e.com/v2/magicitems/?limit=100",
"equipment": f"https://api.open5e.com/v2/items/?limit=100&{SRD_FILTER}",
}
os.makedirs(DATA_DIR, exist_ok=True)
def fetch_page(url: str, retries: int = 3) -> dict:
"""Fetch a single page from the API with retries."""
for attempt in range(retries):
try:
req = urllib.request.Request(url, headers={"User-Agent": "Cupcake-DnD-API/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
return data
except Exception as e:
if attempt < retries - 1:
wait = 2 ** attempt
print(f" ⚠ Retry {attempt + 1}/{retries} after {wait}s: {e}")
time.sleep(wait)
else:
raise
def fetch_all(name: str, first_url: str) -> list:
"""Fetch all pages of a resource, returning combined results."""
print(f"\n📦 Fetching {name}...")
all_results = []
url = first_url
page = 0
while url:
page += 1
print(f" 📄 Page {page}: {url[:80]}...")
data = fetch_page(url)
results = data.get("results", [])
all_results.extend(results)
print(f" Got {len(results)} items (total: {len(all_results)})")
url = data.get("next")
print(f"{name}: {len(all_results)} total items")
return all_results
def simplify_spell(s: dict) -> dict:
"""Extract key fields from a spell."""
return {
"key": s.get("key", "").replace("srd-2024_", ""),
"name": s.get("name", ""),
"level": s.get("level", 0),
"school": s.get("school", {}).get("name") if isinstance(s.get("school"), dict) else s.get("school"),
"casting_time": s.get("casting_time", ""),
"range": s.get("range", ""),
"components": {"material": s.get("material"), "verbal": s.get("verbal"), "somatic": s.get("somatic")},
"duration": s.get("duration", ""),
"concentration": s.get("concentration", False),
"ritual": s.get("ritual", False),
"description": s.get("desc", ""),
"higher_level": s.get("higher_level", ""),
"classes": [c.get("name") for c in (s.get("classes") or [])] if isinstance(s.get("classes"), list) else [],
"subclasses": [c.get("name") for c in (s.get("subclasses") or [])] if isinstance(s.get("subclasses"), list) else [],
}
def simplify_creature(c: dict) -> dict:
"""Extract key fields from a creature."""
return {
"key": c.get("key", ""),
"name": c.get("name", ""),
"size": c.get("size", {}).get("name") if isinstance(c.get("size"), dict) else c.get("size"),
"type": c.get("type", {}).get("name") if isinstance(c.get("type"), dict) else c.get("type"),
"alignment": c.get("alignment", ""),
"armor_class": c.get("armor_class", 0),
"hit_points": c.get("hit_points", 0),
"hit_dice": c.get("hit_dice", ""),
"speed": c.get("speed", {}),
"strength": c.get("strength", 10),
"dexterity": c.get("dexterity", 10),
"constitution": c.get("constitution", 10),
"intelligence": c.get("intelligence", 10),
"wisdom": c.get("wisdom", 10),
"charisma": c.get("charisma", 10),
"saving_throws": c.get("saving_throws", {}),
"skills": c.get("skills", {}),
"damage_vulnerabilities": c.get("damage_vulnerabilities", []),
"damage_resistances": c.get("damage_resistances", []),
"damage_immunities": c.get("damage_immunities", []),
"condition_immunities": c.get("condition_immunities", []),
"senses": c.get("senses", ""),
"languages": c.get("languages", ""),
"challenge_rating": c.get("challenge_rating", "0"),
"xp": c.get("xp", 0),
"traits": [{"name": t.get("name"), "desc": t.get("desc")} for t in (c.get("traits") or [])],
"actions": [{"name": a.get("name"), "desc": a.get("desc")} for a in (c.get("actions") or [])],
"legendary_actions": [{"name": a.get("name"), "desc": a.get("desc")} for a in (c.get("legendary_actions") or [])],
"description": c.get("desc", ""),
}
def simplify_class(c: dict) -> dict:
"""Extract key fields from a class."""
return {
"key": c.get("key", ""),
"name": c.get("name", ""),
"hit_dice": c.get("hit_dice", ""),
"description": c.get("desc", ""),
"spellcasting_ability": c.get("spellcasting_ability", {}).get("key") if isinstance(c.get("spellcasting_ability"), dict) else c.get("spellcasting_ability"),
"subclasses": [s.get("name") for s in (c.get("subclasses") or [])] if isinstance(c.get("subclasses"), list) else [],
}
def simplify_magic_item(m: dict) -> dict:
"""Extract key fields from a magic item."""
return {
"key": m.get("key", ""),
"name": m.get("name", ""),
"type": m.get("type", {}).get("name") if isinstance(m.get("type"), dict) else m.get("type"),
"rarity": m.get("rarity", {}).get("name") if isinstance(m.get("rarity"), dict) else m.get("rarity"),
"requires_attunement": m.get("requires_attunement", ""),
"description": m.get("desc", ""),
}
def simplify_item(i: dict) -> dict:
"""Extract key fields from equipment/item."""
return {
"key": i.get("key", ""),
"name": i.get("name", ""),
"type": i.get("type", {}).get("name") if isinstance(i.get("type"), dict) else i.get("type"),
"cost": i.get("cost", ""),
"weight": i.get("weight", ""),
"description": i.get("desc", ""),
"armor_class": i.get("armor_class"),
"stealth_disadvantage": i.get("stealth_disadvantage", False),
"strength_requirement": i.get("strength_requirement"),
"damage": i.get("damage", {}).get("damage_dice") if isinstance(i.get("damage"), dict) else None,
"properties": [p.get("name") for p in (i.get("properties") or [])] if isinstance(i.get("properties"), list) else [],
}
SIMPLIFIERS = {
"spells": simplify_spell,
"creatures": simplify_creature,
"classes": simplify_class,
"magic-items": simplify_magic_item,
"equipment": simplify_item,
}
def main():
for name, first_url in SOURCES.items():
try:
raw_data = fetch_all(name, first_url)
simplifier = SIMPLIFIERS.get(name)
if simplifier:
simplified = [simplifier(item) for item in raw_data]
else:
simplified = raw_data
# Build index: name -> slug and key -> item
indexed = {
item.get("key"): item
for item in simplified
if item.get("key")
}
# Also build a name index
name_index = {}
for item in simplified:
name_lower = item.get("name", "").lower()
slug = item.get("key", "")
if name_lower:
name_index[name_lower] = slug
output = {
"count": len(simplified),
"results": simplified,
"_index": {
"by_key": {k: True for k in indexed.keys()},
"by_name": name_index,
}
}
# Remove _index for display but keep for reference
filepath = os.path.join(DATA_DIR, f"{name}.json")
with open(filepath, "w") as f:
json.dump(output, f, indent=2)
print(f"💾 Saved {len(simplified)} items to {filepath}")
# Also save raw data for debugging
raw_filepath = os.path.join(DATA_DIR, f"{name}_raw.json")
with open(raw_filepath, "w") as f:
json.dump({"count": len(raw_data), "results": raw_data}, f, indent=2)
print(f"💾 Raw backup saved to {raw_filepath}")
except Exception as e:
print(f"❌ Failed to fetch {name}: {e}")
sys.exit(1)
print("\n✨ All data fetched successfully!")
print(json.dumps({"status": "ok", "dir": DATA_DIR}, indent=2))
if __name__ == "__main__":
main()

94
scripts/manage_keys.py Normal file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Admin CLI for managing D&D SRD API keys.
Usage:
python scripts/manage_keys.py list
python scripts/manage_keys.py create <name> [--rate-limit N]
python scripts/manage_keys.py revoke <key>
"""
import argparse
import json
import os
import secrets
import sys
from pathlib import Path
API_KEYS_FILE = Path(__file__).resolve().parent.parent / "data" / "api_keys.json"
def load_keys() -> dict:
if API_KEYS_FILE.exists():
with open(API_KEYS_FILE) as f:
return json.load(f)
return {"keys": {}}
def save_keys(keys: dict):
API_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(API_KEYS_FILE, "w") as f:
json.dump(keys, f, indent=2)
def cmd_list(args):
keys = load_keys()
if not keys["keys"]:
print("No API keys configured.")
return
print(f"{'Name':<20} {'Key':<40} {'Created':<20}")
print("-" * 80)
for key, info in keys["keys"].items():
print(f"{info.get('name', 'unknown'):<20} {key:<40} {info.get('created', ''):<20}")
def cmd_create(args):
keys = load_keys()
new_key = "dnd_" + secrets.token_hex(16)
keys["keys"][new_key] = {
"name": args.name,
"created": os.popen("date -Iseconds").read().strip(),
"rate_limit": args.rate_limit or 60,
}
save_keys(keys)
print(f"✅ Created API key for '{args.name}':")
print(f" Key: {new_key}")
print(f" Rate limit: {args.rate_limit or 60} req/min")
def cmd_revoke(args):
keys = load_keys()
if args.key in keys["keys"]:
info = keys["keys"].pop(args.key)
save_keys(keys)
print(f"✅ Revoked key for '{info['name']}' ({args.key[:20]}...)")
else:
print(f"❌ Key not found: {args.key[:20]}...")
def main():
parser = argparse.ArgumentParser(description="D&D SRD API Key Manager")
sub = parser.add_subparsers(dest="command")
p_list = sub.add_parser("list", help="List all API keys")
p_create = sub.add_parser("create", help="Create a new API key")
p_create.add_argument("name", help="Name for the key (e.g., 'jez-personal')")
p_create.add_argument("--rate-limit", type=int, default=60, help="Requests per minute limit")
p_revoke = sub.add_parser("revoke", help="Revoke an API key")
p_revoke.add_argument("key", help="Full API key to revoke")
args = parser.parse_args()
if args.command == "list":
cmd_list(args)
elif args.command == "create":
cmd_create(args)
elif args.command == "revoke":
cmd_revoke(args)
else:
parser.print_help()
if __name__ == "__main__":
main()