Compare commits
3 commits
6c29823d45
...
b1378000a2
| Author | SHA1 | Date | |
|---|---|---|---|
| b1378000a2 | |||
| 0327a088f1 | |||
| f6858e6ea1 |
14 changed files with 58736 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
data/*_raw.json
|
||||
data/api_keys.json
|
||||
data/rate_limits.json
|
||||
59
README.md
Normal file
59
README.md
Normal 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
1
app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# empty
|
||||
537
app/main.py
Normal file
537
app/main.py
Normal 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() or value.lower() in i.get("category", "").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)
|
||||
251
data/classes.json
Normal file
251
data/classes.json
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
{
|
||||
"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": []
|
||||
}
|
||||
],
|
||||
"_index": {
|
||||
"by_key": {
|
||||
"srd-2024_barbarian": true,
|
||||
"srd-2024_bard": true,
|
||||
"srd-2024_champion": true,
|
||||
"srd-2024_circle-of-the-land": true,
|
||||
"srd-2024_cleric": true,
|
||||
"srd-2024_college-of-lore": true,
|
||||
"srd-2024_draconic-sorcery": true,
|
||||
"srd-2024_druid": true,
|
||||
"srd-2024_evoker": true,
|
||||
"srd-2024_fiend-patron": true,
|
||||
"srd-2024_fighter": true,
|
||||
"srd-2024_hunter": true,
|
||||
"srd-2024_life-domain": true,
|
||||
"srd-2024_monk": true,
|
||||
"srd-2024_oath-of-devotion": true,
|
||||
"srd-2024_paladin": true,
|
||||
"srd-2024_path-of-the-berserker": true,
|
||||
"srd-2024_ranger": true,
|
||||
"srd-2024_rogue": true,
|
||||
"srd-2024_sorcerer": true,
|
||||
"srd-2024_thief": true,
|
||||
"srd-2024_warlock": true,
|
||||
"srd-2024_warrior-of-the-open-hand": true,
|
||||
"srd-2024_wizard": true
|
||||
},
|
||||
"by_name": {
|
||||
"barbarian": "srd-2024_barbarian",
|
||||
"bard": "srd-2024_bard",
|
||||
"champion": "srd-2024_champion",
|
||||
"circle of the land": "srd-2024_circle-of-the-land",
|
||||
"cleric": "srd-2024_cleric",
|
||||
"college of lore": "srd-2024_college-of-lore",
|
||||
"draconic sorcery": "srd-2024_draconic-sorcery",
|
||||
"druid": "srd-2024_druid",
|
||||
"evoker": "srd-2024_evoker",
|
||||
"fiend patron": "srd-2024_fiend-patron",
|
||||
"fighter": "srd-2024_fighter",
|
||||
"hunter": "srd-2024_hunter",
|
||||
"life domain": "srd-2024_life-domain",
|
||||
"monk": "srd-2024_monk",
|
||||
"oath of devotion": "srd-2024_oath-of-devotion",
|
||||
"paladin": "srd-2024_paladin",
|
||||
"path of the berserker": "srd-2024_path-of-the-berserker",
|
||||
"ranger": "srd-2024_ranger",
|
||||
"rogue": "srd-2024_rogue",
|
||||
"sorcerer": "srd-2024_sorcerer",
|
||||
"thief": "srd-2024_thief",
|
||||
"warlock": "srd-2024_warlock",
|
||||
"warrior of the open hand": "srd-2024_warrior-of-the-open-hand",
|
||||
"wizard": "srd-2024_wizard"
|
||||
}
|
||||
}
|
||||
}
|
||||
22898
data/creatures.json
Normal file
22898
data/creatures.json
Normal file
File diff suppressed because it is too large
Load diff
3056
data/equipment.json
Normal file
3056
data/equipment.json
Normal file
File diff suppressed because it is too large
Load diff
22858
data/magic-items.json
Normal file
22858
data/magic-items.json
Normal file
File diff suppressed because one or more lines are too long
8687
data/spells.json
Normal file
8687
data/spells.json
Normal file
File diff suppressed because it is too large
Load diff
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
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
|
||||
226
scripts/fetch_data.py
Normal file
226
scripts/fetch_data.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/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."""
|
||||
cat = i.get("category") or {}
|
||||
return {
|
||||
"key": i.get("key", ""),
|
||||
"name": i.get("name", ""),
|
||||
"category": cat.get("name") if isinstance(cat, dict) else cat,
|
||||
"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