feat(api): origin seed JSON Schema + validator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
api/app/canon_log.py
Normal file
49
api/app/canon_log.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Load the JSON Schema contracts and validate canon logs / origin seeds.
|
||||
|
||||
Schemas are the single source of truth in /docs/schemas. This module is the
|
||||
api's half of the contract (charter §11): every canon log the client posts is
|
||||
validated here before it could ever reach a prompt.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
|
||||
def _schema_dir() -> Path:
|
||||
"""Locate the schema directory.
|
||||
|
||||
Honours CANON_SCHEMA_DIR, else walks up from this file looking for
|
||||
docs/schemas (local checkout) or schemas (bundled into the Docker image).
|
||||
"""
|
||||
env = os.environ.get("CANON_SCHEMA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
for candidate in (parent / "docs" / "schemas", parent / "schemas"):
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
raise RuntimeError("schema directory not found")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _validator(schema_name: str) -> Draft202012Validator:
|
||||
with open(_schema_dir() / schema_name) as f:
|
||||
return Draft202012Validator(json.load(f))
|
||||
|
||||
|
||||
def validation_errors(doc: dict, schema_name: str) -> list[str]:
|
||||
validator = _validator(schema_name)
|
||||
return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: list(e.path))]
|
||||
|
||||
|
||||
def validate_origin(doc: dict) -> list[str]:
|
||||
return validation_errors(doc, "origin.schema.json")
|
||||
|
||||
|
||||
def validate_canon_log(doc: dict) -> list[str]:
|
||||
return validation_errors(doc, "canon-log.schema.json")
|
||||
3
api/pytest.ini
Normal file
3
api/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
2
api/requirements-dev.txt
Normal file
2
api/requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
# Dev/test deps. Installed on top of requirements.txt.
|
||||
pytest==8.3.2
|
||||
@@ -3,3 +3,4 @@ fastapi==0.139.0
|
||||
uvicorn[standard]==0.51.0
|
||||
httpx==0.28.1 # calling Ollama / Replicate
|
||||
pydantic==2.13.4 # request/response contracts
|
||||
jsonschema==4.23.0 # canon log / origin contract validation
|
||||
|
||||
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
48
api/tests/test_origin_schema.py
Normal file
48
api/tests/test_origin_schema.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import copy
|
||||
|
||||
from app.canon_log import validate_origin
|
||||
|
||||
VALID_ORIGIN = {
|
||||
"schema_version": 1,
|
||||
"id": "deserter",
|
||||
"display_name": "The Deserter",
|
||||
"description": "You walked away from a company that doesn't allow walking away.",
|
||||
"start_location_id": "greywater_docks",
|
||||
"situation": ["Arrived at Greywater by barge before dawn, hood up"],
|
||||
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
|
||||
"disposition_overrides": {"brannoc_thane": 40, "cadwyn_vell": 15},
|
||||
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": {
|
||||
"allowed_classes": ["sellsword", "assassin", "priest"],
|
||||
"luck_modifier": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_valid_origin_passes():
|
||||
assert validate_origin(VALID_ORIGIN) == []
|
||||
|
||||
|
||||
def test_null_start_quest_is_allowed():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["start_quest_id"] = None
|
||||
assert validate_origin(doc) == []
|
||||
|
||||
|
||||
def test_unknown_class_is_rejected():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["build_constraints"]["allowed_classes"] = ["bard"]
|
||||
assert validate_origin(doc) != []
|
||||
|
||||
|
||||
def test_missing_required_field_is_rejected():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
del doc["start_location_id"]
|
||||
assert validate_origin(doc) != []
|
||||
|
||||
|
||||
def test_extra_field_is_rejected():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["surprise"] = True
|
||||
assert validate_origin(doc) != []
|
||||
52
docs/schemas/origin.schema.json
Normal file
52
docs/schemas/origin.schema.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://coc-rpg/schemas/origin.schema.json",
|
||||
"title": "Origin Seed",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "id", "display_name", "description",
|
||||
"start_location_id", "situation", "opening_facts",
|
||||
"disposition_overrides", "inventory_grants", "start_quest_id",
|
||||
"build_constraints"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "type": "integer", "const": 1 },
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||
"display_name": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string", "minLength": 1 },
|
||||
"start_location_id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||
"situation": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"opening_facts": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"disposition_overrides": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "integer", "minimum": -100, "maximum": 100 }
|
||||
},
|
||||
"inventory_grants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["item_id", "qty"],
|
||||
"properties": {
|
||||
"item_id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||
"qty": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"start_quest_id": { "type": ["string", "null"], "pattern": "^[a-z0-9_]+$" },
|
||||
"build_constraints": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["allowed_classes", "luck_modifier"],
|
||||
"properties": {
|
||||
"allowed_classes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "enum": ["sellsword", "assassin", "priest"] }
|
||||
},
|
||||
"luck_modifier": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user