diff --git a/python/registry_loader.py b/python/registry_loader.py index 30d3eb5..8cc0eb1 100644 --- a/python/registry_loader.py +++ b/python/registry_loader.py @@ -170,6 +170,57 @@ def load_single(store: Store, registry_dirs: list[str], secid_type: str, namespa return None +def load_type_info(registry_dirs: list[str], secid_type: str) -> Optional[dict]: + """Read registry/.json from the first registry dir that has it. + + Returns the parsed type-level metadata (description, purpose, format, + examples, etc.) or None if no registry directory has the file. + + Used by the resolver for bare-type queries (e.g., secid:advisory) in + lazy mode, where the full type index isn't pre-built. Also used by + list_all_types() to assemble the /api/v1/types response. + """ + for registry_dir in registry_dirs: + type_file = Path(registry_dir) / f"{secid_type}.json" + if type_file.exists(): + try: + return json.loads(type_file.read_text()) + except json.JSONDecodeError as e: + logger.warning(f"Error parsing {type_file}: {e}") + return None + + +def list_all_types(registry_dirs: list[str]) -> list[dict]: + """Return metadata for all 10 SecID types in canonical order. + + Each entry has the shape: + { + "type": "advisory", + "description": "", + "long_description": "", + "subtypes": [] # Python Server-API doesn't yet enumerate subtype + # descriptions; that data lives in SecID-Service's + # type-registry.ts. Future work: centralize a + # type-registry.json in the SecID spec repo so + # all implementations read from one canonical source. + } + + Types with no registry/.json file get empty description fields + rather than being omitted — the type list itself is canonical (always 10) + even if metadata is missing. + """ + out: list[dict] = [] + for secid_type in SECID_TYPES: + info = load_type_info(registry_dirs, secid_type) or {} + out.append({ + "type": secid_type, + "description": info.get("description", ""), + "long_description": info.get("purpose", info.get("description", "")), + "subtypes": [], + }) + return out + + def update_load(store: Store, registry_dirs: list[str], since_commit: Optional[str] = None) -> int: """Reload only files changed since a given commit. Returns count updated. diff --git a/python/resolver.py b/python/resolver.py index df5eb8b..a6ba368 100644 --- a/python/resolver.py +++ b/python/resolver.py @@ -109,6 +109,21 @@ def resolve(store: Store, secid_query: str, registry_dirs: list[str] = None) -> "data": json.loads(type_data), }], } + # Lazy fallback: read registry/.json directly. Bulk mode + # would have built this into store already, but lazy mode hasn't, + # so we read from disk and synthesize a minimal type-info response. + if registry_dirs: + from registry_loader import load_type_info + info = load_type_info(registry_dirs, candidate_type) + if info: + return { + "secid_query": secid_query, + "status": "found", + "results": [{ + "secid": f"secid:{candidate_type}", + "data": info, + }], + } return _not_found(secid_query) secid_type = remainder[:slash_idx].lower() diff --git a/python/secid_server.py b/python/secid_server.py index 24cb10b..6d0bc91 100644 --- a/python/secid_server.py +++ b/python/secid_server.py @@ -34,7 +34,7 @@ from fastapi.responses import JSONResponse from storage import create_store -from registry_loader import bulk_load, SECID_TYPES +from registry_loader import bulk_load, list_all_types, SECID_TYPES from resolver import resolve logger = logging.getLogger(__name__) @@ -115,6 +115,21 @@ async def api_resolve( ] return JSONResponse(content=result) + @app.get("/api/v1/types") + async def api_types(): + """Return the canonical SecID type list with descriptions. + + Mirrors the same endpoint on SecID-Service. Type metadata comes from + registry/.json in the configured registry directories. + + Note: subtype declarations live in SecID-Service's type-registry.ts + today (not yet centralized in the spec repo's registry data), so the + `subtypes` array is always empty here. Clients that need subtype + descriptions should query SecID-Service directly until centralization + lands. + """ + return JSONResponse(content={"types": list_all_types(config.registry_dirs)}) + @app.post("/admin/reload") async def admin_reload(): """Reload registry data (after git pull).""" diff --git a/python/test_smoke.py b/python/test_smoke.py index c382b75..0b938b4 100644 --- a/python/test_smoke.py +++ b/python/test_smoke.py @@ -245,3 +245,56 @@ def test_create_app_no_side_effects_on_import(): app_a = _empty_app() app_b = _empty_app() assert app_a is not app_b + + +# --------------------------------------------------------------------------- +# Discovery endpoints (added in Phase 2.5c) +# --------------------------------------------------------------------------- + + +def test_types_endpoint_returns_all_ten(): + """GET /api/v1/types returns the canonical 10 SecID types, even with no + registry directories (the type list itself is canonical, metadata is the + only thing that varies).""" + client = TestClient(_empty_app()) + response = client.get("/api/v1/types") + assert response.status_code == 200 + body = response.json() + assert "types" in body + assert len(body["types"]) == 10 + type_names = {t["type"] for t in body["types"]} + assert type_names == { + "advisory", "capability", "control", "disclosure", "entity", + "methodology", "reference", "regulation", "ttp", "weakness", + } + + +def test_types_endpoint_each_entry_has_required_fields(): + """Each /api/v1/types entry must include the canonical shape: + type, description, long_description, subtypes.""" + client = TestClient(_empty_app()) + response = client.get("/api/v1/types") + for entry in response.json()["types"]: + assert "type" in entry + assert "description" in entry + assert "long_description" in entry + assert "subtypes" in entry + assert isinstance(entry["subtypes"], list) + + +def test_list_all_types_with_no_registry(): + """list_all_types() should always return 10 entries, with empty description + fields when no registry data is available.""" + from registry_loader import list_all_types + types = list_all_types([]) + assert len(types) == 10 + # All descriptions empty when no registry + assert all(t["description"] == "" for t in types) + assert all(t["long_description"] == "" for t in types) + + +def test_load_type_info_returns_none_for_missing_registry(): + """load_type_info() returns None when no registry directory has the file.""" + from registry_loader import load_type_info + assert load_type_info([], "advisory") is None + assert load_type_info(["/nonexistent/path"], "advisory") is None