diff --git a/backend/app/main.py b/backend/app/main.py index bb52e04..bd5b324 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,8 @@ from app.shared.infrastructure.config import Settings from app.modules.project.infrastructure.json_repository import JsonProjectRepository 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.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: @@ -22,8 +24,13 @@ def create_app() -> FastAPI: project_service = ProjectService(project_repo) init_project_router(project_service) + # Wire Scanner module + scan_service = ScanService() + init_scanner_router(project_service, scan_service) + # Register routers app.include_router(project_router, prefix="/api") + app.include_router(scanner_router, prefix="/api") # Health check @app.get("/api/health") diff --git a/backend/app/modules/scanner/interfaces/http/router.py b/backend/app/modules/scanner/interfaces/http/router.py index e69de29..bacb77f 100644 --- a/backend/app/modules/scanner/interfaces/http/router.py +++ b/backend/app/modules/scanner/interfaces/http/router.py @@ -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], + } diff --git a/backend/tests/test_api_scanner.py b/backend/tests/test_api_scanner.py new file mode 100644 index 0000000..38a5525 --- /dev/null +++ b/backend/tests/test_api_scanner.py @@ -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"]