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

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()