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:
@@ -26,19 +26,32 @@ import asyncio
|
||||
import datetime
|
||||
import io
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import msal
|
||||
|
||||
from config import settings
|
||||
from storage.base import StorageBackend
|
||||
from storage.cloud_base import (
|
||||
ACTIONS,
|
||||
REASON_PROVIDER_UNSUPPORTED,
|
||||
REASON_REAUTH_REQUIRED,
|
||||
STATE_SUPPORTED,
|
||||
STATE_TEMPORARILY_UNAVAILABLE,
|
||||
STATE_UNSUPPORTED,
|
||||
CloudCapability,
|
||||
CloudListing,
|
||||
CloudResource,
|
||||
CloudResourceAdapter,
|
||||
)
|
||||
from storage.google_drive_backend import CloudConnectionError # reuse shared exception
|
||||
|
||||
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB — above Graph's 4 MB simple upload limit (Pitfall 6)
|
||||
|
||||
|
||||
class OneDriveBackend(StorageBackend):
|
||||
class OneDriveBackend(StorageBackend, CloudResourceAdapter):
|
||||
"""Microsoft Graph / OneDrive implementation of StorageBackend.
|
||||
|
||||
Uses MSAL for token management and httpx for async HTTP to Microsoft Graph.
|
||||
@@ -276,3 +289,130 @@ class OneDriveBackend(StorageBackend):
|
||||
return r.is_success
|
||||
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 OneDrive folder.
|
||||
|
||||
parent_ref=None browses the drive root.
|
||||
Follows @odata.nextLink for complete pagination.
|
||||
Never downloads file bytes or mutates provider content.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_valid_token()
|
||||
except CloudConnectionError:
|
||||
return CloudListing(items=(), complete=False)
|
||||
|
||||
if parent_ref:
|
||||
url = f"{GRAPH_BASE}/me/drive/items/{parent_ref}/children"
|
||||
else:
|
||||
url = f"{GRAPH_BASE}/me/drive/root/children"
|
||||
|
||||
# $select: id, name, folder/file facets, size, modified, eTag/cTag
|
||||
select = "id,name,folder,file,size,lastModifiedDateTime,eTag,cTag,parentReference"
|
||||
if page_token:
|
||||
url = page_token # nextLink already embeds select/expand
|
||||
|
||||
resources: list[CloudResource] = []
|
||||
complete = True
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
while url:
|
||||
r = await client.get(
|
||||
url,
|
||||
headers=self._auth_headers(),
|
||||
params={"$select": select} if not page_token else None,
|
||||
timeout=30,
|
||||
)
|
||||
if not r.is_success:
|
||||
complete = False
|
||||
break
|
||||
data = r.json()
|
||||
for item in data.get("value", []):
|
||||
is_folder = "folder" in item
|
||||
kind = "folder" if is_folder else "file"
|
||||
size: Optional[int] = item.get("size") if not is_folder else None
|
||||
mod_str = item.get("lastModifiedDateTime")
|
||||
modified_at: Optional[datetime.datetime] = None
|
||||
if mod_str:
|
||||
try:
|
||||
modified_at = datetime.datetime.fromisoformat(
|
||||
mod_str.replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
content_type: Optional[str] = None
|
||||
if "file" in item:
|
||||
content_type = item["file"].get("mimeType")
|
||||
parent_ref_val = item.get("parentReference", {}).get("id")
|
||||
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=content_type,
|
||||
size=size,
|
||||
modified_at=modified_at,
|
||||
etag=item.get("eTag") or item.get("cTag"),
|
||||
)
|
||||
)
|
||||
url = data.get("@odata.nextLink")
|
||||
except Exception:
|
||||
complete = False
|
||||
|
||||
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 OneDrive.
|
||||
|
||||
Browse is supported when token is valid; mutations pending Phase 13.
|
||||
Never creates, renames, moves, or deletes provider content.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_valid_token()
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
f"{GRAPH_BASE}/me/drive",
|
||||
params={"$select": "id"},
|
||||
headers=self._auth_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
auth_ok = r.is_success
|
||||
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 OneDrive.",
|
||||
)
|
||||
else:
|
||||
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