Commit 5a7791d1 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

chore: Integrate Paperclip with FastAPI and resolve API case discrepancies,...

chore: Integrate Paperclip with FastAPI and resolve API case discrepancies, custom endpoints, and route issues
parent ff2472d4
......@@ -19,6 +19,7 @@ from .routes.secrets import router as secrets_router
from .routes.costs import router as costs_router
from .routes.activity import router as activity_router
from .routes.dashboard import router as dashboard_router
from .routes.heartbeat_runs import router as heartbeat_runs_router
from .routes.environments import router as environments_router
from .routes.execution_workspaces import router as execution_workspaces_router
from .routes.documents import router as documents_router
......@@ -91,6 +92,7 @@ api_sub_router.include_router(secrets_router)
api_sub_router.include_router(costs_router)
api_sub_router.include_router(activity_router)
api_sub_router.include_router(dashboard_router)
api_sub_router.include_router(heartbeat_runs_router)
api_sub_router.include_router(environments_router)
api_sub_router.include_router(execution_workspaces_router)
api_sub_router.include_router(documents_router)
......
......@@ -64,7 +64,7 @@ async def create_agent(
return db_agent
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[AgentResponse])
async def list_agents(
company_id: str = Query(..., description="Company ID"),
status: Optional[str] = Query(None, description="Filter by status"),
......@@ -91,17 +91,10 @@ async def list_agents(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
agents = [AgentResponse.model_validate(row._mapping["Agent"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": agents,
}
return agents
@router.get("/{agent_id}", response_model=AgentResponse)
......
......@@ -63,7 +63,7 @@ async def create_approval(
return db_approval
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[ApprovalResponse])
async def list_approvals(
company_id: str = Query(..., description="Company ID"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
......@@ -86,17 +86,10 @@ async def list_approvals(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
approvals = [ApprovalResponse.model_validate(row._mapping["Approval"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": approvals,
}
return approvals
@router.get("/{approval_id}", response_model=ApprovalResponse)
......
"""
Company routes.
"""
from typing import Optional
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
......@@ -60,7 +56,7 @@ async def create_company(
return db_company
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[CompanyResponse])
async def list_companies(
pagination: PaginationParams = Depends(),
include_deleted: bool = Query(False, alias="include_deleted"),
......@@ -79,7 +75,7 @@ async def list_companies(
company_ids = [m.company_id for m in memberships]
if not company_ids:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
# Query companies with pagination
stmt = select(Company, func.count("*").over().label("total")).where(
......@@ -91,17 +87,10 @@ async def list_companies(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
companies = [CompanyResponse.model_validate(row._mapping["Company"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": companies,
}
return companies
@router.get("/{company_id}", response_model=CompanyResponse)
......
......@@ -2,7 +2,7 @@
Company membership routes.
"""
from typing import Optional
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
......@@ -107,6 +107,196 @@ async def list_memberships(
}
# --- Custom endpoints added for Paperclip client compatibility ---
@router.get("/members")
async def get_company_members(
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user)
):
# Fetch memberships for company where principal_type == "user"
stmt = (
select(CompanyMembership, AuthUser)
.outerjoin(AuthUser, CompanyMembership.principal_id == AuthUser.id)
.where(
CompanyMembership.company_id == company_id,
CompanyMembership.principal_type == "user"
)
)
result = await db.execute(stmt)
rows = result.fetchall()
members = []
current_user_role = None
for row in rows:
membership = row.CompanyMembership
user = row.AuthUser
member_data = {
"id": membership.id,
"companyId": membership.company_id,
"principalType": membership.principal_type,
"principalId": membership.principal_id,
"status": membership.status,
"membershipRole": membership.membership_role,
"createdAt": membership.created_at,
"updatedAt": membership.updated_at,
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"image": user.image
} if user else None,
"grants": []
}
members.append(member_data)
if membership.principal_id == current_user.id:
current_user_role = membership.membership_role
# Default to admin role if not set or user is admin
if not current_user_role:
current_user_role = "admin"
return {
"members": members,
"access": {
"currentUserRole": current_user_role,
"canManageMembers": current_user_role in ("owner", "admin", "instance_admin"),
"canInviteUsers": current_user_role in ("owner", "admin", "instance_admin"),
"canApproveJoinRequests": current_user_role in ("owner", "admin", "instance_admin")
}
}
@router.get("/user-directory")
async def get_user_directory(
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
stmt = (
select(CompanyMembership, AuthUser)
.join(AuthUser, CompanyMembership.principal_id == AuthUser.id)
.where(
CompanyMembership.company_id == company_id,
CompanyMembership.principal_type == "user",
CompanyMembership.status == "active"
)
)
result = await db.execute(stmt)
rows = result.fetchall()
users = []
for row in rows:
membership = row.CompanyMembership
user = row.AuthUser
users.append({
"principalId": membership.principal_id,
"status": "active",
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"image": user.image
}
})
return {"users": users}
@router.get("/join-requests")
async def get_join_requests(
company_id: str = Query(..., description="Company ID"),
status: str = Query("pending_approval"),
db: AsyncSession = Depends(get_db_session)
):
# Map status to database status
db_status = "pending"
if status == "approved":
db_status = "active"
elif status == "rejected":
db_status = "suspended"
stmt = (
select(CompanyMembership, AuthUser)
.join(AuthUser, CompanyMembership.principal_id == AuthUser.id)
.where(
CompanyMembership.company_id == company_id,
CompanyMembership.principal_type == "user",
CompanyMembership.status == db_status
)
)
result = await db.execute(stmt)
rows = result.fetchall()
join_requests = []
for row in rows:
membership = row.CompanyMembership
user = row.AuthUser
join_requests.append({
"id": membership.id,
"companyId": membership.company_id,
"principalType": membership.principal_type,
"principalId": membership.principal_id,
"status": "pending_approval" if membership.status == "pending" else membership.status,
"membershipRole": membership.membership_role,
"createdAt": membership.created_at,
"updatedAt": membership.updated_at,
"requesterUser": {
"id": user.id,
"email": user.email,
"name": user.name,
"image": user.image
},
"approvedByUser": None,
"rejectedByUser": None,
"invite": None
})
return join_requests
@router.post("/join-requests/{request_id}/approve")
async def approve_join_request(
request_id: str,
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
result = await db.execute(
select(CompanyMembership).where(
CompanyMembership.id == request_id,
CompanyMembership.company_id == company_id
)
)
membership = result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=404, detail="Join request not found")
membership.status = "active"
await db.commit()
return {"id": request_id, "status": "approved"}
@router.post("/join-requests/{request_id}/reject")
async def reject_join_request(
request_id: str,
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
result = await db.execute(
select(CompanyMembership).where(
CompanyMembership.id == request_id,
CompanyMembership.company_id == company_id
)
)
membership = result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=404, detail="Join request not found")
membership.status = "suspended"
await db.commit()
return {"id": request_id, "status": "rejected"}
# --- Standard Wildcard Endpoints ---
@router.get("/{membership_id}", response_model=CompanyMembershipResponse)
async def get_membership(
membership_id: str,
......@@ -187,3 +377,4 @@ async def update_membership(
await db.commit()
return membership
......@@ -6,15 +6,109 @@ from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime, timedelta
from schemas.common import CompanyScope
from database import get_db_session
from middleware.auth import require_company_scope, get_current_user
from models import Company, Issue, Agent, CostEvent
from models import Company, Issue, Agent, CostEvent, Approval
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("")
async def get_dashboard_summary(
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
"""Get dashboard summary matching DashboardSummary schema."""
# 1. Agents count
agents_result = await db.execute(
select(Agent).where(Agent.company_id == company_id)
)
agents = agents_result.scalars().all()
active_agents = sum(1 for a in agents if a.status == "active")
paused_agents = sum(1 for a in agents if a.status == "paused")
error_agents = sum(1 for a in agents if a.status == "error")
running_agents = active_agents
# 2. Tasks count
issues_result = await db.execute(
select(Issue).where(Issue.company_id == company_id)
)
issues = issues_result.scalars().all()
open_issues = sum(1 for i in issues if i.status == "open" or i.status == "todo")
in_progress = sum(1 for i in issues if i.status == "in_progress")
blocked = sum(1 for i in issues if i.status == "blocked")
done = sum(1 for i in issues if i.status == "done")
# 3. Costs
company_result = await db.execute(
select(Company).where(Company.id == company_id)
)
company = company_result.scalar_one_or_none()
month_budget = company.budget_monthly_cents if company else 0
spend_result = await db.execute(
select(func.sum(CostEvent.cost_cents)).where(
CostEvent.company_id == company_id
)
)
month_spend = spend_result.scalar() or 0
util_percent = (month_spend / month_budget * 100.0) if month_budget > 0 else 0.0
# 4. Approvals
approvals_result = await db.execute(
select(func.count()).select_from(Approval).where(
Approval.company_id == company_id,
Approval.resolved_at.is_(None)
)
)
pending_approvals = approvals_result.scalar() or 0
# 5. Run Activity
run_activity = []
today = datetime.utcnow().date()
for i in range(6, -1, -1):
day = today - timedelta(days=i)
run_activity.append({
"date": day.isoformat(),
"succeeded": 0,
"failed": 0,
"other": 0,
"total": 0
})
return {
"companyId": company_id,
"agents": {
"active": active_agents,
"running": running_agents,
"paused": paused_agents,
"error": error_agents
},
"tasks": {
"open": open_issues,
"inProgress": in_progress,
"blocked": blocked,
"done": done
},
"costs": {
"monthSpendCents": month_spend,
"monthBudgetCents": month_budget,
"monthUtilizationPercent": util_percent
},
"pendingApprovals": pending_approvals,
"budgets": {
"activeIncidents": 0,
"pendingApprovals": pending_approvals,
"pausedAgents": paused_agents,
"pausedProjects": 0
},
"runActivity": run_activity
}
@router.get("/stats")
async def get_dashboard_stats(
company_id: str = Query(..., description="Company ID"),
......
......@@ -2,7 +2,7 @@
Environment routes.
"""
from typing import Optional
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
......@@ -60,7 +60,7 @@ async def create_environment(
return db_environment
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[EnvironmentResponse])
async def list_environments(
company_id: str = Query(..., description="Company ID"),
scope: CompanyScope = Depends(require_company_scope),
......@@ -76,17 +76,10 @@ async def list_environments(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
environments = [EnvironmentResponse.model_validate(row._mapping["Environment"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": environments,
}
return environments
@router.get("/{environment_id}", response_model=EnvironmentResponse)
......
......@@ -61,7 +61,7 @@ async def create_goal(
return db_goal
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[GoalResponse])
async def list_goals(
company_id: str = Query(..., description="Company ID"),
project_id: Optional[str] = Query(None, description="Filter by project"),
......@@ -88,17 +88,10 @@ async def list_goals(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
goals = [GoalResponse.model_validate(row._mapping["Goal"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": goals,
}
return goals
@router.get("/{goal_id}", response_model=GoalResponse)
......
"""
Heartbeat runs routes.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import List, Optional
from datetime import datetime
from database import get_db_session
from middleware.auth import require_company_scope, get_current_user
from schemas.auth import AuthUserResponse
from schemas.common import PaginationParams, CompanyScope
from models import HeartbeatRun, Agent
router = APIRouter(tags=["heartbeats"])
# Define schemas for compatibility
from pydantic import BaseModel, ConfigDict
class HeartbeatRunSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
company_id: str
agent_id: str
issue_id: Optional[str] = None
status: str
started_at: datetime
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
created_at: datetime
updated_at: datetime
# Mocked fields for frontend compatibility
invocation_source: str = "system"
trigger_detail: Optional[str] = None
liveness_state: Optional[str] = None
liveness_reason: Optional[str] = None
continuation_attempt: int = 0
last_useful_action_at: Optional[datetime] = None
next_action: Optional[str] = None
class LiveRunSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
status: str
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
agent_id: str
agent_name: str
adapter_type: str
issue_id: Optional[str] = None
# Mocked fields for frontend compatibility
invocation_source: str = "system"
trigger_detail: Optional[str] = None
liveness_state: Optional[str] = None
liveness_reason: Optional[str] = None
continuation_attempt: int = 0
last_useful_action_at: Optional[datetime] = None
next_action: Optional[str] = None
@router.get("/heartbeat-runs", response_model=List[HeartbeatRunSchema])
async def list_heartbeat_runs(
company_id: str = Query(..., description="Company ID"),
agent_id: Optional[str] = Query(None, description="Filter by agent"),
limit: int = Query(50, description="Limit"),
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session)
):
"""List heartbeat runs for a company."""
stmt = select(HeartbeatRun).where(HeartbeatRun.company_id == company_id)
if agent_id:
stmt = stmt.where(HeartbeatRun.agent_id == agent_id)
stmt = stmt.order_by(desc(HeartbeatRun.created_at)).limit(limit)
result = await db.execute(stmt)
runs = result.scalars().all()
return runs
@router.get("/heartbeat-runs/{run_id}", response_model=HeartbeatRunSchema)
async def get_heartbeat_run(
run_id: str,
db: AsyncSession = Depends(get_db_session)
):
"""Get a specific heartbeat run."""
result = await db.execute(select(HeartbeatRun).where(HeartbeatRun.id == run_id))
run = result.scalar_one_or_none()
if not run:
raise HTTPException(status_code=404, detail="Run not found")
return run
@router.get("/live-runs", response_model=List[LiveRunSchema])
async def list_live_runs(
company_id: str = Query(..., description="Company ID"),
min_count: Optional[int] = Query(None, alias="minCount"),
limit: int = Query(50, description="Limit"),
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session)
):
"""List active live runs for a company."""
stmt = (
select(
HeartbeatRun.id,
HeartbeatRun.status,
HeartbeatRun.started_at,
HeartbeatRun.completed_at,
HeartbeatRun.created_at,
HeartbeatRun.agent_id,
HeartbeatRun.issue_id,
Agent.name.label("agent_name"),
Agent.adapter_type.label("adapter_type")
)
.join(Agent, HeartbeatRun.agent_id == Agent.id)
.where(HeartbeatRun.company_id == company_id)
.order_by(desc(HeartbeatRun.created_at))
.limit(limit)
)
result = await db.execute(stmt)
rows = result.fetchall()
live_runs = []
for row in rows:
live_runs.append({
"id": row.id,
"status": row.status,
"started_at": row.started_at,
"completed_at": row.completed_at,
"created_at": row.created_at,
"agent_id": row.agent_id,
"agent_name": row.agent_name,
"adapter_type": row.adapter_type,
"issue_id": row.issue_id
})
return live_runs
@router.post("/heartbeat-runs/{run_id}/cancel")
async def cancel_heartbeat_run(
run_id: str,
db: AsyncSession = Depends(get_db_session)
):
"""Cancel a heartbeat run."""
result = await db.execute(select(HeartbeatRun).where(HeartbeatRun.id == run_id))
run = result.scalar_one_or_none()
if not run:
raise HTTPException(status_code=404, detail="Run not found")
run.status = "cancelled"
run.completed_at = datetime.utcnow()
await db.commit()
return {"status": "cancelled"}
......@@ -2,7 +2,7 @@
Issue routes.
"""
from typing import Optional
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status, Body
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
......@@ -77,7 +77,7 @@ async def create_issue(
return db_issue
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[IssueResponse])
async def list_issues(
company_id: str = Query(..., description="Company ID"),
project_id: Optional[str] = Query(None, description="Filter by project"),
......@@ -110,17 +110,10 @@ async def list_issues(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
issues = [IssueResponse.model_validate(row._mapping["Issue"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": issues,
}
return issues
@router.get("/{issue_id}", response_model=IssueResponse)
......
......@@ -74,7 +74,7 @@ async def create_project(
return db_project
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[ProjectResponse])
async def list_projects(
company_id: str = Query(..., description="Company ID"),
goal_id: Optional[str] = Query(None, description="Filter by goal"),
......@@ -101,17 +101,10 @@ async def list_projects(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
projects = [ProjectResponse.model_validate(row._mapping["Project"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": projects,
}
return projects
@router.get("/{project_id}", response_model=ProjectResponse)
......
......@@ -71,7 +71,9 @@ async def create_routine(
return db_routine
@router.get("", response_model=PaginatedResponse)
from typing import Optional, List
@router.get("", response_model=List[RoutineResponse])
async def list_routines(
company_id: str = Query(..., description="Company ID"),
is_enabled: Optional[bool] = Query(None, description="Filter by enabled status"),
......@@ -91,17 +93,10 @@ async def list_routines(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
routines = [RoutineResponse.model_validate(row._mapping["Routine"]) for row in rows]
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": routines,
}
return routines
@router.get("/{routine_id}", response_model=RoutineResponse)
......
......@@ -2,7 +2,7 @@
Secret routes.
"""
from typing import Optional
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
......@@ -65,7 +65,7 @@ async def create_secret(
return db_secret
@router.get("", response_model=PaginatedResponse)
@router.get("", response_model=List[CompanySecretResponse])
async def list_secrets(
company_id: str = Query(..., description="Company ID"),
include_deleted: bool = Query(False, description="Include deleted secrets"),
......@@ -85,9 +85,8 @@ async def list_secrets(
rows = result.fetchall()
if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []}
return []
total = rows[0].total
secrets = []
for row in rows:
secret = CompanySecretResponse.model_validate(row._mapping["CompanySecret"])
......@@ -95,12 +94,7 @@ async def list_secrets(
secret.value_encrypted = "***" if secret.is_sensitive else secret.value_encrypted
secrets.append(secret)
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": secrets,
}
return secrets
@router.get("/{secret_id}", response_model=CompanySecretResponse)
......
......@@ -23,5 +23,8 @@ class ActivityLogCreate(ActivityLogBase):
model_config = ConfigDict(from_attributes=True)
class ActivityLogResponse(ActivityLogBase, TimestampMixin):
id: str
\ No newline at end of file
class ActivityLogResponse(ActivityLogBase):
model_config = ConfigDict(from_attributes=True)
id: str
occurred_at: datetime
\ No newline at end of file
......@@ -10,7 +10,7 @@ import time
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from api.main_router import main_router as api_router
......@@ -84,6 +84,90 @@ async def lifespan(app: FastAPI):
import re
import json
from urllib.parse import parse_qsl, urlencode
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
def camel_to_snake(name: str) -> str:
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
def snake_to_camel(name: str) -> str:
components = name.split('_')
if not components:
return name
return components[0] + ''.join(x.title() for x in components[1:])
def convert_keys(data, convert_fn):
if isinstance(data, list):
return [convert_keys(item, convert_fn) for item in data]
elif isinstance(data, dict):
return {convert_fn(k): convert_keys(v, convert_fn) for k, v in data.items()}
else:
return data
class CaseConversionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
is_api = request.url.path.startswith("/api")
# 1. Convert incoming Query String (camelCase -> snake_case)
if is_api and request.url.query:
try:
query_bytes = request.scope.get("query_string", b"")
if query_bytes:
query_str = query_bytes.decode("utf-8")
parsed = parse_qsl(query_str, keep_blank_values=True)
converted = [(camel_to_snake(k), v) for k, v in parsed]
request.scope["query_string"] = urlencode(converted).encode("utf-8")
except Exception:
pass
# 2. Convert incoming JSON request body (camelCase -> snake_case)
if is_api and request.method in ("POST", "PUT", "PATCH", "DELETE"):
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
try:
body = await request.body()
if body:
data = json.loads(body)
converted_data = convert_keys(data, camel_to_snake)
request._body = json.dumps(converted_data, ensure_ascii=False).encode("utf-8")
except Exception:
pass
# 3. Call next middleware/route handler
response = await call_next(request)
# 4. Convert outgoing JSON response body (snake_case -> camelCase)
if is_api:
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
try:
if hasattr(response, "body"):
body = response.body
else:
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
body = b"".join(chunks)
if body:
data = json.loads(body)
converted_data = convert_keys(data, snake_to_camel)
new_body = json.dumps(converted_data, ensure_ascii=False).encode("utf-8")
response = Response(
content=new_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type="application/json"
)
response.headers["content-length"] = str(len(new_body))
except Exception:
pass
return response
class CompanyPathRewriteMiddleware:
def __init__(self, app):
......@@ -98,6 +182,16 @@ class CompanyPathRewriteMiddleware:
company_id = match.group(2)
resource_path = match.group(3)
# Map specific resource paths for compatibility
if resource_path.startswith("skills"):
resource_path = re.sub(r"^skills(\b|/)", "company-skills\\1", resource_path)
elif resource_path.startswith("members"):
resource_path = re.sub(r"^members(\b|/)", "company-memberships/members\\1", resource_path)
elif resource_path.startswith("user-directory"):
resource_path = re.sub(r"^user-directory(\b|/)", "company-memberships/user-directory\\1", resource_path)
elif resource_path.startswith("join-requests"):
resource_path = re.sub(r"^join-requests(\b|/)", "company-memberships/join-requests\\1", resource_path)
# Rewrite path
scope["path"] = f"{api_prefix}/{resource_path}"
......@@ -118,8 +212,19 @@ app = FastAPI(
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(CaseConversionMiddleware)
app.add_middleware(CompanyPathRewriteMiddleware)
@app.websocket("/api/events/ws")
async def websocket_events_endpoint(websocket: WebSocket):
await websocket.accept()
try:
while True:
# Keep client connected and drop messages
data = await websocket.receive_text()
except WebSocketDisconnect:
pass
@app.get("/")
async def root():
......@@ -254,7 +359,7 @@ if __name__ == "__main__":
print(f"Event Daily: http://localhost:{PORT}/static/event_daily/index.html")
print("=" * 60)
ENABLE_RELOAD = False
ENABLE_RELOAD = True
print(f"Hot reload: {ENABLE_RELOAD}")
reload_dirs = ["common", "api", "agent"]
......
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from fastapi.encoders import jsonable_encoder
class CamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
from_attributes=True,
)
class CompanyResponse(CamelModel):
id: str
name: str
issue_prefix: str
budget_monthly_cents: int
# Test serialization
company = CompanyResponse(
id="123",
name="Canifa",
issue_prefix="CNF",
budget_monthly_cents=500000
)
encoded = jsonable_encoder(company)
print("Encoded:", encoded)
# Test deserialization from camelCase
data = {"id": "123", "name": "Canifa", "issuePrefix": "CNF", "budgetMonthlyCents": 500000}
parsed = CompanyResponse.model_validate(data)
print("Parsed:", parsed)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment