feat(scanner): add REST API — scan trigger, entity query endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a39cbcb766
commit
602e69b56e
|
|
@ -9,6 +9,8 @@ from app.shared.infrastructure.config import Settings
|
||||||
from app.modules.project.infrastructure.json_repository import JsonProjectRepository
|
from app.modules.project.infrastructure.json_repository import JsonProjectRepository
|
||||||
from app.modules.project.application.services import ProjectService
|
from app.modules.project.application.services import ProjectService
|
||||||
from app.modules.project.interfaces.http.router import router as project_router, init_router as init_project_router
|
from app.modules.project.interfaces.http.router import router as project_router, init_router as init_project_router
|
||||||
|
from app.modules.scanner.application.services import ScanService
|
||||||
|
from app.modules.scanner.interfaces.http.router import router as scanner_router, init_router as init_scanner_router
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|
@ -22,8 +24,13 @@ def create_app() -> FastAPI:
|
||||||
project_service = ProjectService(project_repo)
|
project_service = ProjectService(project_repo)
|
||||||
init_project_router(project_service)
|
init_project_router(project_service)
|
||||||
|
|
||||||
|
# Wire Scanner module
|
||||||
|
scan_service = ScanService()
|
||||||
|
init_scanner_router(project_service, scan_service)
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
app.include_router(project_router, prefix="/api")
|
app.include_router(project_router, prefix="/api")
|
||||||
|
app.include_router(scanner_router, prefix="/api")
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
"""Scanner HTTP router — scan trigger, entity query endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.modules.project.application.services import ProjectService
|
||||||
|
from app.modules.scanner.application.services import ScanService
|
||||||
|
from app.modules.scanner.domain.entities import ScanResult
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}", tags=["scanner"])
|
||||||
|
|
||||||
|
_project_service: ProjectService | None = None
|
||||||
|
_scan_service: ScanService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_router(project_service: ProjectService, scan_service: ScanService) -> None:
|
||||||
|
global _project_service, _scan_service
|
||||||
|
_project_service = project_service
|
||||||
|
_scan_service = scan_service
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_result_response(result: ScanResult) -> dict:
|
||||||
|
"""Build API response for ScanResult (no entity lists)."""
|
||||||
|
return {
|
||||||
|
"project_id": result.project_id,
|
||||||
|
"scanned_at": result.scanned_at.isoformat(),
|
||||||
|
"file_statuses": [
|
||||||
|
{
|
||||||
|
"path": fs.path,
|
||||||
|
"status": fs.status.value,
|
||||||
|
"content_lines": fs.content_lines,
|
||||||
|
}
|
||||||
|
for fs in result.file_statuses
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"total_files": result.summary.total_files,
|
||||||
|
"ok": result.summary.ok,
|
||||||
|
"sparse": result.summary.sparse,
|
||||||
|
"missing": result.summary.missing,
|
||||||
|
"placeholder_heavy": result.summary.placeholder_heavy,
|
||||||
|
"template_residue": result.summary.template_residue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_dict(entity) -> dict:
|
||||||
|
"""Convert a dataclass entity to a dict using asdict."""
|
||||||
|
return asdict(entity)
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_to_dict(integration) -> dict:
|
||||||
|
"""Convert Integration to dict with source_id/target_id mapped to source/target per spec."""
|
||||||
|
d = asdict(integration)
|
||||||
|
d["source"] = d.pop("source_id")
|
||||||
|
d["target"] = d.pop("target_id")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scan_or_404(project_id: str) -> ScanResult | None:
|
||||||
|
"""Get cached scan result or return None."""
|
||||||
|
return _scan_service.get_latest_scan(project_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scan endpoints ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan")
|
||||||
|
def trigger_scan(project_id: str):
|
||||||
|
project = _project_service.get_project(project_id)
|
||||||
|
result = _scan_service.scan(project)
|
||||||
|
return _scan_result_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan")
|
||||||
|
def get_scan(project_id: str):
|
||||||
|
_project_service.get_project(project_id) # Ensure project exists (raises 404)
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return _scan_result_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entity list endpoints ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/capabilities")
|
||||||
|
def list_capabilities(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(c) for c in result.capabilities]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/modules")
|
||||||
|
def list_modules(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(m) for m in result.modules]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/entities")
|
||||||
|
def list_entities(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(e) for e in result.entities]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/integrations")
|
||||||
|
def list_integrations(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_integration_to_dict(i) for i in result.integrations]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/value-flows")
|
||||||
|
def list_value_flows(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(v) for v in result.value_flows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/user-journeys")
|
||||||
|
def list_user_journeys(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(j) for j in result.user_journeys]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/data-flows")
|
||||||
|
def list_data_flows(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(d) for d in result.data_flows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/external-systems")
|
||||||
|
def list_external_systems(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(s) for s in result.external_systems]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/traceability-links")
|
||||||
|
def list_traceability_links(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(t) for t in result.traceability_links]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/runtime-components")
|
||||||
|
def list_runtime_components(project_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
return [_entity_to_dict(c) for c in result.runtime_components]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Detail endpoints ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/capabilities/{capability_id}")
|
||||||
|
def get_capability_detail(project_id: str, capability_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
|
||||||
|
cap = next((c for c in result.capabilities if c.capability_id == capability_id), None)
|
||||||
|
if cap is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": f"Capability not found: {capability_id}"})
|
||||||
|
|
||||||
|
# Find related modules via traceability links
|
||||||
|
related_module_ids = {
|
||||||
|
link.module_id
|
||||||
|
for link in result.traceability_links
|
||||||
|
if link.capability_id == capability_id
|
||||||
|
}
|
||||||
|
related_modules = [m for m in result.modules if m.module_id in related_module_ids]
|
||||||
|
|
||||||
|
# Find related value flows
|
||||||
|
related_vf_ids = set(cap.related_value_flows)
|
||||||
|
related_value_flows = [v for v in result.value_flows if v.value_flow_id in related_vf_ids]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"capability": _entity_to_dict(cap),
|
||||||
|
"modules": [_entity_to_dict(m) for m in related_modules],
|
||||||
|
"value_flows": [_entity_to_dict(v) for v in related_value_flows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/modules/{module_id}")
|
||||||
|
def get_module_detail(project_id: str, module_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
|
||||||
|
mod = next((m for m in result.modules if m.module_id == module_id), None)
|
||||||
|
if mod is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": f"Module not found: {module_id}"})
|
||||||
|
|
||||||
|
# Find owned entities (entity.owner_module matches)
|
||||||
|
owned_entities = [e for e in result.entities if e.owner_module == module_id]
|
||||||
|
|
||||||
|
# Find integrations where source or target matches
|
||||||
|
related_integrations = [
|
||||||
|
i for i in result.integrations
|
||||||
|
if i.source_id == module_id or i.target_id == module_id
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find codebase alignment
|
||||||
|
alignment = next(
|
||||||
|
(a for a in result.codebase_alignments if a.module_id == module_id), None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"module": _entity_to_dict(mod),
|
||||||
|
"entities": [_entity_to_dict(e) for e in owned_entities],
|
||||||
|
"integrations": [_integration_to_dict(i) for i in related_integrations],
|
||||||
|
"codebase_alignment": _entity_to_dict(alignment) if alignment else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/entities/{entity_id}")
|
||||||
|
def get_entity_detail(project_id: str, entity_id: str):
|
||||||
|
result = _get_scan_or_404(project_id)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": "No scan available"})
|
||||||
|
|
||||||
|
entity = next((e for e in result.entities if e.entity_id == entity_id), None)
|
||||||
|
if entity is None:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": f"Entity not found: {entity_id}"})
|
||||||
|
|
||||||
|
# Find data flows where source or target matches entity name or entity_id
|
||||||
|
related_data_flows = [
|
||||||
|
d for d in result.data_flows
|
||||||
|
if entity_id in d.source or entity_id in d.target
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entity": _entity_to_dict(entity),
|
||||||
|
"data_flows": [_entity_to_dict(d) for d in related_data_flows],
|
||||||
|
}
|
||||||
189
backend/tests/test_api_scanner.py
Normal file
189
backend/tests/test_api_scanner.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
"""Tests for scanner REST API endpoints."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DESIGN_DIR = "/workspace/arch-design-agent-skill-dashboard/design"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project_id(client):
|
||||||
|
r = client.post("/api/projects", json={"name": "test", "design_dir": DESIGN_DIR})
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_scan(client, project_id):
|
||||||
|
r = client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["project_id"] == project_id
|
||||||
|
assert "file_statuses" in data
|
||||||
|
assert "summary" in data
|
||||||
|
# Should NOT have entity lists in response
|
||||||
|
assert "capabilities" not in data
|
||||||
|
assert "modules" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_scan(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/scan")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["project_id"] == project_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_scan_not_scanned(client, project_id):
|
||||||
|
r = client.get(f"/api/projects/{project_id}/scan")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_capabilities(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/capabilities")
|
||||||
|
assert r.status_code == 200
|
||||||
|
caps = r.json()
|
||||||
|
assert len(caps) > 0
|
||||||
|
assert "capability_id" in caps[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_modules(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/modules")
|
||||||
|
assert r.status_code == 200
|
||||||
|
mods = r.json()
|
||||||
|
assert len(mods) > 0
|
||||||
|
assert "module_id" in mods[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entities(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/entities")
|
||||||
|
assert r.status_code == 200
|
||||||
|
ents = r.json()
|
||||||
|
assert len(ents) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_integrations(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/integrations")
|
||||||
|
assert r.status_code == 200
|
||||||
|
ints = r.json()
|
||||||
|
assert len(ints) > 0
|
||||||
|
# Integration should have source/target (not source_id/target_id)
|
||||||
|
assert "source" in ints[0]
|
||||||
|
assert "target" in ints[0]
|
||||||
|
assert "source_id" not in ints[0]
|
||||||
|
assert "target_id" not in ints[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_value_flows(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/value-flows")
|
||||||
|
assert r.status_code == 200
|
||||||
|
vfs = r.json()
|
||||||
|
assert len(vfs) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_user_journeys(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/user-journeys")
|
||||||
|
assert r.status_code == 200
|
||||||
|
ujs = r.json()
|
||||||
|
assert len(ujs) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_data_flows(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/data-flows")
|
||||||
|
assert r.status_code == 200
|
||||||
|
dfs = r.json()
|
||||||
|
assert len(dfs) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_external_systems(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/external-systems")
|
||||||
|
assert r.status_code == 200
|
||||||
|
ess = r.json()
|
||||||
|
assert len(ess) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_traceability_links(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/traceability-links")
|
||||||
|
assert r.status_code == 200
|
||||||
|
tls = r.json()
|
||||||
|
assert len(tls) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_runtime_components(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/runtime-components")
|
||||||
|
assert r.status_code == 200
|
||||||
|
rcs = r.json()
|
||||||
|
assert len(rcs) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_detail(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/capabilities/CAP-PROJ-REG")
|
||||||
|
assert r.status_code == 200
|
||||||
|
detail = r.json()
|
||||||
|
assert "capability" in detail
|
||||||
|
assert "modules" in detail
|
||||||
|
assert "value_flows" in detail
|
||||||
|
assert detail["capability"]["capability_id"] == "CAP-PROJ-REG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_detail_not_found(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/capabilities/NONEXISTENT")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_detail(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/modules/MOD-PROJECT")
|
||||||
|
assert r.status_code == 200
|
||||||
|
detail = r.json()
|
||||||
|
assert "module" in detail
|
||||||
|
assert "entities" in detail
|
||||||
|
assert "integrations" in detail
|
||||||
|
assert "codebase_alignment" in detail
|
||||||
|
assert detail["module"]["module_id"] == "MOD-PROJECT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_detail_not_found(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/modules/NONEXISTENT")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_detail(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/entities/ENT-PROJECT")
|
||||||
|
assert r.status_code == 200
|
||||||
|
detail = r.json()
|
||||||
|
assert "entity" in detail
|
||||||
|
assert "data_flows" in detail
|
||||||
|
assert detail["entity"]["entity_id"] == "ENT-PROJECT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_detail_not_found(client, project_id):
|
||||||
|
client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/entities/NONEXISTENT")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_entities_before_scan_returns_404(client, project_id):
|
||||||
|
r = client.get(f"/api/projects/{project_id}/entities/capabilities")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_summary_totals(client, project_id):
|
||||||
|
r = client.post(f"/api/projects/{project_id}/scan")
|
||||||
|
data = r.json()
|
||||||
|
s = data["summary"]
|
||||||
|
assert s["total_files"] == len(data["file_statuses"])
|
||||||
|
assert s["total_files"] == s["ok"] + s["sparse"] + s["missing"] + s["placeholder_heavy"] + s["template_residue"]
|
||||||
Loading…
Reference in New Issue
Block a user