first
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .router import api_v1_router
|
||||
|
||||
__all__ = ["api_v1_router"]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""CRUD APIs for Artifacts — the heart of the ASF domain model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactUpdate,
|
||||
ArtifactType,
|
||||
ArtifactRelationship,
|
||||
)
|
||||
from ...repositories import NotFoundError, TenantMismatchError
|
||||
from .deps import artifact_service, relationship_service
|
||||
from ...services import ArtifactService, RelationshipService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[Artifact], summary="List artifacts (tenant scoped)")
|
||||
async def list_artifacts(
|
||||
project_id: Optional[UUID] = Query(None),
|
||||
type: Optional[ArtifactType] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None, description="Case insensitive regex on title+content"),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
svc: ArtifactService = Depends(artifact_service),
|
||||
) -> list[Artifact]:
|
||||
return await svc.list(
|
||||
project_id=project_id,
|
||||
artifact_type=type,
|
||||
status=status,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=Artifact,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new artifact",
|
||||
)
|
||||
async def create_artifact(
|
||||
body: ArtifactCreate,
|
||||
svc: ArtifactService = Depends(artifact_service),
|
||||
) -> Artifact:
|
||||
try:
|
||||
return await svc.create(body)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{artifact_id}", response_model=Artifact, summary="Get artifact by id")
|
||||
async def get_artifact(
|
||||
artifact_id: UUID,
|
||||
svc: ArtifactService = Depends(artifact_service),
|
||||
) -> Artifact:
|
||||
a = await svc.get(artifact_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
|
||||
return a
|
||||
|
||||
|
||||
@router.patch("/{artifact_id}", response_model=Artifact, summary="Update an artifact")
|
||||
async def update_artifact(
|
||||
artifact_id: UUID,
|
||||
body: ArtifactUpdate,
|
||||
svc: ArtifactService = Depends(artifact_service),
|
||||
) -> Artifact:
|
||||
try:
|
||||
return await svc.update(artifact_id, body)
|
||||
except (NotFoundError, TenantMismatchError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{artifact_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete an artifact",
|
||||
)
|
||||
async def delete_artifact(
|
||||
artifact_id: UUID,
|
||||
svc: ArtifactService = Depends(artifact_service),
|
||||
) -> None:
|
||||
try:
|
||||
await svc.delete(artifact_id)
|
||||
except (NotFoundError, TenantMismatchError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{artifact_id}/relationships",
|
||||
response_model=list[ArtifactRelationship],
|
||||
summary="List all relationships (in + out) for an artifact — convenience wrapper for /relationships/for-artifact/{id}",
|
||||
)
|
||||
async def artifact_relationships(
|
||||
artifact_id: UUID,
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
rel_svc: RelationshipService = Depends(relationship_service),
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await rel_svc.for_artifact(artifact_id, limit=limit)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Ask ASF — command / chat interface (bootstrap).
|
||||
|
||||
For the bootstrap we expose:
|
||||
* POST /api/v1/ask-asf/query → enqueue a Job of type AI_INTERACTIVE
|
||||
and return immediately for the UI to poll.
|
||||
|
||||
Actual AI answer generation will be provided by asf-worker calling the
|
||||
asf-ai Gateway. This endpoint never returns a fake AI answer inline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from asf_domain import Job, JobCreate, JobType, JobPriority
|
||||
|
||||
from .deps import job_service
|
||||
from ...services import JobService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AskQueryRequest(BaseModel):
|
||||
question: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=4000,
|
||||
description="Natural-language query posed to ASF.",
|
||||
examples=["Which services implement REQ-441?"],
|
||||
)
|
||||
project_id: UUID | None = Field(
|
||||
None, description="Optionally constrain context to a project."
|
||||
)
|
||||
context_artifact_ids: list[UUID] = Field(
|
||||
default_factory=list,
|
||||
description="Optional list of artifact ids to anchor context.",
|
||||
)
|
||||
|
||||
|
||||
class AskQueryAccepted(BaseModel):
|
||||
accepted: bool = True
|
||||
job_id: UUID
|
||||
message: str = (
|
||||
"Query enqueued. Poll GET /api/v1/jobs/{job_id} to check status. "
|
||||
"The AI execution result will appear in the job payload when completed."
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/query",
|
||||
response_model=AskQueryAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
summary="Submit a natural-language query to 'Ask ASF'. Returns a Job id you can poll.",
|
||||
)
|
||||
async def ask_query(
|
||||
body: AskQueryRequest,
|
||||
svc: JobService = Depends(job_service),
|
||||
) -> AskQueryAccepted:
|
||||
payload: dict = {
|
||||
"kind": "ask_asf",
|
||||
"question": body.question,
|
||||
"context_artifact_ids": [str(i) for i in body.context_artifact_ids],
|
||||
}
|
||||
jc = JobCreate(
|
||||
type=JobType.AI_INTERACTIVE,
|
||||
priority=JobPriority.HIGH,
|
||||
project_id=body.project_id,
|
||||
payload=payload,
|
||||
)
|
||||
try:
|
||||
job = await svc.submit(jc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enqueue query: {exc}") from exc
|
||||
return AskQueryAccepted(job_id=job.id)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Audit trail API (read-only in bootstrap — append-only via services)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from asf_domain import AuditEvent
|
||||
from .deps import audit_service
|
||||
from ...services import AuditService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuditEvent], summary="List audit events (tenant scoped, newest first)")
|
||||
async def list_audit(
|
||||
actor_id: Optional[UUID] = Query(None),
|
||||
resource_type: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
svc: AuditService = Depends(audit_service),
|
||||
) -> list[AuditEvent]:
|
||||
return await svc.list(
|
||||
limit=limit, offset=offset, actor_id=actor_id, resource_type=resource_type
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Per-route dependencies that construct services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncIterator
|
||||
|
||||
from fastapi import Depends
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from ...dependencies import (
|
||||
RequestContext,
|
||||
get_req_ctx,
|
||||
coll_orgs,
|
||||
coll_projects,
|
||||
coll_artifacts,
|
||||
coll_relationships,
|
||||
coll_jobs,
|
||||
coll_audit,
|
||||
)
|
||||
from ...repositories import (
|
||||
OrganizationRepository,
|
||||
ProjectRepository,
|
||||
ArtifactRepository,
|
||||
RelationshipRepository,
|
||||
JobRepository,
|
||||
AuditRepository,
|
||||
)
|
||||
from ...services import (
|
||||
OrganizationService,
|
||||
ProjectService,
|
||||
ArtifactService,
|
||||
RelationshipService,
|
||||
JobService,
|
||||
AuditService,
|
||||
OverviewService,
|
||||
)
|
||||
|
||||
|
||||
# -------- Repositories --------
|
||||
async def org_repo(
|
||||
c: AsyncIOMotorCollection = Depends(coll_orgs),
|
||||
) -> AsyncIterator[OrganizationRepository]:
|
||||
yield OrganizationRepository(c)
|
||||
|
||||
|
||||
async def project_repo(
|
||||
c: AsyncIOMotorCollection = Depends(coll_projects),
|
||||
) -> AsyncIterator[ProjectRepository]:
|
||||
yield ProjectRepository(c)
|
||||
|
||||
|
||||
async def artifact_repo(
|
||||
c: AsyncIOMotorCollection = Depends(coll_artifacts),
|
||||
) -> AsyncIterator[ArtifactRepository]:
|
||||
yield ArtifactRepository(c)
|
||||
|
||||
|
||||
async def relationship_repo(
|
||||
c: AsyncIOMotorCollection = Depends(coll_relationships),
|
||||
arts: AsyncIOMotorCollection = Depends(coll_artifacts),
|
||||
) -> AsyncIterator[RelationshipRepository]:
|
||||
yield RelationshipRepository(c, arts)
|
||||
|
||||
|
||||
async def job_repo(
|
||||
c: AsyncIOMotorCollection = Depends(coll_jobs),
|
||||
) -> AsyncIterator[JobRepository]:
|
||||
yield JobRepository(c)
|
||||
|
||||
|
||||
async def audit_repo(
|
||||
c: AsyncIOMotorCollection = Depends(coll_audit),
|
||||
) -> AsyncIterator[AuditRepository]:
|
||||
yield AuditRepository(c)
|
||||
|
||||
|
||||
# -------- Services (tenant-scoped) --------
|
||||
async def org_service(
|
||||
orgs: OrganizationRepository = Depends(org_repo),
|
||||
audit: AuditRepository = Depends(audit_repo),
|
||||
ctx: RequestContext = Depends(get_req_ctx),
|
||||
) -> OrganizationService:
|
||||
return OrganizationService(
|
||||
orgs, audit,
|
||||
actor_id=ctx.user_id,
|
||||
preferred_org_id=ctx.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
async def project_service(
|
||||
projects: ProjectRepository = Depends(project_repo),
|
||||
orgs: OrganizationRepository = Depends(org_repo),
|
||||
audit: AuditRepository = Depends(audit_repo),
|
||||
ctx: RequestContext = Depends(get_req_ctx),
|
||||
) -> ProjectService:
|
||||
return ProjectService(
|
||||
projects, orgs, audit,
|
||||
tenant_id=ctx.require_tenant(),
|
||||
actor_id=ctx.user_id,
|
||||
)
|
||||
|
||||
|
||||
async def artifact_service(
|
||||
artifacts: ArtifactRepository = Depends(artifact_repo),
|
||||
projects: ProjectRepository = Depends(project_repo),
|
||||
audit: AuditRepository = Depends(audit_repo),
|
||||
ctx: RequestContext = Depends(get_req_ctx),
|
||||
) -> ArtifactService:
|
||||
return ArtifactService(
|
||||
artifacts, projects, audit,
|
||||
tenant_id=ctx.require_tenant(),
|
||||
actor_id=ctx.user_id,
|
||||
)
|
||||
|
||||
|
||||
async def relationship_service(
|
||||
rels: RelationshipRepository = Depends(relationship_repo),
|
||||
audit: AuditRepository = Depends(audit_repo),
|
||||
ctx: RequestContext = Depends(get_req_ctx),
|
||||
) -> RelationshipService:
|
||||
return RelationshipService(
|
||||
rels, audit,
|
||||
tenant_id=ctx.require_tenant(),
|
||||
actor_id=ctx.user_id,
|
||||
)
|
||||
|
||||
|
||||
async def job_service(
|
||||
jobs: JobRepository = Depends(job_repo),
|
||||
projects: ProjectRepository = Depends(project_repo),
|
||||
audit: AuditRepository = Depends(audit_repo),
|
||||
ctx: RequestContext = Depends(get_req_ctx),
|
||||
) -> JobService:
|
||||
return JobService(
|
||||
jobs, projects, audit,
|
||||
tenant_id=ctx.require_tenant(),
|
||||
actor_id=ctx.user_id,
|
||||
)
|
||||
|
||||
|
||||
async def audit_service(
|
||||
audit: AuditRepository = Depends(audit_repo),
|
||||
ctx: RequestContext = Depends(get_req_ctx),
|
||||
) -> AuditService:
|
||||
return AuditService(audit, tenant_id=ctx.require_tenant())
|
||||
|
||||
|
||||
async def overview_service(
|
||||
artifacts: ArtifactRepository = Depends(artifact_repo),
|
||||
projects: ProjectRepository = Depends(project_repo),
|
||||
jobs: JobRepository = Depends(job_repo),
|
||||
ctx: RequestContext = Depends(get_req_ctx),
|
||||
) -> OverviewService:
|
||||
return OverviewService(
|
||||
artifacts, projects, jobs,
|
||||
tenant_id=ctx.require_tenant(),
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Health + readiness probes.
|
||||
|
||||
``/health`` must always return 200 when the HTTP server is up (liveness).
|
||||
``/health/ready`` additionally checks Mongo, RabbitMQ, Redis reachability
|
||||
(readiness).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...config import get_settings
|
||||
from ...db.mongo import get_mongo_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_STARTED = time.time()
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = Field(..., examples=["ok"])
|
||||
service: str = "asf-core"
|
||||
version: str
|
||||
environment: str
|
||||
uptime_seconds: float
|
||||
timestamp: str
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse, summary="Liveness probe")
|
||||
async def health() -> HealthResponse:
|
||||
s = get_settings()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
service="asf-core",
|
||||
version="0.1.0",
|
||||
environment=s.asf_env,
|
||||
uptime_seconds=round(time.time() - _STARTED, 2),
|
||||
timestamp=datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
)
|
||||
|
||||
|
||||
class ReadyResponse(BaseModel):
|
||||
ready: bool
|
||||
checks: dict
|
||||
|
||||
|
||||
@router.get("/health/ready", response_model=ReadyResponse, summary="Readiness probe")
|
||||
async def ready() -> ReadyResponse:
|
||||
checks: dict = {}
|
||||
ok = True
|
||||
|
||||
# --- MongoDB ---
|
||||
try:
|
||||
client = get_mongo_client()
|
||||
ping = await client.admin.command("ping")
|
||||
checks["mongodb"] = "ok" if ping.get("ok") else "fail"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
checks["mongodb"] = f"error: {exc}"
|
||||
ok = False
|
||||
|
||||
return ReadyResponse(ready=ok, checks=checks)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Job APIs: submit (enqueue), query status, list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from asf_domain import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobStatus,
|
||||
JobType,
|
||||
)
|
||||
from ...repositories import NotFoundError, TenantMismatchError
|
||||
from .deps import job_service
|
||||
from ...services import JobService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[Job], summary="List jobs (tenant scoped)")
|
||||
async def list_jobs(
|
||||
project_id: Optional[UUID] = Query(None),
|
||||
status: Optional[JobStatus] = Query(None),
|
||||
type: Optional[JobType] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
svc: JobService = Depends(job_service),
|
||||
) -> list[Job]:
|
||||
return await svc.list(
|
||||
project_id=project_id, status=status, type=type, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=Job,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
summary="Submit a new asynchronous job. The job record is persisted and the work is enqueued on RabbitMQ.",
|
||||
)
|
||||
async def submit_job(
|
||||
body: JobCreate,
|
||||
svc: JobService = Depends(job_service),
|
||||
) -> Job:
|
||||
try:
|
||||
return await svc.submit(body)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=Job, summary="Get job by id")
|
||||
async def get_job(
|
||||
job_id: UUID,
|
||||
svc: JobService = Depends(job_service),
|
||||
) -> Job:
|
||||
j = await svc.get(job_id)
|
||||
if j is None:
|
||||
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
||||
return j
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{job_id}",
|
||||
response_model=Job,
|
||||
summary="Update job status / progress / result (used by workers and, in future, cancel operations).",
|
||||
)
|
||||
async def update_job(
|
||||
job_id: UUID,
|
||||
body: JobUpdate,
|
||||
svc: JobService = Depends(job_service),
|
||||
) -> Job:
|
||||
try:
|
||||
return await svc.update(job_id, body)
|
||||
except (NotFoundError, TenantMismatchError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,77 @@
|
||||
"""CRUD APIs for Organizations (tenants)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from asf_domain import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
)
|
||||
from ...repositories import NotFoundError, DuplicateError
|
||||
from .deps import org_service
|
||||
from ...services import OrganizationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[Organization], summary="List organizations")
|
||||
async def list_organizations(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
svc: OrganizationService = Depends(org_service),
|
||||
) -> list[Organization]:
|
||||
return await svc.list(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=Organization,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new organization (tenant)",
|
||||
)
|
||||
async def create_organization(
|
||||
body: OrganizationCreate,
|
||||
svc: OrganizationService = Depends(org_service),
|
||||
) -> Organization:
|
||||
try:
|
||||
return await svc.create(body)
|
||||
except DuplicateError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=Organization, summary="Get an organization by id")
|
||||
async def get_organization(
|
||||
org_id: UUID,
|
||||
svc: OrganizationService = Depends(org_service),
|
||||
) -> Organization:
|
||||
org = await svc.get(org_id)
|
||||
if org is None:
|
||||
raise HTTPException(status_code=404, detail=f"Organization {org_id} not found")
|
||||
return org
|
||||
|
||||
|
||||
@router.get("/slug/{slug}", response_model=Organization, summary="Get organization by slug")
|
||||
async def get_organization_by_slug(
|
||||
slug: str,
|
||||
svc: OrganizationService = Depends(org_service),
|
||||
) -> Organization:
|
||||
org = await svc.get_by_slug(slug)
|
||||
if org is None:
|
||||
raise HTTPException(status_code=404, detail=f"Organization slug={slug} not found")
|
||||
return org
|
||||
|
||||
|
||||
@router.patch("/{org_id}", response_model=Organization, summary="Update an organization")
|
||||
async def update_organization(
|
||||
org_id: UUID,
|
||||
body: OrganizationUpdate,
|
||||
svc: OrganizationService = Depends(org_service),
|
||||
) -> Organization:
|
||||
try:
|
||||
return await svc.update(org_id, body)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Overview dashboard aggregations — data for the UI Overview page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .deps import overview_service
|
||||
from ...services import OverviewService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class OverviewResponse(BaseModel):
|
||||
tenant_id: str
|
||||
total_projects: int
|
||||
total_requirements: int
|
||||
total_tasks: int
|
||||
total_tests: int
|
||||
total_deployments: int
|
||||
total_commits: int
|
||||
jobs_running: int
|
||||
jobs_queued: int
|
||||
jobs_failed_24h: int
|
||||
implementation_coverage_pct: float
|
||||
test_coverage_pct: float
|
||||
traceability_score_pct: float
|
||||
project_health_pct: float
|
||||
security_findings: int
|
||||
failed_builds: int
|
||||
active_ai_jobs: int
|
||||
featured_projects: list[dict]
|
||||
mock_fields: list[str]
|
||||
|
||||
|
||||
@router.get("", response_model=OverviewResponse, summary="Overview metrics for the tenant dashboard")
|
||||
async def get_overview(
|
||||
svc: OverviewService = Depends(overview_service),
|
||||
) -> OverviewResponse:
|
||||
stats = await svc.overview()
|
||||
return OverviewResponse(**stats.to_dict())
|
||||
@@ -0,0 +1,65 @@
|
||||
"""CRUD APIs for Projects (inside a tenant)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from asf_domain import Project, ProjectCreate, ProjectUpdate
|
||||
from ...repositories import NotFoundError, DuplicateError, TenantMismatchError
|
||||
from .deps import project_service
|
||||
from ...services import ProjectService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[Project], summary="List projects (tenant scoped)")
|
||||
async def list_projects(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
only_active: bool = True,
|
||||
svc: ProjectService = Depends(project_service),
|
||||
) -> list[Project]:
|
||||
return await svc.list(limit=limit, offset=offset, only_active=only_active)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=Project,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create project",
|
||||
)
|
||||
async def create_project(
|
||||
body: ProjectCreate,
|
||||
svc: ProjectService = Depends(project_service),
|
||||
) -> Project:
|
||||
try:
|
||||
return await svc.create(body)
|
||||
except DuplicateError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=Project, summary="Get project by id")
|
||||
async def get_project(
|
||||
project_id: UUID,
|
||||
svc: ProjectService = Depends(project_service),
|
||||
) -> Project:
|
||||
p = await svc.get(project_id)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
return p
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=Project, summary="Update project")
|
||||
async def update_project(
|
||||
project_id: UUID,
|
||||
body: ProjectUpdate,
|
||||
svc: ProjectService = Depends(project_service),
|
||||
) -> Project:
|
||||
try:
|
||||
return await svc.update(project_id, body)
|
||||
except (NotFoundError, TenantMismatchError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Artifact relationship APIs — explicit edges.
|
||||
|
||||
All endpoints are tenant-scoped. Cross-tenant edges can never be created
|
||||
because both endpoint artifacts are validated against the tenant context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from asf_domain import (
|
||||
ArtifactRelationship,
|
||||
ArtifactRelationshipCreate,
|
||||
RelationshipType,
|
||||
)
|
||||
from ...repositories import NotFoundError, DuplicateError, TenantMismatchError
|
||||
from .deps import relationship_service
|
||||
from ...services import RelationshipService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ArtifactRelationship,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a directed edge: source <relationship_type> target",
|
||||
)
|
||||
async def create_relationship(
|
||||
body: ArtifactRelationshipCreate,
|
||||
svc: RelationshipService = Depends(relationship_service),
|
||||
) -> ArtifactRelationship:
|
||||
try:
|
||||
return await svc.create(body)
|
||||
except DuplicateError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except TenantMismatchError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{relationship_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a relationship",
|
||||
)
|
||||
async def delete_relationship(
|
||||
relationship_id: UUID,
|
||||
svc: RelationshipService = Depends(relationship_service),
|
||||
) -> None:
|
||||
try:
|
||||
await svc.delete(relationship_id)
|
||||
except (NotFoundError, TenantMismatchError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/from/{artifact_id}",
|
||||
response_model=list[ArtifactRelationship],
|
||||
summary="Outgoing edges: artifact → *",
|
||||
)
|
||||
async def list_outgoing(
|
||||
artifact_id: UUID,
|
||||
relationship_type: Optional[RelationshipType] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
svc: RelationshipService = Depends(relationship_service),
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await svc.outgoing(artifact_id, relationship_type=relationship_type, limit=limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/to/{artifact_id}",
|
||||
response_model=list[ArtifactRelationship],
|
||||
summary="Incoming edges: * → artifact",
|
||||
)
|
||||
async def list_incoming(
|
||||
artifact_id: UUID,
|
||||
relationship_type: Optional[RelationshipType] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
svc: RelationshipService = Depends(relationship_service),
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await svc.incoming(artifact_id, relationship_type=relationship_type, limit=limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/for-artifact/{artifact_id}",
|
||||
response_model=list[ArtifactRelationship],
|
||||
summary="All edges touching an artifact (in + out)",
|
||||
)
|
||||
async def list_for_artifact(
|
||||
artifact_id: UUID,
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
svc: RelationshipService = Depends(relationship_service),
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await svc.for_artifact(artifact_id, limit=limit)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Top-level /api/v1 router. Mounts every sub-router in the correct order."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .health import router as health_router
|
||||
from .organizations import router as org_router
|
||||
from .projects import router as project_router
|
||||
from .artifacts import router as artifact_router
|
||||
from .relationships import router as rel_router
|
||||
from .jobs import router as jobs_router
|
||||
from .overview import router as overview_router
|
||||
from .audit import router as audit_router
|
||||
from .traceability import router as traceability_router
|
||||
from .ask_asf import router as ask_asf_router
|
||||
|
||||
api_v1_router = APIRouter()
|
||||
|
||||
api_v1_router.include_router(health_router, tags=["health"])
|
||||
api_v1_router.include_router(org_router, prefix="/organizations", tags=["organizations"])
|
||||
api_v1_router.include_router(project_router, prefix="/projects", tags=["projects"])
|
||||
api_v1_router.include_router(artifact_router, prefix="/artifacts", tags=["artifacts"])
|
||||
api_v1_router.include_router(rel_router, prefix="/relationships", tags=["relationships"])
|
||||
api_v1_router.include_router(jobs_router, prefix="/jobs", tags=["jobs"])
|
||||
api_v1_router.include_router(overview_router, prefix="/overview", tags=["overview"])
|
||||
api_v1_router.include_router(traceability_router, prefix="/traceability", tags=["traceability"])
|
||||
api_v1_router.include_router(audit_router, prefix="/audit", tags=["audit"])
|
||||
api_v1_router.include_router(ask_asf_router, prefix="/ask-asf", tags=["ask-asf"])
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Traceability: return graph edges + node summaries for the React Flow view.
|
||||
|
||||
Endpoint returns a list of ``nodes`` (artifacts) and ``edges`` (relationships)
|
||||
that the frontend can feed directly into React Flow to render an
|
||||
interactive traceability graph.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from pydantic import BaseModel
|
||||
from .deps import artifact_service, relationship_service
|
||||
from ...services import ArtifactService, RelationshipService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FlowNode(BaseModel):
|
||||
id: str
|
||||
type: str # artifact type: requirement, task, ...
|
||||
label: str
|
||||
project_id: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
version: int = 1
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
class FlowEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str # relationship_type, e.g. "implemented_by"
|
||||
|
||||
|
||||
class TraceabilityGraph(BaseModel):
|
||||
nodes: list[FlowNode]
|
||||
edges: list[FlowEdge]
|
||||
mock_fields: list[str] = []
|
||||
|
||||
|
||||
@router.get(
|
||||
"/graph",
|
||||
response_model=TraceabilityGraph,
|
||||
summary="Return a React-Flow-ready graph (nodes+edges) starting from a seed artifact, project, or the entire tenant.",
|
||||
)
|
||||
async def traceability_graph(
|
||||
artifact_id: Optional[UUID] = Query(
|
||||
None,
|
||||
description="Seed artifact. Edges will be traversed outward (outgoing + incoming).",
|
||||
),
|
||||
project_id: Optional[UUID] = Query(
|
||||
None,
|
||||
description="Constrain the graph to a project. When set without artifact_id, returns up to `limit` artifacts.",
|
||||
),
|
||||
max_depth: int = Query(2, ge=1, le=5, description="BFS depth when seed artifact_id is provided."),
|
||||
limit: int = Query(200, ge=1, le=1000, description="Max total nodes returned."),
|
||||
artifacts: ArtifactService = Depends(artifact_service),
|
||||
relationships: RelationshipService = Depends(relationship_service),
|
||||
) -> TraceabilityGraph:
|
||||
nodes: dict[UUID, FlowNode] = {}
|
||||
edges: list[FlowEdge] = []
|
||||
|
||||
async def _add_art(ids: list[UUID]) -> None:
|
||||
needed = [i for i in ids if i not in nodes and len(nodes) < limit]
|
||||
if not needed:
|
||||
return
|
||||
for art in await artifacts.get_many(needed):
|
||||
nodes[art.id] = FlowNode(
|
||||
id=str(art.id),
|
||||
type=art.type if isinstance(art.type, str) else art.type.value,
|
||||
label=art.title,
|
||||
project_id=str(art.project_id),
|
||||
status=art.status,
|
||||
version=art.version,
|
||||
source=art.source,
|
||||
)
|
||||
|
||||
if artifact_id is not None:
|
||||
# BFS around the seed
|
||||
frontier: set[UUID] = {artifact_id}
|
||||
visited: set[UUID] = set()
|
||||
for _ in range(max_depth):
|
||||
current = list(frontier - visited)
|
||||
if not current:
|
||||
break
|
||||
await _add_art(current)
|
||||
visited.update(current)
|
||||
next_front: set[UUID] = set()
|
||||
for aid in current:
|
||||
out = await relationships.outgoing(aid, limit=50)
|
||||
for rel in out:
|
||||
edges.append(
|
||||
FlowEdge(
|
||||
id=f"e-{rel.id}",
|
||||
source=str(rel.source_artifact_id),
|
||||
target=str(rel.target_artifact_id),
|
||||
label=rel.relationship_type
|
||||
if isinstance(rel.relationship_type, str)
|
||||
else rel.relationship_type.value,
|
||||
)
|
||||
)
|
||||
next_front.add(rel.target_artifact_id)
|
||||
inc = await relationships.incoming(aid, limit=50)
|
||||
for rel in inc:
|
||||
edges.append(
|
||||
FlowEdge(
|
||||
id=f"e-{rel.id}",
|
||||
source=str(rel.source_artifact_id),
|
||||
target=str(rel.target_artifact_id),
|
||||
label=rel.relationship_type
|
||||
if isinstance(rel.relationship_type, str)
|
||||
else rel.relationship_type.value,
|
||||
)
|
||||
)
|
||||
next_front.add(rel.source_artifact_id)
|
||||
frontier = next_front - visited
|
||||
elif project_id is not None:
|
||||
arts = await artifacts.list(project_id=project_id, limit=limit)
|
||||
await _add_art([a.id for a in arts])
|
||||
# relationships for every artifact we loaded
|
||||
for a in arts:
|
||||
out = await relationships.outgoing(a.id, limit=50)
|
||||
for rel in out:
|
||||
edges.append(
|
||||
FlowEdge(
|
||||
id=f"e-{rel.id}",
|
||||
source=str(rel.source_artifact_id),
|
||||
target=str(rel.target_artifact_id),
|
||||
label=rel.relationship_type
|
||||
if isinstance(rel.relationship_type, str)
|
||||
else rel.relationship_type.value,
|
||||
)
|
||||
)
|
||||
# filter edges to those whose endpoints are both in our node set
|
||||
node_ids = set(nodes.keys())
|
||||
edges = [e for e in edges if UUID(e.source) in node_ids and UUID(e.target) in node_ids]
|
||||
|
||||
else:
|
||||
# No seed, no project — return tenant-wide recent artifacts (bounded)
|
||||
arts = await artifacts.list(limit=limit)
|
||||
await _add_art([a.id for a in arts])
|
||||
for a in arts:
|
||||
out = await relationships.outgoing(a.id, limit=20)
|
||||
for rel in out:
|
||||
edges.append(
|
||||
FlowEdge(
|
||||
id=f"e-{rel.id}",
|
||||
source=str(rel.source_artifact_id),
|
||||
target=str(rel.target_artifact_id),
|
||||
label=rel.relationship_type
|
||||
if isinstance(rel.relationship_type, str)
|
||||
else rel.relationship_type.value,
|
||||
)
|
||||
)
|
||||
node_ids = set(nodes.keys())
|
||||
edges = [e for e in edges if UUID(e.source) in node_ids and UUID(e.target) in node_ids]
|
||||
|
||||
return TraceabilityGraph(nodes=list(nodes.values()), edges=edges)
|
||||
Reference in New Issue
Block a user