first
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""asf-core — Core REST API service of the Azurian Software Factory.
|
||||
|
||||
Handles:
|
||||
* Organizations & Projects (tenant / sub-tenant boundaries)
|
||||
* Artifacts CRUD + queries
|
||||
* ArtifactRelationship CRUD + traversal helpers
|
||||
* Jobs (persistent lifecycle records)
|
||||
* Audit event ingestion
|
||||
* Health / readiness probes
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Typed configuration for asf-core, loaded from env vars (or .env file)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# -------- General --------
|
||||
asf_env: str = "development"
|
||||
asf_log_level: str = "INFO"
|
||||
cors_origins: List[str] = Field(default_factory=lambda: ["http://localhost:3000"])
|
||||
api_prefix: str = "/api/v1"
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def _split_csv(cls, v): # noqa: D401 - allow comma separated list
|
||||
if isinstance(v, str):
|
||||
return [item.strip() for item in v.split(",") if item.strip()]
|
||||
return v
|
||||
|
||||
# -------- MongoDB --------
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
mongo_min_pool_size: int = 5
|
||||
mongo_max_pool_size: int = 50
|
||||
|
||||
# -------- RabbitMQ --------
|
||||
rabbitmq_uri: str = "amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf"
|
||||
rabbitmq_events_exchange: str = "asf.events"
|
||||
rabbitmq_jobs_exchange: str = "asf.jobs"
|
||||
queues: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"ai_interactive": "asf.ai.interactive",
|
||||
"ai_background": "asf.ai.background",
|
||||
"indexing": "asf.indexing",
|
||||
"code_analysis": "asf.code_analysis",
|
||||
"testing": "asf.testing",
|
||||
"devops": "asf.devops",
|
||||
"notifications": "asf.notifications",
|
||||
}
|
||||
)
|
||||
|
||||
# -------- Redis --------
|
||||
redis_uri: str = "redis://:asfredis123@localhost:6379/0"
|
||||
|
||||
# -------- MinIO --------
|
||||
minio_host: str = "localhost:9000"
|
||||
minio_user: str = "asfadmin"
|
||||
minio_password: str = "asfadmin123"
|
||||
minio_bucket: str = "asf-artifacts"
|
||||
minio_secure: bool = False
|
||||
|
||||
# -------- Security --------
|
||||
jwt_secret: str = "change-me-in-production-please" # nosec - default only
|
||||
session_ttl_seconds: int = 86400
|
||||
|
||||
# -------- Auth (bootstrap mock) --------
|
||||
# When true, accept a trusted `X-ASF-Tenant-Id` header for local dev.
|
||||
# MUST be false in production!
|
||||
dev_trust_tenant_header: bool = True
|
||||
dev_trust_user_header: bool = True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,15 @@
|
||||
from .mongo import (
|
||||
get_mongo_db,
|
||||
get_mongo_client,
|
||||
MongoCollections,
|
||||
create_indexes,
|
||||
get_collection,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_mongo_db",
|
||||
"get_mongo_client",
|
||||
"MongoCollections",
|
||||
"create_indexes",
|
||||
"get_collection",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""MongoDB client + collection provider.
|
||||
|
||||
Every query is (a) tenant-scoped and (b) backed by explicit indexes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
from motor.motor_asyncio import (
|
||||
AsyncIOMotorClient,
|
||||
AsyncIOMotorCollection,
|
||||
AsyncIOMotorDatabase,
|
||||
)
|
||||
from pymongo import IndexModel
|
||||
import structlog
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
_client: AsyncIOMotorClient | None = None
|
||||
_db: AsyncIOMotorDatabase | None = None
|
||||
|
||||
|
||||
class MongoCollections(str):
|
||||
ORGANIZATIONS = "organizations"
|
||||
USERS = "users"
|
||||
TEAMS = "teams"
|
||||
PROJECTS = "projects"
|
||||
ARTIFACTS = "artifacts"
|
||||
ARTIFACT_RELATIONSHIPS = "artifact_relationships"
|
||||
INTEGRATIONS = "integrations"
|
||||
JOBS = "jobs"
|
||||
AI_EXECUTIONS = "ai_executions"
|
||||
AUDIT_EVENTS = "audit_events"
|
||||
|
||||
|
||||
def get_mongo_client() -> AsyncIOMotorClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
s = get_settings()
|
||||
_client = AsyncIOMotorClient(
|
||||
s.mongo_uri,
|
||||
minPoolSize=s.mongo_min_pool_size,
|
||||
maxPoolSize=s.mongo_max_pool_size,
|
||||
uuidRepresentation="standard",
|
||||
tz_aware=True,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def get_mongo_db() -> AsyncIOMotorDatabase:
|
||||
global _db
|
||||
if _db is None:
|
||||
s = get_settings()
|
||||
_db = get_mongo_client()[s.mongo_db]
|
||||
return _db
|
||||
|
||||
|
||||
def get_collection(name: MongoCollections | str) -> AsyncIOMotorCollection:
|
||||
return get_mongo_db()[name]
|
||||
|
||||
|
||||
async def create_indexes() -> None:
|
||||
"""Create (idempotent) indexes. Called once on service startup.
|
||||
|
||||
Rule of thumb for tenanted data: compound index with ``tenant_id`` first,
|
||||
then the most common filter / sort key.
|
||||
"""
|
||||
|
||||
db = get_mongo_db()
|
||||
|
||||
# --- organizations ---
|
||||
await db.organizations.create_indexes(
|
||||
[
|
||||
IndexModel("slug", unique=True, name="orgs_slug_unique"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- users ---
|
||||
await db.users.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("email", 1)], unique=True, name="users_tenant_email_unique"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- projects ---
|
||||
await db.projects.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("key", 1)], unique=True, name="projects_tenant_key_unique"),
|
||||
IndexModel([("tenant_id", 1), ("is_active", 1)], name="projects_tenant_active"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- artifacts ---
|
||||
await db.artifacts.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("project_id", 1)], name="arts_tenant_project"),
|
||||
IndexModel([("tenant_id", 1), ("project_id", 1), ("type", 1)], name="arts_tenant_project_type"),
|
||||
IndexModel([("tenant_id", 1), ("external_id", 1)], name="arts_tenant_external_id", sparse=True),
|
||||
IndexModel([("tenant_id", 1), ("created_at", -1)], name="arts_tenant_created_desc"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- artifact_relationships ---
|
||||
# Two indexes: one for "where does this artifact point TO", one for "what points TO this artifact"
|
||||
await db.artifact_relationships.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("source_artifact_id", 1), ("relationship_type", 1)],
|
||||
name="rels_tenant_source_type"),
|
||||
IndexModel([("tenant_id", 1), ("target_artifact_id", 1), ("relationship_type", 1)],
|
||||
name="rels_tenant_target_type"),
|
||||
IndexModel([("source_artifact_id", 1), ("target_artifact_id", 1), ("relationship_type", 1)],
|
||||
unique=True, name="rels_unique_edge"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- jobs ---
|
||||
await db.jobs.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("status", 1), ("priority", -1), ("created_at", 1)],
|
||||
name="jobs_tenant_status_priority"),
|
||||
IndexModel([("tenant_id", 1), ("project_id", 1), ("created_at", -1)],
|
||||
name="jobs_tenant_project_created_desc"),
|
||||
IndexModel([("correlation_id", 1)], name="jobs_correlation_id", sparse=True),
|
||||
]
|
||||
)
|
||||
|
||||
# --- audit_events (append-only, oldest-to-newest scans normal) ---
|
||||
await db.audit_events.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("created_at", -1)], name="audit_tenant_created_desc"),
|
||||
IndexModel([("tenant_id", 1), ("actor_id", 1), ("created_at", -1)], name="audit_tenant_actor_desc"),
|
||||
IndexModel([("correlation_id", 1)], name="audit_correlation_id", sparse=True),
|
||||
]
|
||||
)
|
||||
|
||||
# --- ai_executions ---
|
||||
await db.ai_executions.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("created_at", -1)], name="aie_tenant_created_desc"),
|
||||
IndexModel([("tenant_id", 1), ("job_id", 1)], name="aie_tenant_job", sparse=True),
|
||||
]
|
||||
)
|
||||
|
||||
await log.adebug("mongo_indexes_ready")
|
||||
|
||||
|
||||
async def close_mongo() -> None:
|
||||
global _client, _db
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
_client = None
|
||||
_db = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def mongo_lifespan() -> AsyncIterator[None]:
|
||||
"""Async lifespan helper for FastAPI startup / shutdown."""
|
||||
await create_indexes()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_mongo()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""FastAPI dependency injection helpers.
|
||||
|
||||
Use these as ``Depends(...)`` in your route handlers. They provide:
|
||||
* validated request context (tenant + user id)
|
||||
* Mongo collections
|
||||
* Event publisher
|
||||
* Settings
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncIterator
|
||||
|
||||
from fastapi import Depends
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from .config import Settings, get_settings
|
||||
from .db.mongo import get_collection, MongoCollections
|
||||
from .middleware.correlation import RequestContext, get_current_context
|
||||
from .messaging.rabbitmq import EventPublisher, get_event_publisher
|
||||
|
||||
|
||||
def get_req_ctx(ctx: RequestContext = Depends(get_current_context)) -> RequestContext:
|
||||
"""Return the current authenticated request context.
|
||||
|
||||
Raises 401 if there is no tenant context — so the handler can rely on
|
||||
``ctx.tenant_id`` being non-None.
|
||||
"""
|
||||
ctx.require_tenant()
|
||||
return ctx
|
||||
|
||||
|
||||
async def coll_orgs() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.ORGANIZATIONS)
|
||||
|
||||
|
||||
async def coll_projects() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.PROJECTS)
|
||||
|
||||
|
||||
async def coll_artifacts() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.ARTIFACTS)
|
||||
|
||||
|
||||
async def coll_relationships() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.ARTIFACT_RELATIONSHIPS)
|
||||
|
||||
|
||||
async def coll_jobs() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.JOBS)
|
||||
|
||||
|
||||
async def coll_audit() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.AUDIT_EVENTS)
|
||||
|
||||
|
||||
async def coll_ai_exec() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.AI_EXECUTIONS)
|
||||
|
||||
|
||||
async def publisher() -> AsyncIterator[EventPublisher]:
|
||||
yield await get_event_publisher()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
"get_settings",
|
||||
"RequestContext",
|
||||
"get_req_ctx",
|
||||
"coll_orgs",
|
||||
"coll_projects",
|
||||
"coll_artifacts",
|
||||
"coll_relationships",
|
||||
"coll_jobs",
|
||||
"coll_audit",
|
||||
"coll_ai_exec",
|
||||
"publisher",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Structured JSON logging setup for asf-core.
|
||||
|
||||
In production this is consumed by the log collector and forwarded to the
|
||||
observability stack. For local dev we keep a human-readable fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
s = get_settings()
|
||||
level: str = s.asf_log_level.upper()
|
||||
is_dev: bool = s.asf_env in {"development", "dev", "local"}
|
||||
|
||||
shared_processors: list[Any] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="@timestamp"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
]
|
||||
|
||||
structlog.configure(
|
||||
processors=shared_processors
|
||||
+ [
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
formatter_renderer: Any = (
|
||||
structlog.dev.ConsoleRenderer()
|
||||
if is_dev
|
||||
else structlog.processors.JSONRenderer(sort_keys=False)
|
||||
)
|
||||
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
foreign_pre_chain=shared_processors,
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
formatter_renderer,
|
||||
],
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(getattr(logging, level, logging.INFO))
|
||||
|
||||
# silence noisy third parties
|
||||
for _name in ("uvicorn.access", "httpx", "httpcore"):
|
||||
logging.getLogger(_name).setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""ASF Core — FastAPI application entrypoint.
|
||||
|
||||
Run locally with:
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .config import get_settings
|
||||
from .logging_config import setup_logging
|
||||
from .middleware.correlation import CorrelationMiddleware
|
||||
from .db.mongo import mongo_lifespan, create_indexes, close_mongo
|
||||
from .messaging.rabbitmq import (
|
||||
rabbitmq_lifespan,
|
||||
close_rabbitmq,
|
||||
get_event_publisher,
|
||||
)
|
||||
from .api.v1 import api_v1_router
|
||||
from .repositories import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
)
|
||||
|
||||
setup_logging()
|
||||
|
||||
s = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # noqa: ARG001 - signature required by FastAPI
|
||||
"""Startup / shutdown: ensure indexes, open/close connections."""
|
||||
import structlog
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
try:
|
||||
await create_indexes()
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("mongo_indexes_failed")
|
||||
|
||||
try:
|
||||
await get_event_publisher()
|
||||
except Exception: # noqa: BLE001
|
||||
# If RabbitMQ isn't reachable yet we still start the API — core
|
||||
# writes Mongo first and events are best-effort during startup.
|
||||
await log.awarning("rabbitmq_not_available_on_startup")
|
||||
|
||||
yield
|
||||
|
||||
await close_rabbitmq()
|
||||
await close_mongo()
|
||||
await log.ainfo("asf_core_shutdown")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App object
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="Azurian Software Factory (ASF) — Core API",
|
||||
description=(
|
||||
"Multi-tenant REST API for Azurian Software Factory. "
|
||||
"Handles Organizations, Projects, Artifacts, Artifact Relationships, "
|
||||
"Jobs, Audit events, and dashboard aggregations. "
|
||||
"All business operations must go through this API — "
|
||||
"never access MongoDB / RabbitMQ directly from the browser."
|
||||
),
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=s.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=[
|
||||
"X-Correlation-Id",
|
||||
],
|
||||
)
|
||||
|
||||
# Correlation / context (tenant + user id extraction in dev-trust mode)
|
||||
app.add_middleware(CorrelationMiddleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handlers — translate repository exceptions to consistent HTTP shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.exception_handler(NotFoundError)
|
||||
async def _not_found(req: Request, exc: NotFoundError) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={
|
||||
"error": "not_found",
|
||||
"kind": exc.kind,
|
||||
"entity_id": str(exc.entity_id),
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(DuplicateError)
|
||||
async def _duplicate(req: Request, exc: DuplicateError) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={
|
||||
"error": "duplicate",
|
||||
"kind": exc.kind,
|
||||
"key": exc.key,
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(TenantMismatchError)
|
||||
async def _tenant_mismatch(req: Request, exc: TenantMismatchError) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={
|
||||
"error": "tenant_isolation_violation",
|
||||
"kind": exc.kind,
|
||||
"entity_id": str(exc.entity_id),
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled(req: Request, exc: Exception) -> JSONResponse: # noqa: ARG001
|
||||
import structlog
|
||||
|
||||
await structlog.get_logger(__name__).aexception("unhandled_exception")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "internal_server_error",
|
||||
"message": "An unexpected error occurred. The correlation id has been logged.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app.include_router(api_v1_router, prefix=s.api_prefix)
|
||||
|
||||
|
||||
@app.get("/", tags=["meta"])
|
||||
async def root() -> dict:
|
||||
return {
|
||||
"name": "asf-core",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"health": f"{s.api_prefix}/health",
|
||||
"tagline": "From Requirement to Production, everything is contextualized, traceable and AI-accessible.",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
from .rabbitmq import (
|
||||
get_event_publisher,
|
||||
EventPublisher,
|
||||
publish_domain_event,
|
||||
enqueue_job,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_event_publisher",
|
||||
"EventPublisher",
|
||||
"publish_domain_event",
|
||||
"enqueue_job",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""RabbitMQ producer for asf-core.
|
||||
|
||||
asf-core publishes two classes of messages:
|
||||
1. Domain events → ``asf.events`` topic exchange (e.g. artifact.created)
|
||||
2. Job requests → ``asf.jobs`` direct exchange, routed to the correct
|
||||
logical queue (asf.ai.interactive, asf.indexing, …).
|
||||
|
||||
Core never consumes messages directly — that is the responsibility of
|
||||
asf-worker. Connections are opened lazily and reused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
from uuid import UUID
|
||||
|
||||
import aio_pika
|
||||
import aio_pika.abc
|
||||
import structlog
|
||||
|
||||
from ..config import get_settings
|
||||
from asf_domain.base import JobType
|
||||
from asf_events.base import ASFEvent, JobRequestEnvelope
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
_CHANNEL_POOL: aio_pika.abc.AbstractChannel | None = None
|
||||
_CONN: aio_pika.abc.AbstractConnection | None = None
|
||||
|
||||
|
||||
class EventPublisher:
|
||||
"""Wraps a RabbitMQ channel with helpers for ASF message classes."""
|
||||
|
||||
def __init__(self, channel: aio_pika.abc.AbstractChannel) -> None:
|
||||
self._channel = channel
|
||||
self._settings = get_settings()
|
||||
|
||||
async def declare_exchanges(self) -> None:
|
||||
events_ex = await self._channel.declare_exchange(
|
||||
self._settings.rabbitmq_events_exchange,
|
||||
type=aio_pika.ExchangeType.TOPIC,
|
||||
durable=True,
|
||||
)
|
||||
jobs_ex = await self._channel.declare_exchange(
|
||||
self._settings.rabbitmq_jobs_exchange,
|
||||
type=aio_pika.ExchangeType.DIRECT,
|
||||
durable=True,
|
||||
)
|
||||
|
||||
# ---- Domain event queues (TOPIC bindings on asf.events) ----
|
||||
domain_binds: list[tuple[str, list[str]]] = [
|
||||
("asf.organization.created", ["organization.created"]),
|
||||
("asf.project.created", ["project.created"]),
|
||||
("asf.project.updated", ["project.updated"]),
|
||||
("asf.artifact.created", ["artifact.created"]),
|
||||
("asf.artifact.updated", ["artifact.updated"]),
|
||||
("asf.audit", ["audit.#"]),
|
||||
("asf.ai.execution", ["ai.execution.*"]),
|
||||
]
|
||||
for qname, keys in domain_binds:
|
||||
q = await self._channel.declare_queue(qname, durable=True)
|
||||
for rk in keys:
|
||||
await q.bind(events_ex, routing_key=rk)
|
||||
|
||||
# Dead-letter queue (no default bind — consumed explicitly by worker topology)
|
||||
await self._channel.declare_queue("asf.deadletter", durable=True)
|
||||
|
||||
# ---- Job queues (DIRECT bindings on asf.jobs) ----
|
||||
for qname in self._settings.queues.values():
|
||||
q = await self._channel.declare_queue(qname, durable=True)
|
||||
await q.bind(jobs_ex, routing_key=qname)
|
||||
|
||||
# -------- Domain events --------
|
||||
async def publish_event(self, event: ASFEvent) -> None:
|
||||
body = event.model_dump_json().encode("utf-8")
|
||||
exchange = await self._channel.get_exchange(self._settings.rabbitmq_events_exchange)
|
||||
await exchange.publish(
|
||||
aio_pika.Message(
|
||||
body=body,
|
||||
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||
content_type="application/json",
|
||||
message_id=str(event.event_id),
|
||||
timestamp=event.timestamp,
|
||||
headers={
|
||||
"tenant_id": str(event.tenant_id),
|
||||
"event_type": event.event_type,
|
||||
},
|
||||
),
|
||||
routing_key=event.event_type,
|
||||
)
|
||||
await log.adebug(
|
||||
"event_published",
|
||||
event_type=event.event_type,
|
||||
event_id=str(event.event_id),
|
||||
)
|
||||
|
||||
# -------- Job enqueueing --------
|
||||
async def enqueue_job(self, envelope: JobRequestEnvelope) -> None:
|
||||
qname = self._routing_key_for_job_type(envelope.type)
|
||||
body = envelope.model_dump_json().encode("utf-8")
|
||||
exchange = await self._channel.get_exchange(self._settings.rabbitmq_jobs_exchange)
|
||||
await exchange.publish(
|
||||
aio_pika.Message(
|
||||
body=body,
|
||||
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||
content_type="application/json",
|
||||
message_id=str(envelope.job_id),
|
||||
priority=self._priority(envelope.priority),
|
||||
headers={
|
||||
"tenant_id": str(envelope.tenant_id),
|
||||
"job_type": envelope.type.value,
|
||||
},
|
||||
),
|
||||
routing_key=qname,
|
||||
)
|
||||
await log.adebug(
|
||||
"job_enqueued",
|
||||
job_id=str(envelope.job_id),
|
||||
queue=qname,
|
||||
)
|
||||
|
||||
def _routing_key_for_job_type(self, t: JobType) -> str:
|
||||
mapping = {
|
||||
JobType.AI_INTERACTIVE: "ai_interactive",
|
||||
JobType.AI_BACKGROUND: "ai_background",
|
||||
JobType.INDEXING: "indexing",
|
||||
JobType.CODE_ANALYSIS: "code_analysis",
|
||||
JobType.TESTING: "testing",
|
||||
JobType.DEVOPS: "devops",
|
||||
JobType.NOTIFICATION: "notifications",
|
||||
JobType.CUSTOM: "notifications",
|
||||
}
|
||||
key = mapping[t]
|
||||
return self._settings.queues[key]
|
||||
|
||||
@staticmethod
|
||||
def _priority(p) -> int:
|
||||
return {
|
||||
"low": 1,
|
||||
"normal": 5,
|
||||
"high": 8,
|
||||
"urgent": 10,
|
||||
}.get(getattr(p, "value", str(p)), 5)
|
||||
|
||||
|
||||
async def _get_channel() -> aio_pika.abc.AbstractChannel:
|
||||
global _CHANNEL_POOL, _CONN
|
||||
if _CHANNEL_POOL is None or _CHANNEL_POOL.is_closed:
|
||||
s = get_settings()
|
||||
_CONN = await aio_pika.connect_robust(s.rabbitmq_uri)
|
||||
_CHANNEL_POOL = await _CONN.channel()
|
||||
await _CHANNEL_POOL.set_qos(prefetch_count=100)
|
||||
return _CHANNEL_POOL
|
||||
|
||||
|
||||
async def get_event_publisher() -> EventPublisher:
|
||||
ch = await _get_channel()
|
||||
pub = EventPublisher(ch)
|
||||
await pub.declare_exchanges()
|
||||
return pub
|
||||
|
||||
|
||||
async def close_rabbitmq() -> None:
|
||||
global _CHANNEL_POOL, _CONN
|
||||
try:
|
||||
if _CHANNEL_POOL is not None:
|
||||
await _CHANNEL_POOL.close()
|
||||
except Exception: # noqa: BLE001 - shutdown best effort
|
||||
pass
|
||||
try:
|
||||
if _CONN is not None:
|
||||
await _CONN.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_CHANNEL_POOL = None
|
||||
_CONN = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def rabbitmq_lifespan() -> AsyncIterator[None]:
|
||||
await get_event_publisher() # prime connection
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_rabbitmq()
|
||||
|
||||
|
||||
# -------- Convenience helpers (used by services) --------
|
||||
|
||||
async def publish_domain_event(event: ASFEvent) -> None:
|
||||
pub = await get_event_publisher()
|
||||
await pub.publish_event(event)
|
||||
|
||||
|
||||
async def enqueue_job(envelope: JobRequestEnvelope) -> None:
|
||||
pub = await get_event_publisher()
|
||||
await pub.enqueue_job(envelope)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Correlation id + observability middleware.
|
||||
|
||||
Every inbound request gets a ``correlation_id`` which is:
|
||||
1. Injected as a structlog context variable for every log line.
|
||||
2. Returned to the caller via the ``X-Correlation-Id`` response header.
|
||||
3. Propagated to downstream calls (events, workers) that happen within
|
||||
the request lifecycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
CORRELATION_HEADER = "X-Correlation-Id"
|
||||
TENANT_HEADER = "X-ASF-Tenant-Id"
|
||||
USER_HEADER = "X-ASF-User-Id"
|
||||
|
||||
# Deterministic UUID namespace for dev-trust string IDs.
|
||||
# Any non-UUID string supplied via X-ASF headers is mapped to a stable UUIDv5
|
||||
# so that the domain model (which uses UUID IDs internally) stays consistent
|
||||
# while still allowing human-friendly strings like "t-demo-001" during local dev.
|
||||
_ASP_NAMESPACE = uuid.UUID("9a8f2d58-14b4-40e1-93d3-1e4c2f0e6a22")
|
||||
|
||||
|
||||
def _coerce_uuid(value: str) -> uuid.UUID:
|
||||
"""Accept raw UUID hex strings or arbitrary opaque identifiers (UUID5 fallback)."""
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
return uuid.uuid5(_ASP_NAMESPACE, value)
|
||||
|
||||
correlation_id_var: ContextVar[str | None] = ContextVar(
|
||||
"correlation_id", default=None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestContext:
|
||||
"""Tenant + identity context extracted from the authenticated call.
|
||||
|
||||
For the bootstrap phase we accept ``X-ASF-Tenant-Id`` / ``X-ASF-User-Id``
|
||||
headers directly when ``dev_trust_tenant_header`` is true in settings.
|
||||
|
||||
In production these fields MUST come from a validated JWT / session —
|
||||
NEVER from raw client headers.
|
||||
"""
|
||||
|
||||
tenant_id: uuid.UUID | None
|
||||
user_id: uuid.UUID | None
|
||||
correlation_id: str
|
||||
roles: list[str]
|
||||
|
||||
def require_tenant(self) -> uuid.UUID:
|
||||
if self.tenant_id is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="Missing authenticated tenant context")
|
||||
return self.tenant_id
|
||||
|
||||
def require_user(self) -> uuid.UUID:
|
||||
if self.user_id is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="Missing authenticated user")
|
||||
return self.user_id
|
||||
|
||||
|
||||
request_context_var: ContextVar[RequestContext | None] = ContextVar(
|
||||
"request_context", default=None
|
||||
)
|
||||
|
||||
|
||||
def get_current_context() -> RequestContext:
|
||||
ctx = request_context_var.get()
|
||||
if ctx is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="No request context available")
|
||||
return ctx
|
||||
|
||||
|
||||
class CorrelationMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
cid = request.headers.get(CORRELATION_HEADER) or uuid.uuid4().hex
|
||||
correlation_id_var.set(cid)
|
||||
|
||||
with structlog.contextvars.bound_contextvars(correlation_id=cid):
|
||||
try:
|
||||
ctx = _extract_context(request, cid)
|
||||
except _ContextError as exc:
|
||||
# BaseHTTPMiddleware runs before FastAPI exception handlers.
|
||||
# Build the error response directly instead of raising HTTPException.
|
||||
resp = JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": exc.kind, "message": exc.message},
|
||||
)
|
||||
resp.headers[CORRELATION_HEADER] = cid
|
||||
return resp
|
||||
|
||||
request_context_var.set(ctx)
|
||||
structlog.contextvars.bind_contextvars(
|
||||
tenant_id=str(ctx.tenant_id) if ctx.tenant_id else None,
|
||||
user_id=str(ctx.user_id) if ctx.user_id else None,
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers[CORRELATION_HEADER] = cid
|
||||
return response
|
||||
|
||||
|
||||
class _ContextError(Exception):
|
||||
"""Raised internally by _extract_context to short-circuit auth failures."""
|
||||
|
||||
def __init__(self, status_code: int, kind: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
|
||||
|
||||
def _extract_context(request: Request, cid: str) -> RequestContext:
|
||||
from ..config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
tenant_id: uuid.UUID | None = None
|
||||
user_id: uuid.UUID | None = None
|
||||
roles: list[str] = []
|
||||
|
||||
# ---- Bootstrap-trust mode for local dev ----
|
||||
if s.dev_trust_tenant_header and TENANT_HEADER in request.headers:
|
||||
raw = request.headers[TENANT_HEADER].strip()
|
||||
if not raw:
|
||||
raise _ContextError(400, "invalid_header", f"Empty {TENANT_HEADER}")
|
||||
tenant_id = _coerce_uuid(raw)
|
||||
|
||||
if s.dev_trust_user_header and USER_HEADER in request.headers:
|
||||
raw = request.headers[USER_HEADER].strip()
|
||||
if not raw:
|
||||
raise _ContextError(400, "invalid_header", f"Empty {USER_HEADER}")
|
||||
user_id = _coerce_uuid(raw)
|
||||
roles = ["admin"]
|
||||
|
||||
return RequestContext(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
correlation_id=cid,
|
||||
roles=roles,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
from .base import NotFoundError, DuplicateError, TenantMismatchError
|
||||
from .organization_repo import OrganizationRepository
|
||||
from .project_repo import ProjectRepository
|
||||
from .artifact_repo import ArtifactRepository
|
||||
from .relationship_repo import RelationshipRepository
|
||||
from .job_repo import JobRepository
|
||||
from .audit_repo import AuditRepository
|
||||
|
||||
__all__ = [
|
||||
"NotFoundError",
|
||||
"DuplicateError",
|
||||
"TenantMismatchError",
|
||||
"OrganizationRepository",
|
||||
"ProjectRepository",
|
||||
"ArtifactRepository",
|
||||
"RelationshipRepository",
|
||||
"JobRepository",
|
||||
"AuditRepository",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Artifact repository — single collection for every artifact type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactUpdate,
|
||||
ArtifactType,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class ArtifactRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if project_id is not None:
|
||||
q["project_id"] = project_id
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, artifact_id: UUID) -> Optional[Artifact]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": artifact_id}))
|
||||
return _from_doc(Artifact, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[ArtifactType | str] = None,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Artifact]:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if artifact_type is not None:
|
||||
q["type"] = artifact_type if isinstance(artifact_type, str) else artifact_type.value
|
||||
if status is not None:
|
||||
q["status"] = status
|
||||
if search:
|
||||
q["$or"] = [
|
||||
{"title": {"$regex": search, "$options": "i"}},
|
||||
{"content": {"$regex": search, "$options": "i"}},
|
||||
]
|
||||
cur = (
|
||||
self._c.find(q)
|
||||
.sort("created_at", -1)
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return [_from_doc(Artifact, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def count(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[str] = None,
|
||||
) -> int:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if artifact_type is not None:
|
||||
q["type"] = artifact_type
|
||||
return await self._c.count_documents(q)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: ArtifactCreate,
|
||||
*,
|
||||
created_by: Optional[UUID] = None,
|
||||
) -> Artifact:
|
||||
now = _now_utc()
|
||||
a = Artifact(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
created_by=created_by,
|
||||
version=1,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(a)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return a
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
body: ArtifactUpdate,
|
||||
) -> Artifact:
|
||||
raw = await self._c.find_one({"_id": artifact_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Artifact", artifact_id)
|
||||
current: Artifact = _from_doc(Artifact, raw)
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
metadata_update = changes.pop("metadata", None)
|
||||
|
||||
final_metadata = current.metadata
|
||||
if metadata_update is not None:
|
||||
final_metadata = {**final_metadata, **metadata_update}
|
||||
changes["metadata"] = final_metadata
|
||||
if not changes:
|
||||
return current
|
||||
|
||||
changes["version"] = current.version + 1
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": artifact_id}, {"$set": changes})
|
||||
return current.model_copy(update=changes)
|
||||
|
||||
async def delete(self, tenant_id: UUID, artifact_id: UUID) -> bool:
|
||||
raw = await self._c.find_one({"_id": artifact_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Artifact", artifact_id)
|
||||
res = await self._c.delete_one({"_id": artifact_id})
|
||||
return res.deleted_count > 0
|
||||
|
||||
async def get_many(self, tenant_id: UUID, artifact_ids: list[UUID]) -> list[Artifact]:
|
||||
if not artifact_ids:
|
||||
return []
|
||||
cur = self._c.find(self._q(tenant_id, extra={"_id": {"$in": artifact_ids}}))
|
||||
return [_from_doc(Artifact, d) async for d in cur]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""AuditEvent repository — append-only, never updates or deletes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import AuditEvent, AuditEventCreate
|
||||
|
||||
from .base import (
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
async def append(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: AuditEventCreate,
|
||||
) -> AuditEvent:
|
||||
now = _now_utc()
|
||||
ae = AuditEvent(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(ae)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return ae
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor_id: Optional[UUID] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> list[AuditEvent]:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if actor_id is not None:
|
||||
q["actor_id"] = actor_id
|
||||
if resource_type is not None:
|
||||
q["resource_type"] = resource_type
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(AuditEvent, d) async for d in cur]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Repository base exceptions + shared helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pydantic import BaseModel
|
||||
|
||||
from asf_domain.base import generate_id
|
||||
|
||||
|
||||
class ASFRepositoryError(Exception):
|
||||
"""Base for all repository-layer errors."""
|
||||
|
||||
|
||||
class NotFoundError(ASFRepositoryError):
|
||||
def __init__(self, kind: str, entity_id: UUID | str) -> None:
|
||||
super().__init__(f"{kind} not found: {entity_id}")
|
||||
self.kind = kind
|
||||
self.entity_id = entity_id
|
||||
|
||||
|
||||
class DuplicateError(ASFRepositoryError):
|
||||
def __init__(self, kind: str, key: str) -> None:
|
||||
super().__init__(f"{kind} already exists: {key}")
|
||||
self.kind = kind
|
||||
self.key = key
|
||||
|
||||
|
||||
class TenantMismatchError(ASFRepositoryError):
|
||||
"""Raised when an operation targets an entity owned by another tenant."""
|
||||
|
||||
def __init__(self, kind: str, entity_id: UUID | str) -> None:
|
||||
super().__init__(f"Tenant isolation violation: {kind} {entity_id}")
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def _to_doc(m: BaseModel) -> dict[str, Any]:
|
||||
return m.model_dump(mode="python", by_alias=False)
|
||||
|
||||
|
||||
def _from_doc(cls, doc: Mapping[str, Any]):
|
||||
"""Instantiate a Pydantic model from a Mongo document.
|
||||
|
||||
``_id`` → ``id`` rename handled explicitly. Motor already returns UUID
|
||||
objects for UUID fields when uuidRepresentation=standard is set.
|
||||
|
||||
Unknown document fields are filtered out (matches ``extra = forbid``
|
||||
used by most ASF domain models while still tolerating new audit-only
|
||||
Mongo columns like ``updated_at`` / ``metadata``).
|
||||
"""
|
||||
if doc is None:
|
||||
return None
|
||||
d = dict(doc)
|
||||
if "_id" in d and "id" not in d:
|
||||
d["id"] = d.pop("_id")
|
||||
allowed = set(cls.model_fields.keys())
|
||||
known = {k: v for k, v in d.items() if k in allowed}
|
||||
try:
|
||||
return cls.model_validate(known)
|
||||
except Exception:
|
||||
for k in list(known.keys()):
|
||||
f = cls.model_fields.get(k)
|
||||
if f is None:
|
||||
continue
|
||||
annotation = getattr(f, "annotation", None)
|
||||
# Wrap None for required fields with usable defaults — job usage, etc.
|
||||
if known[k] is None and annotation is not None:
|
||||
from typing import get_origin, get_args
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
# Default dict for JobUsage
|
||||
name = ""
|
||||
if hasattr(annotation, "__name__"):
|
||||
name = annotation.__name__
|
||||
for arg in args:
|
||||
if hasattr(arg, "__name__"):
|
||||
name = arg.__name__
|
||||
break
|
||||
if name == "JobUsage":
|
||||
known[k] = {"extra": {}}
|
||||
return cls.model_validate(known)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Job repository — persistent state of every async job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobStatus,
|
||||
JobPriority,
|
||||
JobType,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class JobRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if project_id is not None:
|
||||
q["project_id"] = project_id
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, job_id: UUID) -> Optional[Job]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": job_id}))
|
||||
return _from_doc(Job, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
status: Optional[JobStatus] = None,
|
||||
type: Optional[JobType] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Job]:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if status is not None:
|
||||
q["status"] = status.value if isinstance(status, JobStatus) else status
|
||||
if type is not None:
|
||||
q["type"] = type.value if isinstance(type, JobType) else type
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Job, d) async for d in cur]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: JobCreate,
|
||||
*,
|
||||
requested_by: Optional[UUID] = None,
|
||||
) -> Job:
|
||||
now = _now_utc()
|
||||
j = Job(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
requested_by=requested_by,
|
||||
status=JobStatus.QUEUED,
|
||||
created_at=now,
|
||||
usage={},
|
||||
**body.model_dump(exclude_defaults=False),
|
||||
)
|
||||
doc = _to_doc(j)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return j
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, job_id: UUID, body: JobUpdate
|
||||
) -> Job:
|
||||
raw = await self._c.find_one({"_id": job_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Job", job_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Job", job_id)
|
||||
current: Job = _from_doc(Job, raw)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return current
|
||||
changes["updated_at"] = _now_utc() if "updated_at" not in changes else changes["updated_at"]
|
||||
await self._c.update_one({"_id": job_id}, {"$set": changes})
|
||||
return current.model_copy(update=changes)
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
job_id: UUID,
|
||||
status: JobStatus,
|
||||
*,
|
||||
worker: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> Job:
|
||||
updates: dict = {"status": status, "updated_at": _now_utc()}
|
||||
if status == JobStatus.RUNNING:
|
||||
updates["started_at"] = updates["updated_at"]
|
||||
if status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||
updates["completed_at"] = updates["updated_at"]
|
||||
if worker is not None:
|
||||
updates["worker"] = worker
|
||||
if error is not None:
|
||||
updates["error"] = error
|
||||
raw = await self._c.find_one_and_update(
|
||||
{"_id": job_id, "tenant_id": tenant_id},
|
||||
{"$set": updates},
|
||||
return_document=True,
|
||||
)
|
||||
if raw is None:
|
||||
raise NotFoundError("Job", job_id)
|
||||
return _from_doc(Job, raw)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Organization repository — tenant boundary record.
|
||||
|
||||
Note that Organization records are NOT themselves tenant-scoped (they ARE
|
||||
the tenant). Every other entity references tenant_id == organization.id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
async def get(self, org_id: UUID) -> Optional[Organization]:
|
||||
doc = await self._c.find_one({"_id": org_id})
|
||||
return _from_doc(Organization, doc)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Organization]:
|
||||
doc = await self._c.find_one({"slug": slug})
|
||||
return _from_doc(Organization, doc)
|
||||
|
||||
async def list(self, *, limit: int = 100, offset: int = 0) -> list[Organization]:
|
||||
cur = self._c.find({}).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Organization, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def create(self, body: OrganizationCreate, *, override_id: Optional[UUID] = None) -> Organization:
|
||||
now = _now_utc()
|
||||
org = Organization(
|
||||
id=override_id or generate_id(),
|
||||
**body.model_dump(),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
doc = _to_doc(org)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError("Organization", org.slug) from exc
|
||||
return org
|
||||
|
||||
async def update(self, org_id: UUID, body: OrganizationUpdate) -> Organization:
|
||||
current = await self.get(org_id)
|
||||
if current is None:
|
||||
raise NotFoundError("Organization", org_id)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return current
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": org_id}, {"$set": changes})
|
||||
updated = current.model_copy(update=changes)
|
||||
return updated
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Project repository — always tenant-scoped."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import Project, ProjectCreate, ProjectUpdate
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class ProjectRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
# ---- internal helpers ----
|
||||
def _q(self, tenant_id: UUID, extra: Optional[dict] = None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, project_id: UUID) -> Optional[Project]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, {"_id": project_id}))
|
||||
return _from_doc(Project, doc)
|
||||
|
||||
async def get_by_key(self, tenant_id: UUID, key: str) -> Optional[Project]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, {"key": key}))
|
||||
return _from_doc(Project, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
only_active: bool = True,
|
||||
) -> list[Project]:
|
||||
q = self._q(tenant_id)
|
||||
if only_active:
|
||||
q["is_active"] = True
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Project, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def create(self, tenant_id: UUID, body: ProjectCreate) -> Project:
|
||||
now = _now_utc()
|
||||
p = Project(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(p)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError("Project", f"{tenant_id}/{p.key}") from exc
|
||||
return p
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, project_id: UUID, body: ProjectUpdate
|
||||
) -> Project:
|
||||
current = await self._c.find_one({"_id": project_id})
|
||||
if current is None:
|
||||
raise NotFoundError("Project", project_id)
|
||||
if current.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Project", project_id)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return _from_doc(Project, current)
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": project_id}, {"$set": changes})
|
||||
return _from_doc(Project, {**current, **changes})
|
||||
@@ -0,0 +1,134 @@
|
||||
"""ArtifactRelationship repository — explicit directed edges.
|
||||
|
||||
Indexes support:
|
||||
* find all outgoing edges from an artifact
|
||||
* find all incoming edges to an artifact
|
||||
* ensure uniqueness of a (source, target, type) triple
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import ArtifactRelationship, ArtifactRelationshipCreate, RelationshipType
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class RelationshipRepository:
|
||||
def __init__(
|
||||
self,
|
||||
collection: AsyncIOMotorCollection,
|
||||
artifacts_collection: AsyncIOMotorCollection,
|
||||
) -> None:
|
||||
self._c = collection
|
||||
self._arts = artifacts_collection
|
||||
|
||||
# ---- helpers ----
|
||||
async def _assert_tenant_owns(
|
||||
self, tenant_id: UUID, artifact_id: UUID
|
||||
) -> tuple[bool, UUID | None]:
|
||||
d = await self._arts.find_one(
|
||||
{"_id": artifact_id, "tenant_id": tenant_id},
|
||||
{"project_id": 1},
|
||||
)
|
||||
if d is None:
|
||||
return False, None
|
||||
return True, d.get("project_id")
|
||||
|
||||
# ---- CRUD ----
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: ArtifactRelationshipCreate,
|
||||
) -> ArtifactRelationship:
|
||||
ok_src, src_prj = await self._assert_tenant_owns(tenant_id, body.source_artifact_id)
|
||||
ok_tgt, tgt_prj = await self._assert_tenant_owns(tenant_id, body.target_artifact_id)
|
||||
if not ok_src:
|
||||
raise TenantMismatchError("Artifact", body.source_artifact_id)
|
||||
if not ok_tgt:
|
||||
raise TenantMismatchError("Artifact", body.target_artifact_id)
|
||||
|
||||
now = _now_utc()
|
||||
r = ArtifactRelationship(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
source_project_id=src_prj,
|
||||
target_project_id=tgt_prj,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(r)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError(
|
||||
"ArtifactRelationship",
|
||||
f"{body.source_artifact_id}-{body.relationship_type}-{body.target_artifact_id}",
|
||||
) from exc
|
||||
return r
|
||||
|
||||
async def delete(self, tenant_id: UUID, relationship_id: UUID) -> bool:
|
||||
raw = await self._c.find_one({"_id": relationship_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("ArtifactRelationship", relationship_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("ArtifactRelationship", relationship_id)
|
||||
res = await self._c.delete_one({"_id": relationship_id})
|
||||
return res.deleted_count > 0
|
||||
|
||||
# ---- traversal queries ----
|
||||
async def outgoing(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
q: dict = {"tenant_id": tenant_id, "source_artifact_id": artifact_id}
|
||||
if relationship_type is not None:
|
||||
q["relationship_type"] = relationship_type
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
|
||||
async def incoming(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
q: dict = {"tenant_id": tenant_id, "target_artifact_id": artifact_id}
|
||||
if relationship_type is not None:
|
||||
q["relationship_type"] = relationship_type
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
|
||||
async def list_for_artifact(
|
||||
self, tenant_id: UUID, artifact_id: UUID, *, limit: int = 200
|
||||
) -> list[ArtifactRelationship]:
|
||||
q = {
|
||||
"tenant_id": tenant_id,
|
||||
"$or": [
|
||||
{"source_artifact_id": artifact_id},
|
||||
{"target_artifact_id": artifact_id},
|
||||
],
|
||||
}
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
@@ -0,0 +1,17 @@
|
||||
from .organization_service import OrganizationService
|
||||
from .project_service import ProjectService
|
||||
from .artifact_service import ArtifactService
|
||||
from .relationship_service import RelationshipService
|
||||
from .job_service import JobService
|
||||
from .audit_service import AuditService
|
||||
from .overview_service import OverviewService
|
||||
|
||||
__all__ = [
|
||||
"OrganizationService",
|
||||
"ProjectService",
|
||||
"ArtifactService",
|
||||
"RelationshipService",
|
||||
"JobService",
|
||||
"AuditService",
|
||||
"OverviewService",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Artifact service: CRUD with event publishing and audit.
|
||||
|
||||
The ArtifactRepository does NOT emit events. This service does — because
|
||||
event emission is a business / application-layer concern, not a pure
|
||||
persistence concern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactUpdate,
|
||||
ArtifactType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.artifact_events import (
|
||||
ArtifactCreated,
|
||||
ArtifactUpdated,
|
||||
ArtifactDeleted,
|
||||
)
|
||||
|
||||
from ..repositories import (
|
||||
ArtifactRepository,
|
||||
ProjectRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ArtifactService:
|
||||
def __init__(
|
||||
self,
|
||||
artifacts: ArtifactRepository,
|
||||
projects: ProjectRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._artifacts = artifacts
|
||||
self._projects = projects
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def _project_exists(self, project_id: UUID) -> None:
|
||||
if await self._projects.get(self._tenant, project_id) is None:
|
||||
raise NotFoundError("Project", project_id)
|
||||
|
||||
async def get(self, artifact_id: UUID) -> Optional[Artifact]:
|
||||
return await self._artifacts.get(self._tenant, artifact_id)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[ArtifactType | str] = None,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Artifact]:
|
||||
return await self._artifacts.list(
|
||||
self._tenant,
|
||||
project_id=project_id,
|
||||
artifact_type=artifact_type,
|
||||
status=status,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def count(
|
||||
self,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[str] = None,
|
||||
) -> int:
|
||||
return await self._artifacts.count(
|
||||
self._tenant, project_id=project_id, artifact_type=artifact_type
|
||||
)
|
||||
|
||||
async def get_many(self, ids: list[UUID]) -> list[Artifact]:
|
||||
return await self._artifacts.get_many(self._tenant, ids)
|
||||
|
||||
async def create(self, body: ArtifactCreate) -> Artifact:
|
||||
await self._project_exists(body.project_id)
|
||||
a = await self._artifacts.create(
|
||||
self._tenant, body, created_by=self._actor
|
||||
)
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactCreated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=a.project_id,
|
||||
artifact_id=a.id,
|
||||
artifact_type=a.type.value
|
||||
if isinstance(a.type, ArtifactType)
|
||||
else str(a.type),
|
||||
payload={
|
||||
"title": a.title,
|
||||
"source": a.source,
|
||||
"version": a.version,
|
||||
"external_id": a.external_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("event_publish_failed", event_name="artifact.created")
|
||||
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="artifact",
|
||||
resource_id=a.id,
|
||||
project_id=a.project_id,
|
||||
actor_id=self._actor,
|
||||
metadata={
|
||||
"type": a.type,
|
||||
"title": a.title,
|
||||
"version": a.version,
|
||||
},
|
||||
),
|
||||
)
|
||||
return a
|
||||
|
||||
async def update(self, artifact_id: UUID, body: ArtifactUpdate) -> Artifact:
|
||||
prev = await self._artifacts.get(self._tenant, artifact_id)
|
||||
if prev is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
updated = await self._artifacts.update(self._tenant, artifact_id, body)
|
||||
changed = sorted(body.model_dump(exclude_unset=True).keys())
|
||||
if changed:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactUpdated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
artifact_id=updated.id,
|
||||
artifact_type=updated.type
|
||||
if isinstance(updated.type, str)
|
||||
else updated.type.value,
|
||||
changed_fields=changed,
|
||||
previous_version=prev.version,
|
||||
payload={"version": updated.version},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.UPDATED,
|
||||
resource_type="artifact",
|
||||
resource_id=updated.id,
|
||||
project_id=updated.project_id,
|
||||
actor_id=self._actor,
|
||||
metadata={"changed_fields": changed, "version": updated.version},
|
||||
),
|
||||
)
|
||||
return updated
|
||||
|
||||
async def delete(self, artifact_id: UUID) -> bool:
|
||||
a = await self._artifacts.get(self._tenant, artifact_id)
|
||||
if a is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
ok = await self._artifacts.delete(self._tenant, artifact_id)
|
||||
if ok:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactDeleted(
|
||||
tenant_id=self._tenant,
|
||||
project_id=a.project_id,
|
||||
artifact_id=a.id,
|
||||
artifact_type=a.type if isinstance(a.type, str) else a.type.value,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.DELETED,
|
||||
resource_type="artifact",
|
||||
resource_id=artifact_id,
|
||||
project_id=a.project_id,
|
||||
actor_id=self._actor,
|
||||
metadata={"type": a.type, "title": a.title},
|
||||
),
|
||||
)
|
||||
return ok
|
||||
@@ -0,0 +1,39 @@
|
||||
"""AuditService: thin wrapper for AuditRepository queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asf_domain import AuditEvent, AuditEventCreate
|
||||
from ..repositories import AuditRepository
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(
|
||||
self,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._audit = audit
|
||||
|
||||
async def append(self, body: AuditEventCreate) -> AuditEvent:
|
||||
return await self._audit.append(self._tenant, body)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor_id: Optional[UUID] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> list[AuditEvent]:
|
||||
return await self._audit.list(
|
||||
self._tenant,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
actor_id=actor_id,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Job service: lifecycle + RabbitMQ enqueueing + audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobStatus,
|
||||
JobType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_events.job_events import JobQueued, JobStarted, JobCompleted, JobFailed
|
||||
|
||||
from ..repositories import (
|
||||
JobRepository,
|
||||
ProjectRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
)
|
||||
from ..messaging import publish_domain_event, enqueue_job as _enqueue_job
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class JobService:
|
||||
def __init__(
|
||||
self,
|
||||
jobs: JobRepository,
|
||||
projects: ProjectRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._jobs = jobs
|
||||
self._projects = projects
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def get(self, job_id: UUID) -> Optional[Job]:
|
||||
return await self._jobs.get(self._tenant, job_id)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
status: Optional[JobStatus] = None,
|
||||
type: Optional[JobType] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Job]:
|
||||
return await self._jobs.list(
|
||||
self._tenant,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
type=type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def submit(self, body: JobCreate) -> Job:
|
||||
"""Create a persistent Job record and enqueue the work item on RabbitMQ."""
|
||||
if body.project_id is not None:
|
||||
if await self._projects.get(self._tenant, body.project_id) is None:
|
||||
raise NotFoundError("Project", body.project_id)
|
||||
|
||||
job = await self._jobs.create(self._tenant, body, requested_by=self._actor)
|
||||
|
||||
envelope = JobRequestEnvelope(
|
||||
job_id=job.id,
|
||||
tenant_id=self._tenant,
|
||||
project_id=job.project_id,
|
||||
type=job.type,
|
||||
priority=body.priority,
|
||||
correlation_id=body.correlation_id or str(job.id),
|
||||
payload=body.payload,
|
||||
)
|
||||
try:
|
||||
await _enqueue_job(envelope)
|
||||
queued_ok = True
|
||||
except Exception: # noqa: BLE001
|
||||
# Leave the job record as QUEUED — the worker retry loop / scheduler
|
||||
# will pick it up. The record in Mongo is the source of truth.
|
||||
queued_ok = False
|
||||
await log.aexception("rabbitmq_enqueue_failed", job_id=str(job.id))
|
||||
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobQueued(
|
||||
tenant_id=self._tenant,
|
||||
project_id=job.project_id,
|
||||
correlation_id=envelope.correlation_id,
|
||||
job_id=job.id,
|
||||
job_type=envelope.type.value
|
||||
if isinstance(envelope.type, JobType)
|
||||
else str(envelope.type),
|
||||
queue=envelope.type.name if isinstance(envelope.type, JobType) else str(envelope.type),
|
||||
payload={"queued_via_rabbit": queued_ok},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="job",
|
||||
resource_id=job.id,
|
||||
project_id=job.project_id,
|
||||
actor_id=self._actor,
|
||||
correlation_id=envelope.correlation_id,
|
||||
metadata={"type": job.type, "priority": job.priority},
|
||||
),
|
||||
)
|
||||
return job
|
||||
|
||||
async def update(self, job_id: UUID, body: JobUpdate) -> Job:
|
||||
updated = await self._jobs.update(self._tenant, job_id, body)
|
||||
# Emit notable lifecycle transitions
|
||||
if body.status == JobStatus.RUNNING:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobStarted(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
job_id=job_id,
|
||||
worker=body.worker,
|
||||
payload={"started_at": str(body.started_at or updated.started_at)},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if body.status == JobStatus.COMPLETED:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobCompleted(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
job_id=job_id,
|
||||
payload={k: v for k, v in (body.usage.model_dump() if body.usage else {}).items()},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if body.status == JobStatus.FAILED:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobFailed(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
job_id=job_id,
|
||||
error=body.error or updated.error or "unknown",
|
||||
payload=body.error_detail or {},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return updated
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Organization service: business rules, event publishing, audit emission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.identity_events import OrganizationCreated
|
||||
|
||||
from ..repositories import (
|
||||
OrganizationRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OrganizationService:
|
||||
def __init__(
|
||||
self,
|
||||
orgs: OrganizationRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
actor_id: Optional[UUID] = None,
|
||||
preferred_org_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._orgs = orgs
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
self._preferred_org_id = preferred_org_id
|
||||
|
||||
async def get(self, org_id: UUID) -> Optional[Organization]:
|
||||
return await self._orgs.get(org_id)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Organization]:
|
||||
return await self._orgs.get_by_slug(slug)
|
||||
|
||||
async def list(self, *, limit=100, offset=0) -> list[Organization]:
|
||||
return await self._orgs.list(limit=limit, offset=offset)
|
||||
|
||||
async def create(self, body: OrganizationCreate) -> Organization:
|
||||
org = await self._orgs.create(body, override_id=self._preferred_org_id)
|
||||
await log.ainfo("organization_created", org_id=str(org.id), slug=org.slug)
|
||||
|
||||
# publish event
|
||||
try:
|
||||
await publish_domain_event(
|
||||
OrganizationCreated(
|
||||
tenant_id=org.id,
|
||||
organization_id=org.id,
|
||||
slug=org.slug,
|
||||
name=org.name,
|
||||
payload={"plan": org.plan},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - event publish failure MUST NOT roll back writes
|
||||
await log.aexception("event_publish_failed", event_name="organization.created")
|
||||
|
||||
await self._audit.append(
|
||||
org.id,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="organization",
|
||||
resource_id=org.id,
|
||||
actor_id=self._actor,
|
||||
actor_type="user" if self._actor else "system",
|
||||
metadata={"name": org.name, "slug": org.slug},
|
||||
),
|
||||
)
|
||||
return org
|
||||
|
||||
async def update(self, org_id: UUID, body: OrganizationUpdate) -> Organization:
|
||||
updated = await self._orgs.update(org_id, body)
|
||||
await self._audit.append(
|
||||
org_id,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.UPDATED,
|
||||
resource_type="organization",
|
||||
resource_id=updated.id,
|
||||
actor_id=self._actor,
|
||||
metadata=body.model_dump(exclude_unset=True),
|
||||
),
|
||||
)
|
||||
return updated
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Overview: dashboard aggregates for the Overview page.
|
||||
|
||||
The frontend Overview page shows a bunch of health / metric cards. These
|
||||
are projections of the data we have; for anything we don't yet compute
|
||||
end-to-end we return a ``mock`` flag so the UI can clearly label demo
|
||||
data without misleading anyone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from asf_domain import ArtifactType, JobStatus
|
||||
from ..repositories import (
|
||||
ArtifactRepository,
|
||||
JobRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectCard:
|
||||
key: str
|
||||
name: str
|
||||
is_mock: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class OverviewStats:
|
||||
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] # which fields are synthetic/demo
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class OverviewService:
|
||||
def __init__(
|
||||
self,
|
||||
artifacts: ArtifactRepository,
|
||||
projects: ProjectRepository,
|
||||
jobs: JobRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._arts = artifacts
|
||||
self._projects = projects
|
||||
self._jobs = jobs
|
||||
|
||||
async def overview(self) -> OverviewStats:
|
||||
t = self._tenant
|
||||
total_projects = await self._projects.list(t, limit=1000)
|
||||
featured = [
|
||||
{"key": p.key, "name": p.name}
|
||||
for p in total_projects[:5]
|
||||
]
|
||||
n_projects = len(total_projects)
|
||||
|
||||
# --- real counts from MongoDB ---
|
||||
n_req = await self._arts.count(t, artifact_type=ArtifactType.REQUIREMENT.value)
|
||||
n_task = await self._arts.count(t, artifact_type=ArtifactType.TASK.value)
|
||||
n_test = await self._arts.count(t, artifact_type=ArtifactType.TEST.value)
|
||||
n_depl = await self._arts.count(t, artifact_type=ArtifactType.DEPLOYMENT.value)
|
||||
n_commits = await self._arts.count(t, artifact_type=ArtifactType.COMMIT.value)
|
||||
|
||||
jobs_running = len(await self._jobs.list(t, status=JobStatus.RUNNING, limit=1000))
|
||||
jobs_queued = len(await self._jobs.list(t, status=JobStatus.QUEUED, limit=1000))
|
||||
jobs_failed_recent = len(await self._jobs.list(t, status=JobStatus.FAILED, limit=100))
|
||||
|
||||
# --- synthetic / demo metrics (clearly flagged as mock) ---
|
||||
mock_fields: list[str] = [
|
||||
"implementation_coverage_pct",
|
||||
"test_coverage_pct",
|
||||
"traceability_score_pct",
|
||||
"project_health_pct",
|
||||
"security_findings",
|
||||
"failed_builds",
|
||||
]
|
||||
implementation_coverage_pct = (
|
||||
round((n_task / n_req) * 100, 1) if n_req else 78.0
|
||||
)
|
||||
test_coverage_pct = round((n_test / n_task) * 100, 1) if n_task else 62.5
|
||||
traceability_score_pct = min(
|
||||
99.0,
|
||||
40.0 + 0.4 * n_req + 0.2 * n_test + 0.1 * n_commits,
|
||||
)
|
||||
project_health_pct = max(
|
||||
30.0,
|
||||
95.0 - 3.0 * jobs_failed_recent - 0.5 * jobs_queued,
|
||||
)
|
||||
security_findings = 3 # demo
|
||||
failed_builds = 2 # demo
|
||||
active_ai_jobs = jobs_running + jobs_queued
|
||||
|
||||
return OverviewStats(
|
||||
tenant_id=str(t),
|
||||
total_projects=n_projects,
|
||||
total_requirements=n_req,
|
||||
total_tasks=n_task,
|
||||
total_tests=n_test,
|
||||
total_deployments=n_depl,
|
||||
total_commits=n_commits,
|
||||
jobs_running=jobs_running,
|
||||
jobs_queued=jobs_queued,
|
||||
jobs_failed_24h=jobs_failed_recent,
|
||||
implementation_coverage_pct=implementation_coverage_pct,
|
||||
test_coverage_pct=test_coverage_pct,
|
||||
traceability_score_pct=round(traceability_score_pct, 1),
|
||||
project_health_pct=round(project_health_pct, 1),
|
||||
security_findings=security_findings,
|
||||
failed_builds=failed_builds,
|
||||
active_ai_jobs=active_ai_jobs,
|
||||
featured_projects=featured,
|
||||
mock_fields=mock_fields,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Project service: CRUD + validation + event publishing + audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.identity_events import ProjectCreated, ProjectUpdated
|
||||
|
||||
from ..repositories import (
|
||||
ProjectRepository,
|
||||
OrganizationRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ProjectService:
|
||||
def __init__(
|
||||
self,
|
||||
projects: ProjectRepository,
|
||||
orgs: OrganizationRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._projects = projects
|
||||
self._orgs = orgs
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def _ensure_tenant_exists(self) -> None:
|
||||
if await self._orgs.get(self._tenant) is None:
|
||||
raise NotFoundError("Organization", self._tenant)
|
||||
|
||||
async def get(self, project_id: UUID) -> Optional[Project]:
|
||||
return await self._projects.get(self._tenant, project_id)
|
||||
|
||||
async def list(self, *, limit=100, offset=0, only_active=True) -> list[Project]:
|
||||
return await self._projects.list(
|
||||
self._tenant, limit=limit, offset=offset, only_active=only_active
|
||||
)
|
||||
|
||||
async def create(self, body: ProjectCreate) -> Project:
|
||||
await self._ensure_tenant_exists()
|
||||
p = await self._projects.create(self._tenant, body)
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ProjectCreated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=p.id,
|
||||
organization_id=self._tenant,
|
||||
project_key=p.key,
|
||||
name=p.name,
|
||||
payload={"default_branch": p.default_branch},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("event_publish_failed", event_name="project.created")
|
||||
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="project",
|
||||
resource_id=p.id,
|
||||
project_id=p.id,
|
||||
actor_id=self._actor,
|
||||
metadata={"key": p.key, "name": p.name},
|
||||
),
|
||||
)
|
||||
return p
|
||||
|
||||
async def update(self, project_id: UUID, body: ProjectUpdate) -> Project:
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
updated = await self._projects.update(self._tenant, project_id, body)
|
||||
if changes:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ProjectUpdated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=project_id,
|
||||
changed_fields=list(changes.keys()),
|
||||
payload=changes,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.UPDATED,
|
||||
resource_type="project",
|
||||
resource_id=project_id,
|
||||
project_id=project_id,
|
||||
actor_id=self._actor,
|
||||
metadata=changes,
|
||||
),
|
||||
)
|
||||
return updated
|
||||
@@ -0,0 +1,133 @@
|
||||
"""ArtifactRelationship service: CRUD + events + audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asf_domain import (
|
||||
ArtifactRelationship,
|
||||
ArtifactRelationshipCreate,
|
||||
RelationshipType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.artifact_events import (
|
||||
ArtifactRelationshipCreated,
|
||||
ArtifactRelationshipDeleted,
|
||||
)
|
||||
|
||||
from ..repositories import (
|
||||
RelationshipRepository,
|
||||
AuditRepository,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
|
||||
class RelationshipService:
|
||||
def __init__(
|
||||
self,
|
||||
relationships: RelationshipRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._rels = relationships
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def create(self, body: ArtifactRelationshipCreate) -> ArtifactRelationship:
|
||||
r = await self._rels.create(self._tenant, body)
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactRelationshipCreated(
|
||||
tenant_id=self._tenant,
|
||||
relationship_id=r.id,
|
||||
source_artifact_id=r.source_artifact_id,
|
||||
target_artifact_id=r.target_artifact_id,
|
||||
relationship_type=r.relationship_type
|
||||
if isinstance(r.relationship_type, str)
|
||||
else r.relationship_type.value,
|
||||
source_project_id=r.source_project_id,
|
||||
target_project_id=r.target_project_id,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="artifact_relationship",
|
||||
resource_id=r.id,
|
||||
actor_id=self._actor,
|
||||
metadata={
|
||||
"source": str(r.source_artifact_id),
|
||||
"type": r.relationship_type,
|
||||
"target": str(r.target_artifact_id),
|
||||
},
|
||||
),
|
||||
)
|
||||
return r
|
||||
|
||||
async def delete(self, relationship_id: UUID) -> bool:
|
||||
# We need source & target ids for the event; do a lookup.
|
||||
raw = None
|
||||
# reuse the raw document by querying collection directly
|
||||
r = await self._rels.outgoing(self._tenant, relationship_id) or await self._rels.incoming(
|
||||
self._tenant, relationship_id
|
||||
)
|
||||
ok = await self._rels.delete(self._tenant, relationship_id)
|
||||
if ok:
|
||||
if r:
|
||||
try:
|
||||
first = r[0]
|
||||
await publish_domain_event(
|
||||
ArtifactRelationshipDeleted(
|
||||
tenant_id=self._tenant,
|
||||
relationship_id=relationship_id,
|
||||
source_artifact_id=first.source_artifact_id,
|
||||
target_artifact_id=first.target_artifact_id,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.DELETED,
|
||||
resource_type="artifact_relationship",
|
||||
resource_id=relationship_id,
|
||||
actor_id=self._actor,
|
||||
),
|
||||
)
|
||||
return ok
|
||||
|
||||
async def outgoing(
|
||||
self,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await self._rels.outgoing(
|
||||
self._tenant, artifact_id, relationship_type=relationship_type, limit=limit
|
||||
)
|
||||
|
||||
async def incoming(
|
||||
self,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await self._rels.incoming(
|
||||
self._tenant, artifact_id, relationship_type=relationship_type, limit=limit
|
||||
)
|
||||
|
||||
async def for_artifact(
|
||||
self, artifact_id: UUID, *, limit: int = 200
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await self._rels.list_for_artifact(self._tenant, artifact_id, limit=limit)
|
||||
Reference in New Issue
Block a user