This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+12
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
from .router import api_v1_router
__all__ = ["api_v1_router"]
+109
View File
@@ -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)
+77
View File
@@ -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)
+27
View File
@@ -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
)
+157
View File
@@ -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(),
)
+65
View File
@@ -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)
+78
View File
@@ -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
+41
View File
@@ -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())
+65
View File
@@ -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)
+27
View File
@@ -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)
+78
View File
@@ -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()
+15
View File
@@ -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",
]
+167
View File
@@ -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()
+78
View File
@@ -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",
]
+65
View File
@@ -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)
+174
View File
@@ -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",
]
+199
View File
@@ -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)
+34
View File
@@ -0,0 +1,34 @@
# --- Runtime ---
asf-domain==0.1.0
asf-events==0.1.0
asf-connectors==0.1.0
# --- Web framework ---
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
pydantic>=2.7,<3
pydantic-settings>=2.2,<3
# --- Database / async driver ---
motor>=3.5,<4
# --- Messaging ---
aio-pika>=9.4,<10
# --- Cache ---
redis>=5.0,<6
# --- Object storage ---
minio>=7.2,<8
# --- Observability / utilities ---
structlog>=24.4,<25
python-dotenv>=1.0,<2
python-ulid>=2.7,<3
httpx>=0.27,<1
# --- Dev / Test ---
pytest>=8
pytest-asyncio>=0.23
pytest-cov>=5
httpx>=0.27
View File
+62
View File
@@ -0,0 +1,62 @@
"""Pytest fixtures shared across asf-core tests."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
from asgi_lifespan import LifespanManager
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.middleware.correlation import (
request_context_var,
RequestContext,
)
TENANT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000002")
@pytest.fixture
def tenant_context():
"""Bind a dummy authenticated tenant+user context for the duration of a test."""
ctx = RequestContext(
tenant_id=TENANT_ID,
user_id=USER_ID,
correlation_id="test-corr",
roles=["admin"],
)
token = request_context_var.set(ctx)
yield ctx
request_context_var.reset(token)
@pytest.fixture
async def asf_client(tenant_context): # noqa: ARG001 - side effect
"""Async httpx client pointed at the FastAPI app.
For unit tests in bootstrap mode we skip external infra by NOT starting
the full lifespan; we instead instantiate routes with the tenant
context bound so requests succeed without Mongo/Rabbit reachable.
"""
transport = ASGITransport(app=app)
async with LifespanManager(app, startup_timeout=10):
async with AsyncClient(
transport=transport,
base_url="http://testserver",
headers={
"X-ASF-Tenant-Id": str(TENANT_ID),
"X-ASF-User-Id": str(USER_ID),
},
) as c:
yield c
@pytest.fixture
def utc_now():
return datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
@@ -0,0 +1,136 @@
"""Unit tests for domain services using stubbed repositories (no DB needed)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
import pytest
from unittest.mock import AsyncMock
from asf_domain import (
OrganizationCreate,
ProjectCreate,
ArtifactCreate,
ArtifactType,
ArtifactRelationshipCreate,
RelationshipType,
JobCreate,
JobType,
JobStatus,
)
from app.services import (
OrganizationService,
ProjectService,
ArtifactService,
RelationshipService,
JobService,
)
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
TENANT = uuid4()
USER = uuid4()
pytestmark = pytest.mark.anyio
class _StubAudit:
def __init__(self) -> None:
self.events: list[Any] = []
async def append(self, tenant_id: UUID, body: Any):
self.events.append(body)
return body # stub
# ---------- Organizations ----------
async def test_org_service_create_returns_org():
org_repo = AsyncMock()
from asf_domain import Organization
created = Organization(
id=uuid4(),
name="Contoso",
slug="contoso",
plan="team",
is_active=True,
created_at=NOW,
updated_at=NOW,
)
org_repo.create = AsyncMock(return_value=created)
svc = OrganizationService(org_repo, _StubAudit(), actor_id=USER)
result = await svc.create(OrganizationCreate(name="Contoso", slug="contoso", plan="team"))
assert result.slug == "contoso"
org_repo.create.assert_awaited_once()
# ---------- Projects ----------
async def test_project_service_create_checks_org_exists():
project_repo = AsyncMock()
org_repo = AsyncMock()
org_repo.get = AsyncMock(return_value=None)
svc = ProjectService(project_repo, org_repo, _StubAudit(), tenant_id=TENANT, actor_id=USER)
from app.repositories import NotFoundError
with pytest.raises(NotFoundError):
await svc.create(ProjectCreate(name="Demo", key="DEMO"))
# ---------- Artifacts ----------
async def test_artifact_service_create_checks_project():
artifact_repo = AsyncMock()
project_repo = AsyncMock()
project_repo.get = AsyncMock(return_value=None)
svc = ArtifactService(
artifact_repo, project_repo, _StubAudit(), tenant_id=TENANT, actor_id=USER
)
from app.repositories import NotFoundError
with pytest.raises(NotFoundError):
await svc.create(
ArtifactCreate(
project_id=uuid4(),
type=ArtifactType.REQUIREMENT,
title="Need MFA",
)
)
# ---------- Jobs ----------
async def test_job_service_submit_creates_record():
job_repo = AsyncMock()
project_repo = AsyncMock()
from asf_domain import Job
project_repo.get = AsyncMock(return_value=object()) # truthy
new_job = Job(
id=uuid4(),
tenant_id=TENANT,
project_id=uuid4(),
type=JobType.AI_INTERACTIVE,
status=JobStatus.QUEUED,
payload={"question": "hi"},
created_at=NOW,
usage={},
)
job_repo.create = AsyncMock(return_value=new_job)
svc = JobService(job_repo, project_repo, _StubAudit(), tenant_id=TENANT, actor_id=USER)
result = await svc.submit(
JobCreate(
type=JobType.AI_INTERACTIVE,
payload={"question": "hi"},
project_id=uuid4(),
)
)
assert result.status == JobStatus.QUEUED
job_repo.create.assert_awaited_once()
+26
View File
@@ -0,0 +1,26 @@
"""Smoke tests for health endpoints — do NOT require live Mongo."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.anyio
async def test_root_endpoint_returns_metadata(asf_client):
resp = await asf_client.get("/")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "asf-core"
assert "docs" in body
assert body["health"] == "/api/v1/health"
async def test_health_liveness_ok(asf_client):
resp = await asf_client.get("/api/v1/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["service"] == "asf-core"
assert "uptime_seconds" in body
assert body["environment"] in {"development", "test", "production"}