feat(12-02): normalize all four providers into CloudResourceAdapter contract
- GoogleDriveBackend: list_folder with full pagination, native doc size=None, get_capabilities - OneDriveBackend: list_folder with @odata.nextLink pagination, get_capabilities - WebDAVBackend: list_folder via PROPFIND with SSRF re-validation, get_capabilities - NextcloudBackend: inherits CloudResourceAdapter from WebDAVBackend - build_cloud_resource_adapter() added to factory - 22 new contract/behavior/pagination/no-byte-download tests (51 total pass)
This commit is contained in:
@@ -27,6 +27,7 @@ import asyncio
|
||||
import datetime
|
||||
import io
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
@@ -35,10 +36,41 @@ from googleapiclient.errors import HttpError
|
||||
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
|
||||
|
||||
from storage.base import StorageBackend
|
||||
from storage.cloud_base import (
|
||||
ACTIONS,
|
||||
REASON_INSUFFICIENT_SCOPE,
|
||||
REASON_PROVIDER_UNSUPPORTED,
|
||||
REASON_REAUTH_REQUIRED,
|
||||
STATE_SUPPORTED,
|
||||
STATE_TEMPORARILY_UNAVAILABLE,
|
||||
STATE_UNSUPPORTED,
|
||||
CloudCapability,
|
||||
CloudListing,
|
||||
CloudResource,
|
||||
CloudResourceAdapter,
|
||||
)
|
||||
from storage.exceptions import CloudConnectionError # noqa: F401 re-exported for import compatibility
|
||||
|
||||
# Fields requested on every Drive files().list() call — explicit whitelist, no bytes
|
||||
_DRIVE_LIST_FIELDS = (
|
||||
"nextPageToken,"
|
||||
"files(id,name,mimeType,size,modifiedTime,md5Checksum,parents,"
|
||||
"capabilities(canEdit,canDelete,canRename,canMoveItemWithinDrive))"
|
||||
)
|
||||
|
||||
class GoogleDriveBackend(StorageBackend):
|
||||
_GOOGLE_NATIVE_MIMETYPES = frozenset({
|
||||
"application/vnd.google-apps.document",
|
||||
"application/vnd.google-apps.spreadsheet",
|
||||
"application/vnd.google-apps.presentation",
|
||||
"application/vnd.google-apps.form",
|
||||
"application/vnd.google-apps.drawing",
|
||||
"application/vnd.google-apps.map",
|
||||
"application/vnd.google-apps.site",
|
||||
"application/vnd.google-apps.script",
|
||||
})
|
||||
|
||||
|
||||
class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
|
||||
"""Google Drive v3 implementation of StorageBackend.
|
||||
|
||||
Every sync googleapiclient call is wrapped in asyncio.to_thread() (Pitfall 7).
|
||||
@@ -238,3 +270,148 @@ class GoogleDriveBackend(StorageBackend):
|
||||
return await asyncio.to_thread(_check)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── CloudResourceAdapter interface ────────────────────────────────────────
|
||||
|
||||
async def list_folder(
|
||||
self,
|
||||
connection_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
parent_ref: Optional[str] = None,
|
||||
page_token: Optional[str] = None,
|
||||
) -> CloudListing:
|
||||
"""List direct children of a Drive folder.
|
||||
|
||||
parent_ref=None browses the Drive root ('root').
|
||||
Follows nextPageToken for complete pagination before returning.
|
||||
Native Google document types (Docs, Sheets, etc.) are represented with
|
||||
size=None — never treated as folders.
|
||||
Never downloads file bytes or mutates provider content.
|
||||
"""
|
||||
folder_id = parent_ref if parent_ref else "root"
|
||||
|
||||
def _list_all() -> tuple[list[dict], bool]:
|
||||
service = self._get_service()
|
||||
items: list[dict] = []
|
||||
next_token = page_token
|
||||
complete = True
|
||||
try:
|
||||
while True:
|
||||
params: dict = {
|
||||
"q": f"'{folder_id}' in parents and trashed=false",
|
||||
"fields": _DRIVE_LIST_FIELDS,
|
||||
"pageSize": 200,
|
||||
}
|
||||
if next_token:
|
||||
params["pageToken"] = next_token
|
||||
resp = service.files().list(**params).execute()
|
||||
items.extend(resp.get("files", []))
|
||||
next_token = resp.get("nextPageToken")
|
||||
if not next_token:
|
||||
break
|
||||
except HttpError as exc:
|
||||
complete = False
|
||||
if exc.resp.status in (401, 403):
|
||||
pass # Will produce empty items with complete=False
|
||||
return items, complete
|
||||
|
||||
try:
|
||||
raw_items, complete = await asyncio.to_thread(_list_all)
|
||||
except Exception:
|
||||
return CloudListing(items=(), complete=False)
|
||||
|
||||
resources: list[CloudResource] = []
|
||||
for item in raw_items:
|
||||
mime = item.get("mimeType", "")
|
||||
is_native = mime in _GOOGLE_NATIVE_MIMETYPES
|
||||
is_folder = mime == "application/vnd.google-apps.folder"
|
||||
kind = "folder" if is_folder else "file"
|
||||
# Native docs have no binary size — represent as None
|
||||
size: Optional[int] = None
|
||||
if not is_native and not is_folder:
|
||||
raw_size = item.get("size")
|
||||
if raw_size is not None:
|
||||
try:
|
||||
size = int(raw_size)
|
||||
except (TypeError, ValueError):
|
||||
size = None
|
||||
modified_str = item.get("modifiedTime")
|
||||
modified_at: Optional[datetime.datetime] = None
|
||||
if modified_str:
|
||||
try:
|
||||
modified_at = datetime.datetime.fromisoformat(
|
||||
modified_str.replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
resources.append(
|
||||
CloudResource(
|
||||
id=uuid.uuid4(),
|
||||
provider_item_id=item["id"],
|
||||
connection_id=connection_id,
|
||||
user_id=user_id,
|
||||
name=item.get("name", ""),
|
||||
kind=kind,
|
||||
parent_ref=parent_ref,
|
||||
content_type=mime if not is_folder else None,
|
||||
size=size,
|
||||
modified_at=modified_at,
|
||||
etag=item.get("md5Checksum"),
|
||||
)
|
||||
)
|
||||
return CloudListing(items=tuple(resources), complete=complete)
|
||||
|
||||
async def get_capabilities(
|
||||
self,
|
||||
connection_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict[str, CloudCapability]:
|
||||
"""Return connection-level capabilities for Google Drive.
|
||||
|
||||
Uses drive.file scope — browse is supported; mutations pending Phase 13.
|
||||
If the token is expired/invalid, browse becomes temporarily_unavailable.
|
||||
Never creates, renames, moves, or deletes provider content.
|
||||
"""
|
||||
def _check_scope() -> bool:
|
||||
"""Verify browse access with a minimal listing call."""
|
||||
service = self._get_service()
|
||||
service.files().list(pageSize=1, fields="files(id)").execute()
|
||||
return True
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_check_scope)
|
||||
auth_ok = True
|
||||
except HttpError as exc:
|
||||
auth_ok = exc.resp.status not in (401, 403)
|
||||
except Exception:
|
||||
auth_ok = False
|
||||
|
||||
caps: dict[str, CloudCapability] = {}
|
||||
for action in ACTIONS:
|
||||
if action == "browse":
|
||||
if auth_ok:
|
||||
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
|
||||
else:
|
||||
caps[action] = CloudCapability(
|
||||
action=action,
|
||||
state=STATE_TEMPORARILY_UNAVAILABLE,
|
||||
reason=REASON_REAUTH_REQUIRED,
|
||||
message="Re-authenticate to browse Google Drive.",
|
||||
)
|
||||
elif action in ("open", "preview"):
|
||||
# Phase 13 mutations — not yet implemented
|
||||
caps[action] = CloudCapability(
|
||||
action=action,
|
||||
state=STATE_UNSUPPORTED,
|
||||
reason=REASON_PROVIDER_UNSUPPORTED,
|
||||
message="Not available in the current phase.",
|
||||
)
|
||||
else:
|
||||
# upload, create_folder, rename, move, delete, change_tracking — Phase 13
|
||||
caps[action] = CloudCapability(
|
||||
action=action,
|
||||
state=STATE_UNSUPPORTED,
|
||||
reason=REASON_PROVIDER_UNSUPPORTED,
|
||||
message="Not available in the current phase.",
|
||||
)
|
||||
return caps
|
||||
|
||||
Reference in New Issue
Block a user