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:
parent
f62803bc7e
commit
f6858e6ea1
14 changed files with 58679 additions and 0 deletions
44
scripts/dnd-api.nginx
Normal file
44
scripts/dnd-api.nginx
Normal 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
16
scripts/dnd-api.service
Normal 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
225
scripts/fetch_data.py
Normal 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
94
scripts/manage_keys.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue