From eneo-core
Use whenever writing or editing any endpoint under backend/src/intric/api/** (and the services those endpoints call). Enforces Eneo's non-negotiable backend invariants — tenant_id filter on every query, audit entry on every mutation, Pydantic v2 response models, SQLAlchemy 2.0 style, structured logging, zero raw SQL. Load this skill before touching any FastAPI router so the patterns are in context.
How this skill is triggered — by the user, by Claude, or both
Slash command
/eneo-core:fastapi-conventionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Eneo's backend is FastAPI + SQLAlchemy 2.0 + Pydantic v2. This skill is the single source of truth for endpoint conventions. The patterns below have invariants enforced by hooks and subagents (`tenancy-checker`, `audit-auditor`); violating them fails `/eneo-verify`.
Eneo's backend is FastAPI + SQLAlchemy 2.0 + Pydantic v2. This skill is the single source of truth for endpoint conventions. The patterns below have invariants enforced by hooks and subagents (tenancy-checker, audit-auditor); violating them fails /eneo-verify.
tenant_id via the get_current_tenant dependency. No exceptions outside explicitly-global tables.audit_log.create(...) in the service layer (not the router).response_model=<Schema> or a typed return.select(Model).where(...) via sqlalchemy.select. No session.query(), no raw SQL.logger.bind(...) (structlog-style). print is forbidden.from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from intric.auth.dependencies import get_current_tenant
from intric.db.dependencies import get_db_session
from intric.domain.api_keys.schemas import RevokeRequest, RevokeResponse
from intric.domain.api_keys.service import APIKeyService
from intric.domain.tenants.types import Tenant
router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"])
@router.post(
"/{api_key_id}/revoke",
response_model=RevokeResponse,
status_code=status.HTTP_200_OK,
)
async def revoke_api_key(
api_key_id: str,
body: RevokeRequest,
tenant: Tenant = Depends(get_current_tenant),
session: AsyncSession = Depends(get_db_session),
) -> RevokeResponse:
service = APIKeyService(session=session)
try:
key = await service.revoke(
tenant_id=tenant.id,
api_key_id=api_key_id,
reason=body.reason,
actor_user_id=tenant.user_id,
)
except APIKeyNotFound:
raise HTTPException(status_code=404, detail="API key not found")
return RevokeResponse(revoked_at=key.revoked_at)
get_current_tenant returns a Tenant object including .id and .user_id. The service receives tenant_id as an argument, not implicit state.response_model=RevokeResponse forces Pydantic v2 validation on the way out.from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from intric.domain.audit.service import AuditLogService
from intric.domain.api_keys.models import APIKey
from intric.domain.api_keys.exceptions import APIKeyNotFound
class APIKeyService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.audit = AuditLogService(session)
async def revoke(
self,
*,
tenant_id: str,
api_key_id: str,
reason: str,
actor_user_id: str,
) -> APIKey:
stmt = select(APIKey).where(
APIKey.tenant_id == tenant_id,
APIKey.id == api_key_id,
APIKey.revoked_at.is_(None),
)
result = await self.session.execute(stmt)
key = result.scalar_one_or_none()
if key is None:
raise APIKeyNotFound(api_key_id)
now = datetime.now(tz=UTC)
await self.session.execute(
update(APIKey)
.where(APIKey.id == api_key_id)
.values(revoked_at=now, revoked_reason=reason),
)
await self.audit.create(
action="api_key.revoke",
actor=actor_user_id,
resource=api_key_id,
tenant_id=tenant_id,
metadata={"reason": reason},
)
await self.session.commit()
key.revoked_at = now
return key
tenant_id is an explicit argument. The tenancy-checker agent grep's select( and verifies .tenant_id == tenant_id is present; implicit globals fail the check.AuditLogService is injected via the session so tests can assert the row via the same session fixture.from pydantic import BaseModel, Field
class RevokeRequest(BaseModel):
reason: str = Field(min_length=4, max_length=200)
class RevokeResponse(BaseModel):
revoked_at: datetime
Field(min_length=..., pattern=r"...") rather than validators where possible.model_config = ConfigDict(extra="forbid") if strictness matters for the endpoint.| Mistake | Fix |
|---|---|
session.query(APIKey).filter_by(id=...) | select(APIKey).where(APIKey.id == ...) |
raw_sql = "SELECT ..." | Use select(); if a CTE is unavoidable, use sa.text() bound params |
print(f"revoking {id}") | logger.bind(api_key_id=id).info("revoke_requested") |
return {"revoked_at": key.revoked_at} | Define RevokeResponse; response_model=RevokeResponse |
tenant_id = request.state.tenant_id | tenant: Tenant = Depends(get_current_tenant), pass tenant.id explicitly |
| Writing audit in the router | Write audit in the service; router only translates exceptions to HTTP |
Forgetting a test that queries audit_log | Add assert await audit_log_count(tenant_id=..., action="api_key.revoke") == 1 |
Use the shared intric.utils.paginate helper — it returns a typed Page[Model] and handles limit, offset, and next_cursor consistently.
Service raises domain-specific exceptions (subclasses of DomainError); router catches and maps. Do not leak SQL errors to the HTTP layer.
Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
npx claudepluginhub ccimen/eneoplugin --plugin eneo-core