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:
curo1305
2026-06-18 22:45:09 +02:00
parent 71ba0293a5
commit ff33439f0a
5 changed files with 844 additions and 3 deletions
+178 -1
View File
@@ -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