feat(14.1-02): CloudItemDetailOut schema + resolve_owned_cloud_item_detail + GET detail route
- Add CloudItemDetailOut allowlist schema to backend/api/cloud/schemas.py
(excludes credentials_enc, object_key, version_key, raw provider URLs — T-14.1-03)
- Add CloudItemDetail dataclass and resolve_owned_cloud_item_detail service helper
to backend/services/cloud_items.py (owner-scoped, metadata-only, no byte hydration)
- Add GET /connections/{id}/items/{item_id:path}/detail route to operations.py
with get_regular_user (admin 403), ConnectionNotFound → 404, CloudItemNotFound → 404
- All 8 test_cloud_detail_parity tests now pass (D-05, D-07, D-16, T-14.1-03/04/06)
This commit is contained in:
@@ -47,6 +47,7 @@ from fastapi.responses import JSONResponse, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.cloud.schemas import (
|
||||
CloudItemDetailOut,
|
||||
CreateFolderRequest,
|
||||
MoveItemRequest,
|
||||
MutationResultOut,
|
||||
@@ -59,7 +60,9 @@ from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from services.cloud_items import (
|
||||
CloudItemNotFound,
|
||||
ConnectionNotFound,
|
||||
resolve_owned_cloud_item_detail,
|
||||
update_folder_state,
|
||||
upsert_cloud_item,
|
||||
)
|
||||
@@ -280,6 +283,84 @@ async def open_cloud_file(
|
||||
}
|
||||
|
||||
|
||||
# ── Cloud item detail endpoint ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/connections/{connection_id}/items/{item_id:path}/detail",
|
||||
response_model=CloudItemDetailOut,
|
||||
)
|
||||
@account_limiter.limit("120/minute")
|
||||
async def get_cloud_item_detail(
|
||||
connection_id: uuid.UUID,
|
||||
item_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> CloudItemDetailOut:
|
||||
"""Return owner-scoped cloud item detail with analysis fields and source metadata.
|
||||
|
||||
Endpoint contract (Phase 14.1 Plan 02):
|
||||
- Returns extracted_text, analysis_status, semantic_index_status, topics,
|
||||
provider, display_name, location, capabilities, and unsupported_analysis_reason.
|
||||
- Foreign user gets 404 (IDOR protection — T-14.1-04).
|
||||
- Admin is blocked by get_regular_user (T-14.1-04).
|
||||
- No provider bytes are downloaded — metadata-only DB reads (T-14.1-06, CACHE-03).
|
||||
- response_model=CloudItemDetailOut enforces allowlist — no credentials_enc,
|
||||
object_key, version_key, or raw provider URLs can leak (T-14.1-03).
|
||||
|
||||
D-05: Once analysis completes, detail shows extracted text, topics, and status.
|
||||
D-07: Stale items retain prior extracted_text and topics; is_stale=True signals state.
|
||||
D-16: unsupported_analysis_reason present when analysis is not possible.
|
||||
"""
|
||||
request.state.current_user = current_user
|
||||
|
||||
try:
|
||||
detail = await resolve_owned_cloud_item_detail(
|
||||
session,
|
||||
user_id=current_user.id,
|
||||
connection_id=connection_id,
|
||||
provider_item_id=item_id,
|
||||
)
|
||||
except ConnectionNotFound:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Connection not found",
|
||||
)
|
||||
except CloudItemNotFound:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Cloud item not found",
|
||||
)
|
||||
|
||||
# Map the credential-free dataclass to the allowlist response schema.
|
||||
# capabilities is left empty here — the detail surface does not call the provider
|
||||
# for live capability resolution (that would require credential decryption, which
|
||||
# violates the metadata-only constraint). An empty dict is safe: the frontend can
|
||||
# infer available actions from analysis_status and unsupported_analysis_reason.
|
||||
return CloudItemDetailOut(
|
||||
id=detail.id,
|
||||
provider_item_id=detail.provider_item_id,
|
||||
name=detail.name,
|
||||
kind=detail.kind,
|
||||
parent_ref=detail.parent_ref,
|
||||
content_type=detail.content_type,
|
||||
size=detail.size,
|
||||
modified_at=detail.modified_at,
|
||||
etag=detail.etag,
|
||||
provider=detail.provider,
|
||||
display_name=detail.display_name,
|
||||
location=detail.location,
|
||||
analysis_status=detail.analysis_status,
|
||||
semantic_index_status=detail.semantic_index_status,
|
||||
extracted_text=detail.extracted_text,
|
||||
topics=detail.topics,
|
||||
capabilities={},
|
||||
unsupported_analysis_reason=detail.unsupported_analysis_reason,
|
||||
is_stale=detail.is_stale,
|
||||
)
|
||||
|
||||
|
||||
# ── Preview endpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
def _unsupported_preview_response(connection_id: uuid.UUID, item_id: str, reason: str) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user