Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions python/registry_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<secid_type>.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": "<short description from registry/advisory.json>",
"long_description": "<purpose field, same source>",
"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/<type>.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.

Expand Down
15 changes: 15 additions & 0 deletions python/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<type>.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()
Expand Down
17 changes: 16 additions & 1 deletion python/secid_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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/<type>.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)."""
Expand Down
53 changes: 53 additions & 0 deletions python/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading